From 754261c9f7a1f302f0cd28488141ab3b80820838 Mon Sep 17 00:00:00 2001 From: Jae Date: Mon, 18 May 2026 18:54:45 -0600 Subject: [PATCH 01/23] feat(run-lifecycle): complete run lifecycle + MQTT pipeline --- README.md | 40 ++ backend/Dockerfile | 6 + backend/api/serializers.py | 2 +- backend/api/views.py | 17 +- backend/databus/celery.py | 4 + backend/pyproject.toml | 1 + backend/realtime_engine/README.md | 41 ++ .../realtime_engine/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../commands/bootstrap_simulator_runs.py | 198 ++++++++++ .../management/commands/mqtt_consumer.py | 146 ++++++++ backend/realtime_engine/tasks.py | 171 ++++----- backend/runs/domain/actions.py | 146 +++++--- backend/runs/domain/events.py | 21 +- backend/runs/domain/guards.py | 352 +++++++++++------- backend/runs/domain/transitions.py | 5 + backend/runs/services/exceptions.py | 5 +- backend/runs/services/lifecycle.py | 12 +- backend/schedule_engine/tasks.py | 313 ++++++++-------- backend/uv.lock | 11 + compose.dev.yml | 26 ++ 21 files changed, 1057 insertions(+), 460 deletions(-) create mode 100644 backend/realtime_engine/README.md create mode 100644 backend/realtime_engine/management/__init__.py create mode 100644 backend/realtime_engine/management/commands/__init__.py create mode 100644 backend/realtime_engine/management/commands/bootstrap_simulator_runs.py create mode 100644 backend/realtime_engine/management/commands/mqtt_consumer.py diff --git a/README.md b/README.md index bf5a52c..dbb763f 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,46 @@ The application will be available at `http://localhost:8000` For the full documentation site, run `mkdocs serve` and visit http://localhost:8000 +## Demo: full run lifecycle + +End-to-end demo of a complete run lifecycle driven by MQTT telemetry from the simulator. + +```bash +# Terminal 1 — start the full databus stack +cd databus && bash scripts/dev.sh + +# Terminal 2 — load GTFS feed + bootstrap simulator-aligned runs +docker compose -f compose.dev.yml exec orchestrator \ + uv run python manage.py loaddata gtfs.json +docker compose -f compose.dev.yml exec orchestrator \ + uv run python manage.py bootstrap_simulator_runs + +# Terminal 3 — start the simulator (wired to databus broker) +cd ../simulator && docker compose up simulator web + +# Terminal 4 — observe (optional) +open http://localhost:8080 # live map +watch ls backend/feed/files/ # GTFS-RT outputs (refresh every 15 s) +``` + +Within ~30 s of starting the simulator: + +- Every run advances `CONFIRMED → TRACKING → IN_PROGRESS` +- `backend/feed/files/vehicle_positions.pb` contains one `FeedEntity` per active run +- `backend/feed/files/trip_updates.pb` contains stop-time predictions + +Killing the simulator triggers `RUN_TRACKING_LOST` after 60 s and +`RUN_TRACKING_EXPIRED → CANCELLED` after 300 s. + +Verify the protobuf output: + +```python +from google.transit import gtfs_realtime_pb2 +msg = gtfs_realtime_pb2.FeedMessage() +msg.ParseFromString(open("backend/feed/files/vehicle_positions.pb", "rb").read()) +print(len(msg.entity)) # should equal the number of active runs +``` + ## 🛣️ Roadmap Where is this going? Check SIMOVI's [roadmap](https://github.com/simovilab/context/blob/main/roadmap.md). diff --git a/backend/Dockerfile b/backend/Dockerfile index 25b1349..070bde4 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -84,3 +84,9 @@ FROM base AS scheduler # -------------------- CMD ["uv", "run", "celery", "-A", "databus", "beat", "--loglevel=info"] + +# -------- MQTT consumer +FROM base AS realtime-consumer +# ----------------------- + +CMD ["uv", "run", "python", "manage.py", "mqtt_consumer"] diff --git a/backend/api/serializers.py b/backend/api/serializers.py index edcd806..983bbf8 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -133,7 +133,7 @@ class CreateRunSerializer(serializers.Serializer): class UpdateRunSerializer(serializers.Serializer): run_id = serializers.CharField(max_length=100) event = serializers.ChoiceField(choices=RunLifecycleEvents) - details = serializers.JSONField() + details = serializers.JSONField(required=False, default=dict) class PositionSerializer(serializers.HyperlinkedModelSerializer): diff --git a/backend/api/views.py b/backend/api/views.py index 3c93f2e..7401f0a 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -206,12 +206,14 @@ def post(self, request): {"status": "error", "step": "operational_validation", "errors": errors}, status=status.HTTP_400_BAD_REQUEST, ) - # Registration of the run (event: RUN_REQUESTED, state: REQUESTED) + # Record creation puts the run in REQUESTED state (run_requested event) try: run = Run.objects.create(**payload) run.vehicle.set([vehicle]) run.operator.set([operator_obj]) payload["run_id"] = run.id + payload["vehicle_id"] = vehicle_id + payload["operator_id"] = operator_id except Exception as e: return Response( { @@ -221,26 +223,23 @@ def post(self, request): }, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) - # First transition: GTFS validation (run_lifecycle_state = REQUESTED) + # REQUESTED → VALIDATED: GTFS consistency check try: - service.process_event(RunLifecycleEvents.RUN_REQUESTED, payload) + service.process_event(RunLifecycleEvents.VALIDATE_RUN, payload) except RunLifecycleError as e: - payload["guards"] = e.errors.attempts.guards - payload["actions"] = e.errors.attempts.actions service.process_event(RunLifecycleEvents.RUN_REJECTED, payload) return Response( {"status": "error", "step": "gtfs_validation", "errors": e.errors}, status=status.HTTP_422_UNPROCESSABLE_ENTITY, ) - # System initialization (run_lifecycle_state = VALIDATED) + # VALIDATED → INITIALIZED: write system state try: - service.process_event(RunLifecycleEvents.VALIDATE_RUN, payload) + service.process_event(RunLifecycleEvents.INITIALIZE_RUN, payload) except RunLifecycleError as e: return Response( {"status": "error", "step": "initialization", "errors": e.errors}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) - # If successful, give a 200 OK response (run_lifecycle_state = INITIALIZED) return Response( { "status": "success", @@ -276,7 +275,7 @@ def post(self, request): ) event = payload.get("event") try: - new_run_lifecycle_state = service.process_event(event, payload) + new_run_lifecycle_state, _guards, _actions = service.process_event(event, payload) except RunLifecycleError as e: return Response( {"status": "error", "errors": e.errors}, diff --git a/backend/databus/celery.py b/backend/databus/celery.py index e535693..6dff3c9 100644 --- a/backend/databus/celery.py +++ b/backend/databus/celery.py @@ -40,4 +40,8 @@ def debug_task(self): "task": "schedule_engine.tasks.build_alerts", "schedule": timedelta(seconds=10), }, + "scan-stale-runs-every-30s": { + "task": "realtime_engine.tasks.scan_stale_runs", + "schedule": timedelta(seconds=30), + }, } diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d601b89..38a8d0c 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "gunicorn>=23.0.0", "kombu>=5.6.2", "mkdocs-material>=9.6.18", + "paho-mqtt>=2.1.0", "pandas>=2.3.3", "pillow>=11.3.0", "prefect>=3.6.12", diff --git a/backend/realtime_engine/README.md b/backend/realtime_engine/README.md new file mode 100644 index 0000000..f4f3c60 --- /dev/null +++ b/backend/realtime_engine/README.md @@ -0,0 +1,41 @@ +# realtime_engine + +Celery worker that processes lifecycle events and runs the MQTT telemetry consumer. + +## MQTT consumer: management command approach + +The MQTT consumer is implemented as a **Django management command** (`manage.py mqtt_consumer`) +started by a dedicated `realtime-consumer` compose service (see `compose.dev.yml`). + +**Why not Celery bootsteps?** +A Celery bootstep would start the MQTT loop inside the Celery worker process, mixing +telemetry I/O with task execution. Keeping them separate means the consumer can reconnect +cleanly without affecting the Celery worker pool, and scaling them independently is +straightforward (e.g. add more Celery workers without adding more MQTT connections). + +## Topic subscriptions + +``` +transit/vehicle/+/position QoS 0 +transit/vehicle/+/progression QoS 0 +transit/vehicle/+/occupancy QoS 0 +``` + +The `data` leaf (static metadata) is not subscribed here — vehicle metadata is written to +Redis by `RunLifecycleActions.update_system_state` when a run is initialized. + +## Lifecycle events fired by the consumer + +| Run state | Trigger condition | Event fired | +|--------------|--------------------------------------------|-------------------------| +| `Confirmed` | Any valid ping received | `run_tracking_started` | +| `Tracking` | `position.speed > 0.5` m/s | `run_started` | +| `No Signal` | Any valid ping received | `run_tracking_restored` | +| `In Progress`| `progression.current_status == STOPPED_AT` at terminal stop | `complete_run` | + +## Stale run scanning + +`scan_stale_runs` runs every 30 s via Celery Beat: + +- `IN_PROGRESS` + staleness > 60 s → `run_tracking_lost` +- `NO_SIGNAL` + staleness > 300 s → `run_tracking_expired` diff --git a/backend/realtime_engine/management/__init__.py b/backend/realtime_engine/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/realtime_engine/management/commands/__init__.py b/backend/realtime_engine/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/realtime_engine/management/commands/bootstrap_simulator_runs.py b/backend/realtime_engine/management/commands/bootstrap_simulator_runs.py new file mode 100644 index 0000000..619e37a --- /dev/null +++ b/backend/realtime_engine/management/commands/bootstrap_simulator_runs.py @@ -0,0 +1,198 @@ +""" +manage.py bootstrap_simulator_runs + +Reads the simulator fleet definition from simulator/sim/shapes.json, +creates one Vehicle + Operator + Run per vehicle, advances each run to +CONFIRMED state via the FSM, and sets vehicle:{id}:current_run in Redis. + +After this command completes, starting the simulator will cause MQTT pings +to trigger RUN_TRACKING_STARTED automatically. + +Usage: + manage.py bootstrap_simulator_runs + manage.py bootstrap_simulator_runs --shapes-json /path/to/shapes.json + manage.py bootstrap_simulator_runs --per-route 2 +""" +import json +import logging +from datetime import date +from pathlib import Path + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from operations.models import Vehicle, Operator +from runs.models import Run +from runs.services.lifecycle import RunLifecycleService +from runs.domain.events import RunLifecycleEvents +from runs.services.exceptions import RunLifecycleError + +logger = logging.getLogger(__name__) + +try: + DEFAULT_SHAPES_JSON = ( + Path(__file__).resolve().parents[5] # up to git.no_sync/ on host + / "simulator" / "sim" / "shapes.json" + ) +except IndexError: + DEFAULT_SHAPES_JSON = Path("/tmp/shapes.json") +PER_ROUTE = 4 + + +class Command(BaseCommand): + help = "Bootstrap simulator-aligned runs for the MQTT demo" + + def add_arguments(self, parser): + parser.add_argument( + "--shapes-json", + default=str(DEFAULT_SHAPES_JSON), + help="Path to simulator shapes.json", + ) + parser.add_argument( + "--per-route", + type=int, + default=PER_ROUTE, + help="Vehicles per route (must match simulator --per-route, default 4)", + ) + parser.add_argument( + "--clear", + action="store_true", + help="Delete all existing simulator runs before bootstrapping", + ) + + def handle(self, **options): + shapes_path = Path(options["shapes_json"]) + if not shapes_path.exists(): + raise CommandError(f"shapes.json not found at {shapes_path}") + + data = json.loads(shapes_path.read_text()) + routes = {r["route_id"]: r for r in data.get("routes", [])} + + # Build the same fleet as the simulator + fleet: list[dict] = [] + unit_num = 1 + for route_id, route in routes.items(): + available_shapes = [ + s for s in route.get("shape_ids", []) if s in data.get("shapes", {}) + ] + if not available_shapes: + continue + for i in range(options["per_route"]): + shape_id = available_shapes[i % len(available_shapes)] + fleet.append({ + "vehicle_id": f"unit-{unit_num:02d}", + "route_id": route_id, + "shape_id": shape_id, + }) + unit_num += 1 + + if not fleet: + raise CommandError("No vehicles derived from shapes.json") + + self.stdout.write(f"Fleet: {len(fleet)} vehicles") + + if options["clear"]: + deleted = Run.objects.filter( + vehicle__id__in=[v["vehicle_id"] for v in fleet] + ).delete() + self.stdout.write(f"Cleared existing runs: {deleted}") + + from feed.models import Feed, Trip + + feed = Feed.objects.filter(is_current=True).first() + if not feed: + raise CommandError( + "No current GTFS feed found. Run: manage.py loaddata gtfs.json" + ) + + service = RunLifecycleService() + User = get_user_model() + + created_runs = [] + with transaction.atomic(): + for spec in fleet: + vehicle_id = spec["vehicle_id"] + route_id = spec["route_id"] + shape_id = spec["shape_id"] + + # Find a GTFS trip matching this route+shape + trip = Trip.objects.filter( + feed=feed, route_id=route_id, shape_id=shape_id + ).first() + if not trip: + self.stderr.write( + f" {vehicle_id}: no trip found for route={route_id} " + f"shape={shape_id} — skipping" + ) + continue + + # Ensure Vehicle exists + vehicle, _ = Vehicle.objects.get_or_create( + id=vehicle_id, + defaults={ + "label": vehicle_id, + "license_plate": vehicle_id, + }, + ) + + # Ensure a synthetic operator exists for this vehicle + username = f"sim_{vehicle_id}" + user, _ = User.objects.get_or_create( + username=username, + defaults={"first_name": "Sim", "last_name": vehicle_id}, + ) + operator_id = f"op-{vehicle_id}" + operator, _ = Operator.objects.get_or_create( + id=operator_id, + defaults={"user": user}, + ) + + # Create the run (REQUESTED state by default) + run = Run.objects.create( + route_id=route_id, + trip_id=trip.trip_id, + direction_id=trip.direction_id, + shape_id=shape_id, + schedule_relationship="SCHEDULED", + start_date=date.today(), + ) + run.vehicle.set([vehicle]) + run.operator.set([operator]) + + payload = { + "run_id": run.id, + "vehicle_id": vehicle_id, + "operator_id": operator.id, + "route_id": route_id, + "trip_id": trip.trip_id, + "direction_id": trip.direction_id, + "shape_id": shape_id, + "schedule_relationship": "SCHEDULED", + } + + try: + service.process_event(RunLifecycleEvents.VALIDATE_RUN, payload) + service.process_event(RunLifecycleEvents.INITIALIZE_RUN, payload) + service.process_event( + RunLifecycleEvents.RUN_CONFIRMED_BY_OPERATOR, payload + ) + except RunLifecycleError as exc: + self.stderr.write( + f" {vehicle_id}: FSM error — {exc.errors} — skipping" + ) + run.delete() + continue + + created_runs.append((vehicle_id, run.id, trip.trip_id)) + self.stdout.write( + f" ✓ {vehicle_id} run={run.id} trip={trip.trip_id} " + f"route={route_id} shape={shape_id}" + ) + + self.stdout.write( + self.style.SUCCESS( + f"\nBootstrap complete: {len(created_runs)}/{len(fleet)} runs in CONFIRMED state." + ) + ) + self.stdout.write("Start the simulator — first MQTT pings will trigger RUN_TRACKING_STARTED.") diff --git a/backend/realtime_engine/management/commands/mqtt_consumer.py b/backend/realtime_engine/management/commands/mqtt_consumer.py new file mode 100644 index 0000000..ddf7a06 --- /dev/null +++ b/backend/realtime_engine/management/commands/mqtt_consumer.py @@ -0,0 +1,146 @@ +""" +Django management command: mqtt_consumer + +Long-lived MQTT subscriber that bridges vehicle telemetry into the run +lifecycle FSM. Run by the 'realtime-consumer' compose service. + +Topic pattern: transit/vehicle/+/{position,progression,occupancy,data} +""" +import json +import logging +import os +import time + +import redis +from django.core.management.base import BaseCommand +from django.utils.timezone import now + +import paho.mqtt.client as mqtt + +logger = logging.getLogger(__name__) + +MQTT_HOST = os.getenv("MQTT_HOST", "telemetry-broker") +MQTT_PORT = int(os.getenv("MQTT_PORT", "1883")) + +_redis = redis.Redis( + host=os.getenv("REDIS_HOST", "state"), + port=int(os.getenv("REDIS_PORT", "6379")), + db=0, + decode_responses=True, +) + + +def _vehicle_id_from_topic(topic: str) -> str | None: + """Extract vehicle_id from transit/vehicle//.""" + parts = topic.split("/") + if len(parts) == 4 and parts[0] == "transit" and parts[1] == "vehicle": + return parts[2] + return None + + +def _leaf_from_topic(topic: str) -> str | None: + parts = topic.split("/") + return parts[-1] if len(parts) == 4 else None + + +def _handle_telemetry(vehicle_id: str, leaf: str, payload_bytes: bytes) -> None: + try: + data = json.loads(payload_bytes) + except (json.JSONDecodeError, ValueError): + logger.warning("Non-JSON payload on vehicle %s/%s — ignored", vehicle_id, leaf) + return + + run_id_bytes = _redis.get(f"vehicle:{vehicle_id}:current_run") + if not run_id_bytes: + logger.debug("No active run for vehicle %s — dropping %s", vehicle_id, leaf) + return + + run_id = run_id_bytes + + # Persist raw telemetry so GTFS-RT builders can read it + if isinstance(data, dict): + _redis.hset(f"vehicle:{vehicle_id}:{leaf}", mapping={k: str(v) for k, v in data.items()}) + + # Update last-seen timestamp + _redis.set(f"runs:last_seen:{run_id}", now().isoformat()) + + # Determine which lifecycle event (if any) to fire + _maybe_fire_lifecycle_event(run_id, vehicle_id, leaf, data) + + +def _maybe_fire_lifecycle_event( + run_id: str, vehicle_id: str, leaf: str, data: dict +) -> None: + from realtime_engine.tasks import run_lifecycle_event + + run_state = _redis.hget(f"run:{run_id}", "run_lifecycle_state") + if not run_state: + return + + payload = { + "run_id": run_id, + "vehicle_id": vehicle_id, + "last_seen_at": now().isoformat(), + **data, + } + + if run_state == "Confirmed": + # CONFIRMED → TRACKING: first valid ping + _redis.sadd("runs:tracking", run_id) + run_lifecycle_event.delay("run_tracking_started", payload) + + elif run_state == "Tracking" and leaf == "position": + speed = float(data.get("speed", 0)) + if speed > 0.5: + # TRACKING → IN_PROGRESS: vehicle is moving + run_lifecycle_event.delay("run_started", payload) + + elif run_state == "No Signal": + # NO_SIGNAL → IN_PROGRESS: signal restored + _redis.sadd("runs:tracking", run_id) + run_lifecycle_event.delay("run_tracking_restored", payload) + + elif run_state == "In Progress" and leaf == "progression": + current_status = data.get("current_status", "") + stop_id = data.get("stop_id", "") + if current_status == "STOPPED_AT" and stop_id: + # May be at terminal stop — fire COMPLETE_RUN and let the guard decide + run_lifecycle_event.delay("complete_run", {**payload, "stop_id": stop_id}) + + +def _on_connect(client: mqtt.Client, userdata, flags, rc): + if rc == 0: + logger.info("Connected to MQTT broker %s:%s", MQTT_HOST, MQTT_PORT) + client.subscribe("transit/vehicle/+/position", qos=0) + client.subscribe("transit/vehicle/+/progression", qos=0) + client.subscribe("transit/vehicle/+/occupancy", qos=0) + else: + logger.error("MQTT connection refused, rc=%d", rc) + + +def _on_message(client: mqtt.Client, userdata, msg: mqtt.MQTTMessage): + vehicle_id = _vehicle_id_from_topic(msg.topic) + leaf = _leaf_from_topic(msg.topic) + if not vehicle_id or not leaf: + return + try: + _handle_telemetry(vehicle_id, leaf, msg.payload) + except Exception: + logger.exception("Error processing telemetry for vehicle %s/%s", vehicle_id, leaf) + + +class Command(BaseCommand): + help = "Long-lived MQTT subscriber — bridges vehicle telemetry into the run lifecycle FSM" + + def handle(self, *args, **options): + client = mqtt.Client(client_id="databus-realtime-consumer", clean_session=True) + client.on_connect = _on_connect + client.on_message = _on_message + + while True: + try: + client.connect(MQTT_HOST, MQTT_PORT, keepalive=60) + client.loop_forever() + except Exception: + logger.exception("MQTT connection failed — retrying in 5s") + time.sleep(5) diff --git a/backend/realtime_engine/tasks.py b/backend/realtime_engine/tasks.py index fcfe299..2389c2b 100644 --- a/backend/realtime_engine/tasks.py +++ b/backend/realtime_engine/tasks.py @@ -1,117 +1,84 @@ -from celery import shared_task -from messages.publisher import publish_event +import logging +import os +from datetime import datetime, timezone from typing import Any -from operations.models import Vehicle, Operator -from runs.models import Run -from runs.services.lifecycle import RunLifecycleService -from feed.models import Feed, Trip + import redis -import os +from celery import shared_task +from django.utils.timezone import now + +from runs.services.lifecycle import RunLifecycleService +from runs.domain.states import RunLifecycleStates + +logger = logging.getLogger(__name__) redis_client = redis.Redis( - host=os.getenv("REDIS_HOST", "state"), port=os.getenv("REDIS_PORT", 6379), db=0 + host=os.getenv("REDIS_HOST", "state"), + port=int(os.getenv("REDIS_PORT", "6379")), + db=0, + decode_responses=True, ) - -@shared_task(queue="realtime_engine") -def hello_world() -> None: - print("Hello, world!") +TELEMETRY_GRACE_S = 60 +TELEMETRY_EXPIRY_S = 300 @shared_task(queue="realtime_engine") -def run_lifecycle_event(event: str, payload: dict[str, Any]) -> Run | None: - service = RunLifecycleService() - result = service.process_event(event, payload) - return result +def run_lifecycle_event(event: str, payload: dict[str, Any]) -> None: + from runs.domain.events import RunLifecycleEvents - -@shared_task(queue="realtime_engine") -def validate_run(run_data: dict[str, Any]) -> bool: - # Validation against the system state in Redis. - # Verify that: - # 1. the vehicle is not currently in another run, - # 2. the trip_id is not already being executed by another run - return True # Placeholder for actual validation logic. Always returns True for now. + service = RunLifecycleService() + try: + evt = RunLifecycleEvents(event) + except ValueError: + logger.error("Unknown lifecycle event: %s", event) + return + try: + service.process_event(evt, payload) + except Exception: + logger.exception("Lifecycle event %s failed for run %s", event, payload.get("run_id")) @shared_task(queue="realtime_engine") -def initialize_run(run_data: dict[str, Any]) -> tuple[bool, str | None]: +def scan_stale_runs() -> str: """ - Initializes a run in the database and the system state (Redis). - - Args: - run_data (dict): Run request payload to initialize. Expected keys include - ``vehicle_id``, ``operator_id``, ``route_id``, ``trip_id``, - ``direction_id``, ``shape_id``, and ``schedule_relationship``. - Returns: - tuple[bool, str | None]: A tuple where the first value is ``True`` when the run was successfully initialized. The second value is the run ID on success, or ``None`` on failure. + Scans runs:tracking every 30 s. + - Grace exceeded (>60s, ≤300s) while IN_PROGRESS → RUN_TRACKING_LOST + - Expiry exceeded (>300s) while NO_SIGNAL → RUN_TRACKING_EXPIRED """ - try: - run = Run.objects.create( - vehicle_id=run_data.get("vehicle_id"), - operator_id=run_data.get("operator_id"), - route_id=run_data.get("route_id"), - trip_id=run_data.get( - "trip_id" - ), # Cuando no es SCHEDULED, alguien tiene que crear un nuevo trip_id - direction_id=int(run_data.get("direction_id")), - shape_id=run_data.get("shape_id"), - schedule_relationship=run_data.get("schedule_relationship"), - run_lifecycle_state="INITIALIZED", # TODO: CONFIRMAR ESTADO ADECUADO - ) - # Save run data (all) to Redis' "runs" key - redis_client.hset(f"run:{run.id}", mapping=run_data) - return (True, str(run.id)) - except Exception as e: - publish_event( - "RUN_INITIALIZATION_ERROR", - {"error": str(e), **run_data}, # Error with payload - ) - return (False, None) - - -@shared_task(queue="realtime_engine") -def register_run(run_data: dict[str, Any]) -> tuple[bool, str | None]: - if not validate_run(run_data): - publish_event("RUN_VALIDATION_FAILED", run_data) - return (False, None) - - publish_event("RUN_VALIDATION_SUCCEEDED", run_data) - initialization_ok, run_id = initialize_run(run_data) - if initialization_ok: - publish_event("RUN_INITIALIZATION_SUCCEEDED", run_data) - return (True, run_id) - - publish_event("RUN_INITIALIZATION_FAILED", run_data) - return (False, None) - - -@shared_task(queue="realtime_engine") -def start_run(run_data: dict[str, Any]) -> bool: - # Aquí iría la lógica de inicio del run_data - # Save run_id to Redis' runs:in_progress group - # redis_client.sadd("runs:in_progress", run_data.get("run_id")) - return True - - -@shared_task(queue="realtime_engine") -def end_run(run_data: dict[str, Any]) -> bool: - # Transitions an IN_PROGRESS run to COMPLETED. Returns False if the run is missing, in a non-completable state, or an erorr occurs. - run_id = run_data.get("run_id") - if run_id in (None, ""): - return False - try: - run = Run.objects.filter(id=run_id).first() - if run is None: - return False - if run.run_lifecycle_state != "IN_PROGRESS": - return False - run.run_lifecycle_state = "COMPLETED" - run.save(update_fields=["run_lifecycle_state"]) - return True - except Exception as e: - publish_event( - "RUN_COMPLETION_ERROR", - {"error": str(e), **run_data}, - ) - return False + run_ids = redis_client.smembers("runs:tracking") + fired = 0 + for run_id in run_ids: + raw_last_seen = redis_client.get(f"runs:last_seen:{run_id}") + if not raw_last_seen: + continue + try: + last_seen = datetime.fromisoformat(raw_last_seen) + if last_seen.tzinfo is None: + last_seen = last_seen.replace(tzinfo=timezone.utc) + except (ValueError, TypeError): + continue + + staleness = (now() - last_seen).total_seconds() + run_state = redis_client.hget(f"run:{run_id}", "run_lifecycle_state") + + payload = { + "run_id": run_id, + "last_seen_at": raw_last_seen, + "actor_role": "system", + } + + if ( + TELEMETRY_GRACE_S < staleness <= TELEMETRY_EXPIRY_S + and run_state == RunLifecycleStates.IN_PROGRESS.value + ): + run_lifecycle_event.delay("run_tracking_lost", payload) + fired += 1 + elif ( + staleness > TELEMETRY_EXPIRY_S + and run_state == RunLifecycleStates.NO_SIGNAL.value + ): + run_lifecycle_event.delay("run_tracking_expired", payload) + fired += 1 + + return f"scan_stale_runs: checked {len(run_ids)} runs, fired {fired} events" diff --git a/backend/runs/domain/actions.py b/backend/runs/domain/actions.py index f840598..1cc79e1 100644 --- a/backend/runs/domain/actions.py +++ b/backend/runs/domain/actions.py @@ -1,5 +1,4 @@ from typing import Any, TYPE_CHECKING -from django.utils.timezone import now from runs.models import Run import redis @@ -11,82 +10,129 @@ class RunLifecycleActions: """ - Defines the possible actions that can be taken during a run's lifecycle. - Ordering convention: every transition should list persist_lifecycle_event first so the audit record is written before any external side-effects. State is saved last via update_run_lifecycle_state so the Run row always reflects the final outcome of a fully-executed transition. """ - # ------------------------------------------------------------------ - # Core (implemented) - # ------------------------------------------------------------------ - @staticmethod def update_system_state( run: Run, transition: "Transition", payload: dict[str, Any] ) -> bool: - """Write run data to the Redis hash so other services can read system state. - - TODO: serialize relevant run fields into run:{run.id} hash key. - """ - return True # TODO placeholder + """Write run metadata to run:{run.id} hash and claim vehicle/operator/trip keys.""" + vehicle_id = ( + payload.get("vehicle_id") + or run.vehicle.values_list("id", flat=True).first() + ) + operator_id = ( + payload.get("operator_id") + or run.operator.values_list("id", flat=True).first() + ) + run_key = f"run:{run.id}" + mapping: dict[str, str] = { + "run_id": str(run.id), + "route_id": run.route_id or "", + "trip_id": run.trip_id or "", + "direction_id": "" if run.direction_id is None else str(run.direction_id), + "shape_id": run.shape_id or "", + "schedule_relationship": run.schedule_relationship or "", + # Use transition.to_state because the action fires before _update_run_lifecycle_state + "run_lifecycle_state": transition.to_state.value, + } + if vehicle_id: + mapping["vehicle"] = str(vehicle_id) + if operator_id: + mapping["operator"] = str(operator_id) + if run.start_date: + mapping["start_date"] = run.start_date.strftime("%Y%m%d") + if run.start_time: + total_seconds = int(run.start_time.total_seconds()) + h, rem = divmod(total_seconds, 3600) + m, s = divmod(rem, 60) + mapping["start_time"] = f"{h:02d}:{m:02d}:{s:02d}" + + pipe = r.pipeline() + pipe.hset(run_key, mapping=mapping) + + # Claim assignment keys so availability guards have a signal to read + if vehicle_id: + pipe.set(f"vehicle:{vehicle_id}:current_run", str(run.id)) + if operator_id: + pipe.set(f"operator:{operator_id}:current_run", str(run.id)) + if run.trip_id: + pipe.set(f"trip:{run.trip_id}:current_run", str(run.id)) + + # Write vehicle metadata so the GTFS-RT builders can populate VehicleDescriptor + if vehicle_id: + from operations.models import Vehicle as VehicleModel + try: + v = VehicleModel.objects.get(id=vehicle_id) + vehicle_meta: dict[str, str] = { + "id": str(v.id), + "label": v.label or str(v.id), + } + if v.license_plate: + vehicle_meta["license_plate"] = v.license_plate + if v.wheelchair_accessible: + vehicle_meta["wheelchair_accessible"] = v.wheelchair_accessible + pipe.hset(f"vehicle:{vehicle_id}:data", mapping=vehicle_meta) + except Exception: + pass + + pipe.execute() + return True # ------------------------------------------------------------------ # Redis set mutations # ------------------------------------------------------------------ @staticmethod - def add_to_tracking_set( + def sync_lifecycle_state( run: Run, transition: "Transition", payload: dict[str, Any] ) -> bool: - """Add run to the runs:tracking Redis set. + """Keep the Redis run hash's run_lifecycle_state in sync with the DB transition.""" + r.hset(f"run:{run.id}", "run_lifecycle_state", transition.to_state.value) + return True - TODO: r.sadd("runs:tracking", str(run.id)) - """ - return True # TODO placeholder + @staticmethod + def add_to_tracking_set( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + r.sadd("runs:tracking", str(run.id)) + return True @staticmethod def remove_from_tracking_set( run: Run, transition: "Transition", payload: dict[str, Any] ) -> bool: - """Remove run from the runs:tracking Redis set. - - TODO: r.srem("runs:tracking", str(run.id)) - """ - return True # TODO placeholder + r.srem("runs:tracking", str(run.id)) + return True @staticmethod def add_to_in_progress_set( run: Run, transition: "Transition", payload: dict[str, Any] ) -> bool: - """Add run to the runs:in_progress Redis set. - - TODO: r.sadd("runs:in_progress", str(run.id)) - """ - return True # TODO placeholder + r.sadd("runs:in_progress", str(run.id)) + return True @staticmethod def remove_from_in_progress_set( run: Run, transition: "Transition", payload: dict[str, Any] ) -> bool: - """Remove run from the runs:in_progress Redis set. - - TODO: r.srem("runs:in_progress", str(run.id)) - """ - return True # TODO placeholder + r.srem("runs:in_progress", str(run.id)) + return True @staticmethod def remove_from_system_state( run: Run, transition: "Transition", payload: dict[str, Any] ) -> bool: - """Delete the run:{id} hash and remove from all Redis sets. - - TODO: pipeline r.delete("run:{run.id}"), srem from tracking and - in_progress sets, and any other membership keys. - """ - return True # TODO placeholder + pipe = r.pipeline() + pipe.delete(f"run:{run.id}") + pipe.srem("runs:tracking", str(run.id)) + pipe.srem("runs:in_progress", str(run.id)) + pipe.execute() + return True # ------------------------------------------------------------------ # Resource release @@ -96,14 +142,16 @@ def remove_from_system_state( def release_resources( run: Run, transition: "Transition", payload: dict[str, Any] ) -> bool: - """Free the vehicle and operator so they can be assigned to another run. - - TODO: clear the "currently assigned" Redis keys for run.vehicle and - run.operator so is_vehicle_available / is_operator_available guards - pass for future runs using the same assets. - """ - return True # TODO placeholder - - - - + """Free vehicle, operator, and trip assignment keys.""" + vehicle_id = run.vehicle.values_list("id", flat=True).first() + operator_id = run.operator.values_list("id", flat=True).first() + keys_to_delete = [] + if vehicle_id: + keys_to_delete.append(f"vehicle:{vehicle_id}:current_run") + if operator_id: + keys_to_delete.append(f"operator:{operator_id}:current_run") + if run.trip_id: + keys_to_delete.append(f"trip:{run.trip_id}:current_run") + if keys_to_delete: + r.delete(*keys_to_delete) + return True diff --git a/backend/runs/domain/events.py b/backend/runs/domain/events.py index 0b4d15d..de49a77 100644 --- a/backend/runs/domain/events.py +++ b/backend/runs/domain/events.py @@ -7,7 +7,7 @@ class RunLifecycleEvents(str, Enum): """ # Lifecycle progression events - RUN_REQUESTED = "run_requested" + RUN_REQUESTED = "run_requested" # initial: record creation puts run in REQUESTED VALIDATE_RUN = "validate_run" INITIALIZE_RUN = "initialize_run" RUN_CONFIRMED_BY_OPERATOR = "run_confirmed_by_operator" @@ -22,22 +22,3 @@ class RunLifecycleEvents(str, Enum): RUN_TRACKING_LOST = "run_tracking_lost" RUN_TRACKING_RESTORED = "run_tracking_restored" RUN_TRACKING_EXPIRED = "run_tracking_expired" - - -""" -RUN_REQUESTED = a "POST /create-run" API call request happened (an implicit run request) -VALIDATE_RUN = apply the transition guards to check GTFS consistency -INITIALIZE_RUN = execute actions to update the system state -RUN_CONFIRMED_BY_OPERATOR = the operator (driver, dispatcher) re-confirmed the run -RUN_TRACKING_STARTED = GPS pings are detected and valid -RUN_STARTED = the run actually started (vehicle is moving along a valid path) -COMPLETE_RUN = manual or automatic request to complete a successful run (e.g. vehicle reached the end of the route or the run was completed by the operator) - -RUN_REJECTED = validation or initialization failed -CANCEL_RUN = a cancellation request by the operator (driver, administrator, dispatcher) or the system before it started -INTERRUPT_RUN = a manual or automatic request to interrupt the run after it started, either by the operator or the system (possible activation of an alert!) -SHORT_TURN_RUN = a manual request to short-turn the run -RUN_TRACKING_LOST = the run tracking was lost (automatic, async) -RUN_TRACKING_RESTORED = the run tracking was restored (automatic, async) -RUN_TRACKING_EXPIRED = the run tracking expired (e.g. no telemetry for a long time) (automatic, async) -""" diff --git a/backend/runs/domain/guards.py b/backend/runs/domain/guards.py index d4e0fe2..bb798c7 100644 --- a/backend/runs/domain/guards.py +++ b/backend/runs/domain/guards.py @@ -1,221 +1,313 @@ -from typing import Any -from datetime import datetime +from typing import Any, TYPE_CHECKING +from datetime import datetime, timezone +from django.utils.timezone import now from runs.models import Run from runs.services.exceptions import RunLifecycleError import redis +if TYPE_CHECKING: + from runs.domain.transitions import Transition + r = redis.Redis(host="state", port=6379, db=0) -# Thresholds for telemetry freshness checks. Move to Django settings when stabilised. TELEMETRY_GRACE_S = 60 TELEMETRY_EXPIRY_S = 300 +def _parse_last_seen(payload: dict[str, Any]) -> datetime | None: + raw = payload.get("last_seen_at") + if raw is None: + return None + if isinstance(raw, datetime): + return raw + try: + dt = datetime.fromisoformat(str(raw)) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + except (ValueError, TypeError): + return None + + class RunLifecycleGuards: @staticmethod - def is_gtfs_valid(run: Run, payload: dict[str, Any]) -> bool: - """ - Checks in the database that the GTFS data in the payload is valid. + def is_gtfs_valid( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + from feed.models import Feed, Route, Trip, Shape - This is a placeholder implementation. - """ route_id = payload.get("route_id") trip_id = payload.get("trip_id") direction_id = payload.get("direction_id") shape_id = payload.get("shape_id") schedule_relationship = payload.get("schedule_relationship") + errors: dict[str, str] = {} + if not route_id: errors["route_id"] = "route_id is required" - elif route_id != "valid": - errors["route_id"] = ( - f"route_id '{route_id}' was not found in the current GTFS feed" - ) if not trip_id: errors["trip_id"] = "trip_id is required" - elif trip_id != "valid": - errors["trip_id"] = ( - f"trip_id '{trip_id}' was not found in the current GTFS feed" - ) if direction_id not in [0, 1]: - errors["direction_id"] = ( - f"direction_id must be 0 or 1, got '{direction_id}'" - ) + errors["direction_id"] = f"direction_id must be 0 or 1, got '{direction_id}'" if not shape_id: errors["shape_id"] = "shape_id is required" - elif shape_id != "valid": - errors["shape_id"] = ( - f"shape_id '{shape_id}' was not found in the current GTFS feed" - ) if not schedule_relationship: errors["schedule_relationship"] = "schedule_relationship is required" elif schedule_relationship != "SCHEDULED": errors["schedule_relationship"] = ( f"schedule_relationship '{schedule_relationship}' is not valid" ) + if errors: raise RunLifecycleError(errors) - return True - @staticmethod - def is_vehicle_available(run: Run, payload: dict[str, Any]) -> bool: - """ - Checks in system state (Redis) that the vehicle is not already assigned to another run at the same time. + feed = Feed.objects.filter(is_current=True).first() + if not feed: + raise RunLifecycleError({"feed": "No current GTFS feed found"}) + + lookup_errors: dict[str, str] = {} + + if not Route.objects.filter(feed=feed, route_id=route_id).exists(): + lookup_errors["route_id"] = ( + f"route_id '{route_id}' not found in current GTFS feed" + ) + + trip = Trip.objects.filter(feed=feed, trip_id=trip_id).first() + if not trip: + lookup_errors["trip_id"] = ( + f"trip_id '{trip_id}' not found in current GTFS feed" + ) + elif trip.direction_id != direction_id: + lookup_errors["direction_id"] = ( + f"direction_id '{direction_id}' does not match trip '{trip_id}'" + ) + elif shape_id and trip.shape_id != shape_id: + lookup_errors["shape_id"] = ( + f"shape_id '{shape_id}' does not match trip '{trip_id}'" + ) + + if lookup_errors: + raise RunLifecycleError(lookup_errors) - This is a placeholder implementation. - """ return True @staticmethod - def is_trip_available(run: Run, payload: dict[str, Any]) -> bool: - """ - Checks in system state (Redis) that the trip is not already assigned to another run at the same time. - - This is a placeholder implementation. - """ + def is_vehicle_available( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + vehicle_id = payload.get("vehicle_id") or ( + run.vehicle.values_list("id", flat=True).first() + ) + if not vehicle_id: + return True + existing = r.get(f"vehicle:{vehicle_id}:current_run") + if existing and existing.decode() != str(run.id): + raise RunLifecycleError( + {"vehicle_id": f"Vehicle '{vehicle_id}' is already assigned to run {existing.decode()}"} + ) return True @staticmethod - def is_operator_available(run: Run, payload: dict[str, Any]) -> bool: - """ - Checks in system state (Redis) that the operator is not already assigned to another run at the same time. + def is_trip_available( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + trip_id = payload.get("trip_id") or run.trip_id + if not trip_id: + return True + existing = r.get(f"trip:{trip_id}:current_run") + if existing and existing.decode() != str(run.id): + raise RunLifecycleError( + {"trip_id": f"Trip '{trip_id}' is already assigned to run {existing.decode()}"} + ) + return True - This is a placeholder implementation. - """ + @staticmethod + def is_operator_available( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + operator_id = payload.get("operator_id") or ( + run.operator.values_list("id", flat=True).first() + ) + if not operator_id: + return True + existing = r.get(f"operator:{operator_id}:current_run") + if existing and existing.decode() != str(run.id): + raise RunLifecycleError( + {"operator_id": f"Operator '{operator_id}' is already assigned to run {existing.decode()}"} + ) return True @staticmethod - def is_vehicle_tracked(run: Run, payload: dict[str, Any]) -> bool: + def is_vehicle_tracked( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: return bool(r.sismember("runs:tracking", str(run.id))) @staticmethod - def is_run_validated(run: Run, payload: dict[str, Any]) -> bool: + def is_run_validated( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: return True @staticmethod - def is_vehicle_moving(run: Run, payload: dict[str, Any]) -> bool: - return payload["speed"] > 5 + def is_vehicle_moving( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + return float(payload.get("speed", 0)) > 0.5 # ------------------------------------------------------------------ # Cancellation / interruption / short-turn authority guards # ------------------------------------------------------------------ @staticmethod - def is_cancellation_authorized(run: Run, payload: dict[str, Any]) -> bool: - """The actor requesting cancellation has authority over this run. - - Required payload keys: - actor_id (UUID): the user or system identifier - actor_role (str): one of {"operator", "dispatcher", "system"} - reason (str): free-text justification persisted in the audit log - - TODO: cross-check that actor_id has dispatcher role OR is the assigned - operator on this run. System-initiated cancellations always pass. - """ - return True # TODO placeholder + def is_cancellation_authorized( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + actor_role = payload.get("actor_role", "") + if actor_role == "system": + return True + if actor_role in ("dispatcher", "operator"): + return True + raise RunLifecycleError( + {"actor_role": f"actor_role '{actor_role}' is not authorized to cancel runs"} + ) @staticmethod - def is_interruption_authorized(run: Run, payload: dict[str, Any]) -> bool: - """The actor requesting interruption has authority over this run. - - Required payload keys: - actor_id (UUID): the user or system identifier - actor_role (str): one of {"operator", "dispatcher", "system"} - reason (str): free-text justification persisted in the audit log + def is_interruption_authorized( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + actor_role = payload.get("actor_role", "") + if actor_role in ("system", "dispatcher", "operator"): + return True + raise RunLifecycleError( + {"actor_role": f"actor_role '{actor_role}' is not authorized to interrupt runs"} + ) - TODO: same authority logic as is_cancellation_authorized; interruption - may additionally require a minimum run duration (e.g. > 2 minutes - in progress) to distinguish from an accidental start. - """ - return True # TODO placeholder + @staticmethod + def is_short_turn_authorized( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + actor_role = payload.get("actor_role", "") + if actor_role in ("dispatcher", "system"): + return True + raise RunLifecycleError( + {"actor_role": f"actor_role '{actor_role}' must be 'dispatcher' or 'system' to short-turn"} + ) @staticmethod - def is_short_turn_authorized(run: Run, payload: dict[str, Any]) -> bool: - """The actor requesting short-turn has dispatcher-level authority. + def is_short_turn_geometrically_valid( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + from feed.models import Feed, StopTime - Required payload keys: - actor_id (UUID) - actor_role (str): must be "dispatcher" or "system" - short_turn_stop_id (str): the stop at which the run will terminate early - reason (str): free-text justification + short_turn_stop_id = payload.get("short_turn_stop_id") + if not short_turn_stop_id: + raise RunLifecycleError({"short_turn_stop_id": "short_turn_stop_id is required"}) - TODO: verify actor_role is "dispatcher" or "system" (operators cannot - self-authorize a short-turn unilaterally). - """ - return True # TODO placeholder + trip_id = run.trip_id + if not trip_id: + raise RunLifecycleError({"trip_id": "Run has no trip_id"}) - @staticmethod - def is_short_turn_geometrically_valid(run: Run, payload: dict[str, Any]) -> bool: - """The short-turn stop lies on the run's shape and before the terminal stop. + feed = Feed.objects.filter(is_current=True).first() + if not feed: + raise RunLifecycleError({"feed": "No current GTFS feed found"}) - Required payload keys: - short_turn_stop_id (str): the stop at which the run terminates early + stop_times = StopTime.objects.filter(feed=feed, trip_id=trip_id).order_by("stop_sequence") + if not stop_times.exists(): + raise RunLifecycleError({"trip_id": f"No stop times found for trip '{trip_id}'"}) - TODO: look up the stop sequence for run.trip_id, confirm - short_turn_stop_id appears in it, and confirm it is not the last stop - (which would be a normal completion, not a short-turn). - """ - return True # TODO placeholder + terminal = stop_times.last() + stop_ids = list(stop_times.values_list("stop_id", flat=True)) + + if short_turn_stop_id not in stop_ids: + raise RunLifecycleError( + {"short_turn_stop_id": f"Stop '{short_turn_stop_id}' not on trip '{trip_id}'"} + ) + if short_turn_stop_id == terminal.stop_id: + raise RunLifecycleError( + {"short_turn_stop_id": "Short-turn stop cannot be the terminal stop"} + ) + return True # ------------------------------------------------------------------ # Telemetry freshness guards # ------------------------------------------------------------------ @staticmethod - def is_telemetry_stale(run: Run, payload: dict[str, Any]) -> bool: - """No telemetry has been received for longer than TELEMETRY_GRACE_S seconds. - - Required payload keys: - last_seen_at (datetime | ISO-8601 str): timestamp of last received ping - - TODO: parse last_seen_at from payload and compare against utcnow(). - Fall back to run.last_event_at if last_seen_at is absent. - """ - return True # TODO placeholder + def is_telemetry_stale( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + last_seen = _parse_last_seen(payload) + if last_seen is None: + last_seen = run.last_event_at + if last_seen is None: + return True + staleness = (now() - last_seen).total_seconds() + return staleness > TELEMETRY_GRACE_S @staticmethod - def is_telemetry_fresh(run: Run, payload: dict[str, Any]) -> bool: - """A valid telemetry ping has been received within TELEMETRY_GRACE_S seconds. - - Required payload keys: - last_seen_at (datetime | ISO-8601 str): timestamp of last received ping - - TODO: inverse of is_telemetry_stale — same implementation, negated result. - """ - return True # TODO placeholder + def is_telemetry_fresh( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + last_seen = _parse_last_seen(payload) + if last_seen is None: + last_seen = run.last_event_at + if last_seen is None: + return False + staleness = (now() - last_seen).total_seconds() + return staleness <= TELEMETRY_GRACE_S @staticmethod - def is_telemetry_grace_period_exceeded(run: Run, payload: dict[str, Any]) -> bool: - """No telemetry for longer than TELEMETRY_EXPIRY_S seconds (hard expiry). - - Required payload keys: - last_seen_at (datetime | ISO-8601 str): timestamp of last received ping - - TODO: same as is_telemetry_stale but uses TELEMETRY_EXPIRY_S threshold. - Triggers an INTERRUPTED transition rather than a NO_SIGNAL one. - """ - return True # TODO placeholder + def is_telemetry_grace_period_exceeded( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + last_seen = _parse_last_seen(payload) + if last_seen is None: + last_seen = run.last_event_at + if last_seen is None: + return True + staleness = (now() - last_seen).total_seconds() + return staleness > TELEMETRY_EXPIRY_S # ------------------------------------------------------------------ # Completion guard # ------------------------------------------------------------------ @staticmethod - def is_at_terminal_stop(run: Run, payload: dict[str, Any]) -> bool: - """The vehicle has reached the last stop of the scheduled trip. + def is_at_terminal_stop( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + from feed.models import Feed, StopTime - Required payload keys: - stop_id (str): the stop the vehicle just arrived at + stop_id = payload.get("stop_id") + if not stop_id: + raise RunLifecycleError({"stop_id": "stop_id is required"}) - TODO: look up the stop sequence for run.trip_id in the GTFS feed, - find the stop with the highest stop_sequence, and compare its - stop_id against payload["stop_id"]. - """ - return True # TODO placeholder + trip_id = run.trip_id + if not trip_id: + raise RunLifecycleError({"trip_id": "Run has no trip_id"}) + + feed = Feed.objects.filter(is_current=True).first() + if not feed: + raise RunLifecycleError({"feed": "No current GTFS feed found"}) + + terminal = ( + StopTime.objects.filter(feed=feed, trip_id=trip_id) + .order_by("-stop_sequence") + .first() + ) + if not terminal: + raise RunLifecycleError({"trip_id": f"No stop times found for trip '{trip_id}'"}) + + if stop_id != terminal.stop_id: + raise RunLifecycleError( + {"stop_id": f"Stop '{stop_id}' is not the terminal stop '{terminal.stop_id}'"} + ) + return True class RunProgressGuards: @staticmethod def telemetry_lost(run: Run, payload: dict[str, Any], now: datetime) -> bool: - # TODO: implement once Run has a last_seen_at timestamp field - return True # TODO placeholder + return True diff --git a/backend/runs/domain/transitions.py b/backend/runs/domain/transitions.py index 7f462f5..866ed31 100644 --- a/backend/runs/domain/transitions.py +++ b/backend/runs/domain/transitions.py @@ -81,6 +81,7 @@ class Transition: guards=[ ], actions=[ + RunLifecycleActions.sync_lifecycle_state, ], ), # Cancelled before operator confirmation @@ -107,6 +108,7 @@ class Transition: RunLifecycleGuards.is_vehicle_tracked, ], actions=[ + RunLifecycleActions.sync_lifecycle_state, RunLifecycleActions.add_to_tracking_set, ], ), @@ -134,6 +136,7 @@ class Transition: RunLifecycleGuards.is_vehicle_moving, ], actions=[ + RunLifecycleActions.sync_lifecycle_state, RunLifecycleActions.add_to_in_progress_set, ], ), @@ -162,6 +165,7 @@ class Transition: RunLifecycleGuards.is_telemetry_stale, ], actions=[ + RunLifecycleActions.sync_lifecycle_state, RunLifecycleActions.remove_from_tracking_set, ], ), @@ -217,6 +221,7 @@ class Transition: RunLifecycleGuards.is_vehicle_tracked, ], actions=[ + RunLifecycleActions.sync_lifecycle_state, RunLifecycleActions.add_to_tracking_set, RunLifecycleActions.add_to_in_progress_set, ], diff --git a/backend/runs/services/exceptions.py b/backend/runs/services/exceptions.py index ee0927f..949e448 100644 --- a/backend/runs/services/exceptions.py +++ b/backend/runs/services/exceptions.py @@ -1,3 +1,6 @@ +from typing import Any + + class RunLifecycleError(Exception): - def __init__(self, errors: dict[str, str]): + def __init__(self, errors: dict[str, Any]): self.errors = errors diff --git a/backend/runs/services/lifecycle.py b/backend/runs/services/lifecycle.py index 798c101..11338a6 100644 --- a/backend/runs/services/lifecycle.py +++ b/backend/runs/services/lifecycle.py @@ -51,10 +51,18 @@ def _check_guards( ) -> tuple[bool, dict[str, bool]]: guards = {} is_valid = True + first_guard_error: RunLifecycleError | None = None for guard in transition.guards: - passed = guard(run, transition, payload) + try: + passed = guard(run, transition, payload) + except RunLifecycleError as exc: + passed = False + if first_guard_error is None: + first_guard_error = exc is_valid = is_valid and passed guards[guard.__name__] = passed + if not is_valid and first_guard_error is not None: + raise first_guard_error return is_valid, guards def _execute_actions( @@ -78,7 +86,7 @@ def _apply_transition( def _update_run_lifecycle_state( self, run: Run, transition: Transition, payload: dict[str, Any] - ) -> bool: + ) -> None: run.run_lifecycle_state = transition.to_state run.last_event_at = now() run.save() diff --git a/backend/schedule_engine/tasks.py b/backend/schedule_engine/tasks.py index 1937c5e..d24bf8c 100644 --- a/backend/schedule_engine/tasks.py +++ b/backend/schedule_engine/tasks.py @@ -18,9 +18,7 @@ def get_redis(): global _redis if _redis is None: _redis = redis.Redis( - host=os.environ.get( - "REDIS_HOST", "state" - ), # use compose service name, not localhost + host=os.environ.get("REDIS_HOST", "state"), port=int(os.environ.get("REDIS_PORT", "6379")), db=int(os.environ.get("REDIS_DB", "0")), decode_responses=True, @@ -29,12 +27,10 @@ def get_redis(): def get_feed_version(): - # TODO: Implement a method to get the current feed version, e.g., from a database return "1.0.0" def get_entity_id(vehicle_id): - # TODO: Implement a method to get a unique entity ID based on vehicle ID or trip_id + schedule_relationship return vehicle_id @@ -44,215 +40,240 @@ def get_current_timestamp(): @shared_task(queue="schedule_engine") def build_vehicle_positions(): - """ - Build the VehiclePosition feed message.""" - + """Build the VehiclePosition feed message.""" r = get_redis() - # Feed message dictionary - feed_message = {} - - # Feed message header - feed_message["header"] = {} - feed_message["header"]["gtfs_realtime_version"] = "2.0" - feed_message["header"]["incrementality"] = "FULL_DATASET" - feed_message["header"]["timestamp"] = get_current_timestamp() - feed_message["header"]["feed_version"] = get_feed_version() - - # Feed message entity - feed_message["entity"] = [] + feed_message = { + "header": { + "gtfs_realtime_version": "2.0", + "incrementality": "FULL_DATASET", + "timestamp": get_current_timestamp(), + }, + "entity": [], + } - # Get all in-progress runs runs_in_progress = r.smembers("runs:in_progress") for run_id in runs_in_progress: - run = r.hgetall(run_id) - vehicle_id = run["vehicle"] - vehicle = r.hgetall(f"vehicle:{vehicle_id}:data") + run = r.hgetall(f"run:{run_id}") + if not run: + continue + vehicle_id = run.get("vehicle", "") + if not vehicle_id: + continue + position = r.hgetall(f"vehicle:{vehicle_id}:position") progression = r.hgetall(f"vehicle:{vehicle_id}:progression") occupancy = r.hgetall(f"vehicle:{vehicle_id}:occupancy") + vehicle_meta = r.hgetall(f"vehicle:{vehicle_id}:data") if not position and not progression and not occupancy: continue - entity = {} - entity["id"] = f"{vehicle['id']}" - entity["vehicle"] = {} - entity["vehicle"]["timestamp"] = int(position["timestamp"]) - entity["vehicle"]["trip"] = {} - entity["vehicle"]["trip"]["trip_id"] = run["trip_id"] - entity["vehicle"]["trip"]["route_id"] = run["route_id"] - entity["vehicle"]["trip"]["direction_id"] = run["direction_id"] - entity["vehicle"]["trip"]["start_time"] = run["start_time"] - entity["vehicle"]["trip"]["start_date"] = run["start_date"] - entity["vehicle"]["trip"]["schedule_relationship"] = run[ - "schedule_relationship" - ] - entity["vehicle"]["vehicle"] = {} - entity["vehicle"]["vehicle"]["id"] = vehicle["id"] - entity["vehicle"]["vehicle"]["label"] = vehicle["label"] - entity["vehicle"]["vehicle"]["license_plate"] = vehicle["license_plate"] - if vehicle.get("wheelchair_accessible"): - entity["vehicle"]["vehicle"]["wheelchair_accessible"] = vehicle[ - "wheelchair_accessible" - ] + entity: dict = {"id": vehicle_id, "vehicle": {}} + v = entity["vehicle"] + + if position.get("timestamp"): + try: + v["timestamp"] = int(float(position["timestamp"])) + except (ValueError, TypeError): + v["timestamp"] = get_current_timestamp() + else: + v["timestamp"] = get_current_timestamp() + + v["trip"] = { + "trip_id": run.get("trip_id", ""), + "route_id": run.get("route_id", ""), + "schedule_relationship": run.get("schedule_relationship", "SCHEDULED"), + } + if run.get("direction_id") not in (None, ""): + try: + v["trip"]["direction_id"] = int(run["direction_id"]) + except (ValueError, TypeError): + pass + if run.get("start_time"): + v["trip"]["start_time"] = run["start_time"] + if run.get("start_date"): + v["trip"]["start_date"] = run["start_date"] + + v["vehicle"] = { + "id": vehicle_meta.get("id", vehicle_id), + "label": vehicle_meta.get("label", vehicle_id), + } + if vehicle_meta.get("license_plate"): + v["vehicle"]["license_plate"] = vehicle_meta["license_plate"] + if vehicle_meta.get("wheelchair_accessible"): + v["vehicle"]["wheelchair_accessible"] = vehicle_meta["wheelchair_accessible"] + if position: - entity["vehicle"]["position"] = {} - entity["vehicle"]["position"]["latitude"] = position["latitude"] - entity["vehicle"]["position"]["longitude"] = position["longitude"] - if position.get("bearing"): - entity["vehicle"]["position"]["bearing"] = position["bearing"] - if position.get("odometer"): - entity["vehicle"]["position"]["odometer"] = position["odometer"] - if position.get("speed"): - entity["vehicle"]["position"]["speed"] = position["speed"] + try: + pos_entry: dict = { + "latitude": float(position.get("latitude", 0)), + "longitude": float(position.get("longitude", 0)), + } + if position.get("bearing"): + pos_entry["bearing"] = float(position["bearing"]) + if position.get("speed"): + pos_entry["speed"] = float(position["speed"]) + if position.get("odometer"): + pos_entry["odometer"] = float(position["odometer"]) + v["position"] = pos_entry + except (ValueError, TypeError): + pass + if progression: if progression.get("current_stop_sequence"): - entity["vehicle"]["current_stop_sequence"] = progression[ - "current_stop_sequence" - ] + try: + v["current_stop_sequence"] = int(progression["current_stop_sequence"]) + except (ValueError, TypeError): + pass if progression.get("stop_id"): - entity["vehicle"]["stop_id"] = progression["stop_id"] + v["stop_id"] = progression["stop_id"] if progression.get("current_status"): - entity["vehicle"]["current_status"] = progression["current_status"] + v["current_status"] = progression["current_status"] if progression.get("congestion_level"): - entity["vehicle"]["congestion_level"] = progression["congestion_level"] + v["congestion_level"] = progression["congestion_level"] + if occupancy: if occupancy.get("occupancy_status"): - entity["vehicle"]["occupancy_status"] = occupancy["occupancy_status"] + v["occupancy_status"] = occupancy["occupancy_status"] if occupancy.get("occupancy_percentage"): - entity["vehicle"]["occupancy_percentage"] = occupancy[ - "occupancy_percentage" - ] + try: + v["occupancy_percentage"] = int(occupancy["occupancy_percentage"]) + except (ValueError, TypeError): + pass - # Append entity to feed message feed_message["entity"].append(entity) output_dir = settings.BASE_DIR / "feed" / "files" output_dir.mkdir(parents=True, exist_ok=True) - # Create and save JSON feed_message_json = json.dumps(feed_message) with open(output_dir / "vehicle_positions.json", "w") as f: f.write(feed_message_json) - # Create and save Protobuf - feed_message_dict = json.loads(feed_message_json) - if "feed_version" in feed_message_dict.get("header", {}): - del feed_message_dict["header"]["feed_version"] - feed_message_pb = json_format.ParseDict(feed_message_dict, gtfs_rt.FeedMessage()) + feed_dict = json.loads(feed_message_json) + feed_message_pb = json_format.ParseDict(feed_dict, gtfs_rt.FeedMessage()) with open(output_dir / "vehicle_positions.pb", "wb") as f: f.write(feed_message_pb.SerializeToString()) - return f"FeedMessage VehiclePosition built successfully with {len(feed_message['entity'])} entities" + return f"VehiclePositions built: {len(feed_message['entity'])} entities" @shared_task(queue="schedule_engine") def build_trip_updates(): r = get_redis() - # Feed message dictionary - feed_message = {} - - # Feed message header - feed_message["header"] = {} - feed_message["header"]["gtfs_realtime_version"] = "2.0" - feed_message["header"]["incrementality"] = "FULL_DATASET" - feed_message["header"]["timestamp"] = get_current_timestamp() - feed_message["header"]["feed_version"] = get_feed_version() - - # Feed message entity - feed_message["entity"] = [] + feed_message = { + "header": { + "gtfs_realtime_version": "2.0", + "incrementality": "FULL_DATASET", + "timestamp": get_current_timestamp(), + }, + "entity": [], + } - # Get all in-progress runs runs_in_progress = r.smembers("runs:in_progress") for run_id in runs_in_progress: - run = r.hgetall(run_id) - vehicle_id = run["vehicle"] - vehicle = r.hgetall(f"vehicle:{vehicle_id}:data") + run = r.hgetall(f"run:{run_id}") + if not run: + continue + vehicle_id = run.get("vehicle", "") + if not vehicle_id: + continue + position = r.hgetall(f"vehicle:{vehicle_id}:position") progression = r.hgetall(f"vehicle:{vehicle_id}:progression") + vehicle_meta = r.hgetall(f"vehicle:{vehicle_id}:data") if not position and not progression: continue - entity = {} - entity["id"] = get_entity_id(vehicle["id"]) - entity["trip_update"] = {} - entity["trip_update"]["timestamp"] = int(position["timestamp"]) - entity["trip_update"]["trip"] = {} - entity["trip_update"]["trip"]["trip_id"] = run["trip_id"] - entity["trip_update"]["trip"]["route_id"] = run["route_id"] - entity["trip_update"]["trip"]["direction_id"] = run["direction_id"] - entity["trip_update"]["trip"]["start_time"] = run["start_time"] - entity["trip_update"]["trip"]["start_date"] = run["start_date"] - entity["trip_update"]["trip"]["schedule_relationship"] = run[ - "schedule_relationship" - ] - entity["trip_update"]["vehicle"] = {} - entity["trip_update"]["vehicle"]["id"] = vehicle["id"] - entity["trip_update"]["vehicle"]["label"] = vehicle["label"] - entity["trip_update"]["vehicle"]["license_plate"] = vehicle["license_plate"] - if vehicle.get("wheelchair_accessible"): - entity["trip_update"]["vehicle"]["wheelchair_accessible"] = vehicle[ - "wheelchair_accessible" - ] - entity["trip_update"]["stop_time_update"] = [] + entity: dict = {"id": get_entity_id(vehicle_id), "trip_update": {}} + tu = entity["trip_update"] + + if position.get("timestamp"): + try: + tu["timestamp"] = int(float(position["timestamp"])) + except (ValueError, TypeError): + tu["timestamp"] = get_current_timestamp() + else: + tu["timestamp"] = get_current_timestamp() + + tu["trip"] = { + "trip_id": run.get("trip_id", ""), + "route_id": run.get("route_id", ""), + "schedule_relationship": run.get("schedule_relationship", "SCHEDULED"), + } + if run.get("direction_id") not in (None, ""): + try: + tu["trip"]["direction_id"] = int(run["direction_id"]) + except (ValueError, TypeError): + pass + if run.get("start_time"): + tu["trip"]["start_time"] = run["start_time"] + if run.get("start_date"): + tu["trip"]["start_date"] = run["start_date"] + + tu["vehicle"] = { + "id": vehicle_meta.get("id", vehicle_id), + "label": vehicle_meta.get("label", vehicle_id), + } + if vehicle_meta.get("license_plate"): + tu["vehicle"]["license_plate"] = vehicle_meta["license_plate"] + stop_time_updates = build_stop_time_updates( - run=run, position=position, progression=progression + run=run, progression=progression ) + tu["stop_time_update"] = [] for update in stop_time_updates: - stop_time_update = {} - stop_time_update["stop_sequence"] = update["stop_sequence"] - stop_time_update["stop_id"] = update["stop_id"] - stop_time_update["arrival"] = {} - stop_time_update["arrival"]["time"] = update["eta_posix"] - stop_time_update["arrival"]["uncertainty"] = update["uncertainty"] - stop_time_update["departure"] = {} - stop_time_update["departure"]["time"] = update["eta_posix"] - stop_time_update["departure"]["uncertainty"] = update["uncertainty"] - - # Append stop time update to stop time updates - entity["trip_update"]["stop_time_update"].append(stop_time_update) - - # Append entity to feed message + tu["stop_time_update"].append({ + "stop_sequence": update["stop_sequence"], + "stop_id": update["stop_id"], + "arrival": { + "time": update["eta_posix"], + "uncertainty": update["uncertainty"], + }, + "departure": { + "time": update["eta_posix"], + "uncertainty": update["uncertainty"], + }, + }) + feed_message["entity"].append(entity) output_dir = settings.BASE_DIR / "feed" / "files" output_dir.mkdir(parents=True, exist_ok=True) - # Create and save JSON feed_message_json = json.dumps(feed_message) with open(output_dir / "trip_updates.json", "w") as f: f.write(feed_message_json) - # Create and save Protobuf - feed_message_dict = json.loads(feed_message_json) - if "feed_version" in feed_message_dict.get("header", {}): - del feed_message_dict["header"]["feed_version"] - feed_message_pb = json_format.ParseDict(feed_message_dict, gtfs_rt.FeedMessage()) + feed_dict = json.loads(feed_message_json) + feed_message_pb = json_format.ParseDict(feed_dict, gtfs_rt.FeedMessage()) with open(output_dir / "trip_updates.pb", "wb") as f: f.write(feed_message_pb.SerializeToString()) - # Send status update to WebSocket - message = {} - message["last_update"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - message["runs"] = len(runs_in_progress) - channel_layer = get_channel_layer() - async_to_sync(channel_layer.group_send)( - "status", - { - "type": "status_message", - "message": message, - }, - ) - - return "Feed TripUpdate built." + try: + channel_layer = get_channel_layer() + if channel_layer: + async_to_sync(channel_layer.group_send)( + "status", + { + "type": "status_message", + "message": { + "last_update": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "runs": len(runs_in_progress), + }, + }, + ) + except Exception: + pass + + return f"TripUpdates built: {len(feed_message['entity'])} entities" @shared_task(queue="schedule_engine") def build_alerts(): - print("Building feed Alert...") return "Feed ServiceAlert built" diff --git a/backend/uv.lock b/backend/uv.lock index 825cfd9..dc502f3 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -635,6 +635,7 @@ dependencies = [ { name = "gunicorn" }, { name = "kombu" }, { name = "mkdocs-material" }, + { name = "paho-mqtt" }, { name = "pandas" }, { name = "pillow" }, { name = "prefect" }, @@ -674,6 +675,7 @@ requires-dist = [ { name = "gunicorn", specifier = ">=23.0.0" }, { name = "kombu", specifier = ">=5.6.2" }, { name = "mkdocs-material", specifier = ">=9.6.18" }, + { name = "paho-mqtt", specifier = ">=2.1.0" }, { name = "pandas", specifier = ">=2.3.3" }, { name = "pillow", specifier = ">=11.3.0" }, { name = "prefect", specifier = ">=3.6.12" }, @@ -1844,6 +1846,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, ] +[[package]] +name = "paho-mqtt" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" }, +] + [[package]] name = "pandas" version = "3.0.2" diff --git a/compose.dev.yml b/compose.dev.yml index 9c3c672..911203a 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -45,6 +45,32 @@ services: message-broker: condition: service_healthy + realtime-consumer: + build: + context: ./backend + target: realtime-consumer + environment: + - DJANGO_SETUP=False + - UV_PROJECT_ENVIRONMENT=/home/app/.venv + - PYTHONPYCACHEPREFIX=/tmp/pycache + - MQTT_HOST=telemetry-broker + - MQTT_PORT=1883 + env_file: + - .env + - .env.dev + volumes: + - ./backend:/app + - backend_venv:/home/app/.venv + depends_on: + database: + condition: service_healthy + state: + condition: service_healthy + telemetry-broker: + condition: service_started + message-broker: + condition: service_healthy + schedule-engine: build: context: ./backend From de1fa06d30bd1cb209caf75d142c1a847a0e67c2 Mon Sep 17 00:00:00 2001 From: Jae Date: Wed, 20 May 2026 16:12:04 -0600 Subject: [PATCH 02/23] fix(run-lifecycle): flatten details + sync state on terminal transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UpdateRunViewSet - Flatten serializer.validated_data["details"] into payload before process_event. Guards like is_*_authorized read actor_role at the top level of payload (consistent with the Celery path in realtime_engine/tasks.py), so without this every UI-driven cancel/interrupt/short-turn/run_rejected returned 422 with "actor_role '' is not authorized…". Transitions - INTERRUPT_RUN, SHORT_TURN_RUN, COMPLETE_RUN: add sync_lifecycle_state so Redis's run:{id} hash reflects the terminal state. Without this the simulator's RunBinder polled an "In Progress" value forever and the UI never recognized the terminal state. - RUN_TRACKING_LOST: stop removing the run from runs:tracking — the set is scan_stale_runs's work queue, and removing on lost prevented the eventual RUN_TRACKING_EXPIRED transition. - RUN_TRACKING_EXPIRED: add sync_lifecycle_state + remove_from_tracking_set so the run finally exits the queue when fully terminal. Also includes RunHistoryView (read-only GET /api/runs/{id}/history/) in views.py — drives the history panel in the simulator's Runs tab. Note: committed against feat/run-lifecycle-mqtt which still has unrelated WIP from another contributor (uncommitted) — only the two files touched here are part of this fix. --- backend/api/views.py | 46 ++++++++++++++++++++++++++++++ backend/runs/domain/transitions.py | 9 +++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/backend/api/views.py b/backend/api/views.py index 7401f0a..1475d58 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -26,6 +26,7 @@ ) from runs.models import ( Run, + RunLifecycleTransition, Position, Progression, Occupancy, @@ -266,6 +267,12 @@ def post(self, request): status=status.HTTP_400_BAD_REQUEST, ) payload = dict(serializer.validated_data) + # Flatten `details` into payload so guards/actions can read fields like + # `actor_role` at the top level — matches the convention used by the + # internal Celery path (realtime_engine/tasks.py). + details = payload.pop("details", {}) or {} + if isinstance(details, dict): + payload.update(details) run_id = payload.get("run_id") run = Run.objects.filter(id=run_id).first() if not run: @@ -287,6 +294,45 @@ def post(self, request): ) +class RunHistoryView(APIView): + """ + Return the ordered list of FSM transitions for a run. + + Read-only audit log derived from RunLifecycleTransition, which the + lifecycle service writes before any external side-effect (so the log + is authoritative even if a downstream action later fails). + """ + + def get(self, request, run_id): + if not Run.objects.filter(id=run_id).exists(): + return Response( + {"status": "error", "errors": {"detail": f"run {run_id} not found"}}, + status=status.HTTP_404_NOT_FOUND, + ) + transitions = ( + RunLifecycleTransition.objects + .filter(run_id=run_id) + .order_by("timestamp", "created_at") + ) + return Response( + { + "run_id": str(run_id), + "transitions": [ + { + "event": t.event_name, + "from_state": t.from_state, + "to_state": t.to_state, + "timestamp": t.timestamp.isoformat(), + "actions": t.actions or {}, + "guards": t.guards or {}, + } + for t in transitions + ], + }, + status=status.HTTP_200_OK, + ) + + class PositionViewSet(viewsets.ModelViewSet): queryset = Position.objects.all() serializer_class = PositionSerializer diff --git a/backend/runs/domain/transitions.py b/backend/runs/domain/transitions.py index 866ed31..971ee22 100644 --- a/backend/runs/domain/transitions.py +++ b/backend/runs/domain/transitions.py @@ -165,8 +165,10 @@ class Transition: RunLifecycleGuards.is_telemetry_stale, ], actions=[ + # Keep the run in `runs:tracking` so scan_stale_runs can fire + # RUN_TRACKING_EXPIRED later. The set is the work queue, not a + # status flag — only fully-terminal transitions should remove from it. RunLifecycleActions.sync_lifecycle_state, - RunLifecycleActions.remove_from_tracking_set, ], ), Transition( @@ -177,6 +179,7 @@ class Transition: RunLifecycleGuards.is_interruption_authorized, ], actions=[ + RunLifecycleActions.sync_lifecycle_state, RunLifecycleActions.remove_from_tracking_set, RunLifecycleActions.remove_from_in_progress_set, RunLifecycleActions.release_resources, @@ -191,6 +194,7 @@ class Transition: RunLifecycleGuards.is_short_turn_geometrically_valid, ], actions=[ + RunLifecycleActions.sync_lifecycle_state, RunLifecycleActions.remove_from_tracking_set, RunLifecycleActions.remove_from_in_progress_set, RunLifecycleActions.release_resources, @@ -204,6 +208,7 @@ class Transition: RunLifecycleGuards.is_at_terminal_stop, ], actions=[ + RunLifecycleActions.sync_lifecycle_state, RunLifecycleActions.remove_from_tracking_set, RunLifecycleActions.remove_from_in_progress_set, RunLifecycleActions.release_resources, @@ -234,6 +239,8 @@ class Transition: RunLifecycleGuards.is_telemetry_grace_period_exceeded, ], actions=[ + RunLifecycleActions.sync_lifecycle_state, + RunLifecycleActions.remove_from_tracking_set, RunLifecycleActions.remove_from_in_progress_set, RunLifecycleActions.release_resources, ], From 595f9ade94a8a796327739b36bc078fc87384265 Mon Sep 17 00:00:00 2001 From: Jae Date: Wed, 20 May 2026 16:54:32 -0600 Subject: [PATCH 03/23] feat(run-lifecycle-mqtt): MQTT consumer + lifecycle infra Replaces the management-command mqtt_consumer with an in-worker Celery bootstep at realtime_engine/mqtt.py. It subscribes to transit/vehicle/+/{position,progression,occupancy}, updates Redis on every ping, and emits lifecycle events (run_tracking_started, run_started, complete_run, run_tracking_restored) based on the current run state. - backend/realtime_engine/mqtt.py: new MQTT bootstep - backend/realtime_engine/management/commands/*: removed (mqtt_consumer + bootstrap_simulator_runs no longer needed) - backend/databus/celery.py: register the bootstep on workers that set MQTT_CONSUMER_ENABLED=true - backend/api/urls.py: add /api/runs//history/ - backend/Dockerfile, compose.dev.yml, compose.prod.yml: wire telemetry-broker + realtime-engine environment so the consumer attaches at startup - backend/uv.lock: pick up paho-mqtt - docs/content/processes/run-lifecycle.md, README, realtime_engine/README: document the new flow --- README.md | 7 +- backend/Dockerfile | 6 - backend/api/urls.py | 1 + backend/databus/celery.py | 6 + backend/realtime_engine/README.md | 54 +- .../realtime_engine/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../commands/bootstrap_simulator_runs.py | 198 --- .../management/commands/mqtt_consumer.py | 146 -- backend/realtime_engine/mqtt.py | 187 +++ backend/uv.lock | 1287 +++++++++-------- compose.dev.yml | 27 +- compose.prod.yml | 15 +- docs/content/processes/run-lifecycle.md | 2 +- docs/uv.lock | 133 -- 15 files changed, 885 insertions(+), 1184 deletions(-) delete mode 100644 backend/realtime_engine/management/__init__.py delete mode 100644 backend/realtime_engine/management/commands/__init__.py delete mode 100644 backend/realtime_engine/management/commands/bootstrap_simulator_runs.py delete mode 100644 backend/realtime_engine/management/commands/mqtt_consumer.py create mode 100644 backend/realtime_engine/mqtt.py delete mode 100644 docs/uv.lock diff --git a/README.md b/README.md index dbb763f..1c6514b 100644 --- a/README.md +++ b/README.md @@ -109,13 +109,14 @@ End-to-end demo of a complete run lifecycle driven by MQTT telemetry from the si # Terminal 1 — start the full databus stack cd databus && bash scripts/dev.sh -# Terminal 2 — load GTFS feed + bootstrap simulator-aligned runs +# Terminal 2 — load GTFS feed docker compose -f compose.dev.yml exec orchestrator \ uv run python manage.py loaddata gtfs.json -docker compose -f compose.dev.yml exec orchestrator \ - uv run python manage.py bootstrap_simulator_runs # Terminal 3 — start the simulator (wired to databus broker) +# The simulator's scheduler posts to /api/create-run on each schedule entry's +# start_time. The UI's Operator tab handles confirmation. No databus-side +# bootstrap command is required. cd ../simulator && docker compose up simulator web # Terminal 4 — observe (optional) diff --git a/backend/Dockerfile b/backend/Dockerfile index 070bde4..25b1349 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -84,9 +84,3 @@ FROM base AS scheduler # -------------------- CMD ["uv", "run", "celery", "-A", "databus", "beat", "--loglevel=info"] - -# -------- MQTT consumer -FROM base AS realtime-consumer -# ----------------------- - -CMD ["uv", "run", "python", "manage.py", "mqtt_consumer"] diff --git a/backend/api/urls.py b/backend/api/urls.py index 7f2de25..e61a23f 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -40,6 +40,7 @@ # path("route-stops/", views.RouteStopView.as_view(), name="route_stops"), path("create-run/", views.CreateRunViewSet.as_view(), name="create_run"), path("update-run/", views.UpdateRunViewSet.as_view(), name="update_run"), + path("runs//history/", views.RunHistoryView.as_view(), name="run_history"), path("service-today/", views.ServiceTodayView.as_view(), name="service_today"), path("which-shapes/", views.WhichShapesView.as_view(), name="which_shapes"), path("find-trips/", views.FindTripsView.as_view(), name="find_trips"), diff --git a/backend/databus/celery.py b/backend/databus/celery.py index 6dff3c9..9cd91fb 100644 --- a/backend/databus/celery.py +++ b/backend/databus/celery.py @@ -17,6 +17,12 @@ # Load task modules from all registered Django apps. app.autodiscover_tasks() +# Register the MQTT consumer bootstep. The class is gated internally by +# MQTT_CONSUMER_ENABLED so only the realtime-engine worker actually starts it. +from realtime_engine.mqtt import MQTTConsumerStep # noqa: E402 + +app.steps["worker"].add(MQTTConsumerStep) + @app.task(bind=True, ignore_result=True) def debug_task(self): diff --git a/backend/realtime_engine/README.md b/backend/realtime_engine/README.md index f4f3c60..5a84705 100644 --- a/backend/realtime_engine/README.md +++ b/backend/realtime_engine/README.md @@ -1,17 +1,26 @@ # realtime_engine -Celery worker that processes lifecycle events and runs the MQTT telemetry consumer. +Celery worker that processes lifecycle events and hosts the MQTT telemetry +consumer as an in-process bootstep. -## MQTT consumer: management command approach +## MQTT consumer: Celery bootstep approach -The MQTT consumer is implemented as a **Django management command** (`manage.py mqtt_consumer`) -started by a dedicated `realtime-consumer` compose service (see `compose.dev.yml`). +The MQTT consumer (`mqtt.py`) runs inside the realtime-engine worker as a +Celery `bootsteps.StartStopStep`. It starts when the worker boots and shuts +down when the worker shuts down. paho's `loop_start()` runs the network loop +in its own background thread, so it does not block Celery task execution. -**Why not Celery bootsteps?** -A Celery bootstep would start the MQTT loop inside the Celery worker process, mixing -telemetry I/O with task execution. Keeping them separate means the consumer can reconnect -cleanly without affecting the Celery worker pool, and scaling them independently is -straightforward (e.g. add more Celery workers without adding more MQTT connections). +The bootstep is registered globally in `databus/celery.py` but gated by the +`MQTT_CONSUMER_ENABLED` env var. Only the `realtime-engine` compose service +sets it to `true`; other workers (schedule-engine, beat) skip the bootstep so +the broker doesn't see duplicate subscribers. + +**Why a bootstep instead of a separate service?** +The dedicated `realtime-consumer` service was an early choice that traded +operational simplicity for separation-of-concerns at a scale the demo doesn't +need. paho's network loop already runs in its own thread, so colocating it +with the Celery worker doesn't starve task execution. The bootstep avoids an +extra container, Dockerfile target, and bind-mount on the backend tree. ## Topic subscriptions @@ -21,17 +30,18 @@ transit/vehicle/+/progression QoS 0 transit/vehicle/+/occupancy QoS 0 ``` -The `data` leaf (static metadata) is not subscribed here — vehicle metadata is written to -Redis by `RunLifecycleActions.update_system_state` when a run is initialized. +The `data` leaf (static metadata) is not subscribed here — vehicle metadata is +written to Redis by `RunLifecycleActions.update_system_state` when a run is +initialized. ## Lifecycle events fired by the consumer -| Run state | Trigger condition | Event fired | -|--------------|--------------------------------------------|-------------------------| -| `Confirmed` | Any valid ping received | `run_tracking_started` | -| `Tracking` | `position.speed > 0.5` m/s | `run_started` | -| `No Signal` | Any valid ping received | `run_tracking_restored` | -| `In Progress`| `progression.current_status == STOPPED_AT` at terminal stop | `complete_run` | +| Run state | Trigger condition | Event fired | +|--------------|-----------------------------------------------------------|-------------------------| +| `Confirmed` | Any valid ping received | `run_tracking_started` | +| `Tracking` | `position.speed > 0.5` m/s | `run_started` | +| `No Signal` | Any valid ping received | `run_tracking_restored` | +| `In Progress`| `progression.current_status == STOPPED_AT` with `stop_id` | `complete_run` | ## Stale run scanning @@ -39,3 +49,13 @@ Redis by `RunLifecycleActions.update_system_state` when a run is initialized. - `IN_PROGRESS` + staleness > 60 s → `run_tracking_lost` - `NO_SIGNAL` + staleness > 300 s → `run_tracking_expired` + +## Environment variables + +| Var | Default | Purpose | +|-------------------------|--------------------|---------------------------------------------------------------| +| `MQTT_CONSUMER_ENABLED` | `false` | Master switch; set `true` only on the realtime-engine worker. | +| `MQTT_HOST` | `telemetry-broker` | Broker hostname (resolved inside compose). | +| `MQTT_PORT` | `1883` | Broker port. | +| `REDIS_HOST` | `state` | Redis hostname. | +| `REDIS_PORT` | `6379` | Redis port. | diff --git a/backend/realtime_engine/management/__init__.py b/backend/realtime_engine/management/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/realtime_engine/management/commands/__init__.py b/backend/realtime_engine/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/realtime_engine/management/commands/bootstrap_simulator_runs.py b/backend/realtime_engine/management/commands/bootstrap_simulator_runs.py deleted file mode 100644 index 619e37a..0000000 --- a/backend/realtime_engine/management/commands/bootstrap_simulator_runs.py +++ /dev/null @@ -1,198 +0,0 @@ -""" -manage.py bootstrap_simulator_runs - -Reads the simulator fleet definition from simulator/sim/shapes.json, -creates one Vehicle + Operator + Run per vehicle, advances each run to -CONFIRMED state via the FSM, and sets vehicle:{id}:current_run in Redis. - -After this command completes, starting the simulator will cause MQTT pings -to trigger RUN_TRACKING_STARTED automatically. - -Usage: - manage.py bootstrap_simulator_runs - manage.py bootstrap_simulator_runs --shapes-json /path/to/shapes.json - manage.py bootstrap_simulator_runs --per-route 2 -""" -import json -import logging -from datetime import date -from pathlib import Path - -from django.contrib.auth import get_user_model -from django.core.management.base import BaseCommand, CommandError -from django.db import transaction - -from operations.models import Vehicle, Operator -from runs.models import Run -from runs.services.lifecycle import RunLifecycleService -from runs.domain.events import RunLifecycleEvents -from runs.services.exceptions import RunLifecycleError - -logger = logging.getLogger(__name__) - -try: - DEFAULT_SHAPES_JSON = ( - Path(__file__).resolve().parents[5] # up to git.no_sync/ on host - / "simulator" / "sim" / "shapes.json" - ) -except IndexError: - DEFAULT_SHAPES_JSON = Path("/tmp/shapes.json") -PER_ROUTE = 4 - - -class Command(BaseCommand): - help = "Bootstrap simulator-aligned runs for the MQTT demo" - - def add_arguments(self, parser): - parser.add_argument( - "--shapes-json", - default=str(DEFAULT_SHAPES_JSON), - help="Path to simulator shapes.json", - ) - parser.add_argument( - "--per-route", - type=int, - default=PER_ROUTE, - help="Vehicles per route (must match simulator --per-route, default 4)", - ) - parser.add_argument( - "--clear", - action="store_true", - help="Delete all existing simulator runs before bootstrapping", - ) - - def handle(self, **options): - shapes_path = Path(options["shapes_json"]) - if not shapes_path.exists(): - raise CommandError(f"shapes.json not found at {shapes_path}") - - data = json.loads(shapes_path.read_text()) - routes = {r["route_id"]: r for r in data.get("routes", [])} - - # Build the same fleet as the simulator - fleet: list[dict] = [] - unit_num = 1 - for route_id, route in routes.items(): - available_shapes = [ - s for s in route.get("shape_ids", []) if s in data.get("shapes", {}) - ] - if not available_shapes: - continue - for i in range(options["per_route"]): - shape_id = available_shapes[i % len(available_shapes)] - fleet.append({ - "vehicle_id": f"unit-{unit_num:02d}", - "route_id": route_id, - "shape_id": shape_id, - }) - unit_num += 1 - - if not fleet: - raise CommandError("No vehicles derived from shapes.json") - - self.stdout.write(f"Fleet: {len(fleet)} vehicles") - - if options["clear"]: - deleted = Run.objects.filter( - vehicle__id__in=[v["vehicle_id"] for v in fleet] - ).delete() - self.stdout.write(f"Cleared existing runs: {deleted}") - - from feed.models import Feed, Trip - - feed = Feed.objects.filter(is_current=True).first() - if not feed: - raise CommandError( - "No current GTFS feed found. Run: manage.py loaddata gtfs.json" - ) - - service = RunLifecycleService() - User = get_user_model() - - created_runs = [] - with transaction.atomic(): - for spec in fleet: - vehicle_id = spec["vehicle_id"] - route_id = spec["route_id"] - shape_id = spec["shape_id"] - - # Find a GTFS trip matching this route+shape - trip = Trip.objects.filter( - feed=feed, route_id=route_id, shape_id=shape_id - ).first() - if not trip: - self.stderr.write( - f" {vehicle_id}: no trip found for route={route_id} " - f"shape={shape_id} — skipping" - ) - continue - - # Ensure Vehicle exists - vehicle, _ = Vehicle.objects.get_or_create( - id=vehicle_id, - defaults={ - "label": vehicle_id, - "license_plate": vehicle_id, - }, - ) - - # Ensure a synthetic operator exists for this vehicle - username = f"sim_{vehicle_id}" - user, _ = User.objects.get_or_create( - username=username, - defaults={"first_name": "Sim", "last_name": vehicle_id}, - ) - operator_id = f"op-{vehicle_id}" - operator, _ = Operator.objects.get_or_create( - id=operator_id, - defaults={"user": user}, - ) - - # Create the run (REQUESTED state by default) - run = Run.objects.create( - route_id=route_id, - trip_id=trip.trip_id, - direction_id=trip.direction_id, - shape_id=shape_id, - schedule_relationship="SCHEDULED", - start_date=date.today(), - ) - run.vehicle.set([vehicle]) - run.operator.set([operator]) - - payload = { - "run_id": run.id, - "vehicle_id": vehicle_id, - "operator_id": operator.id, - "route_id": route_id, - "trip_id": trip.trip_id, - "direction_id": trip.direction_id, - "shape_id": shape_id, - "schedule_relationship": "SCHEDULED", - } - - try: - service.process_event(RunLifecycleEvents.VALIDATE_RUN, payload) - service.process_event(RunLifecycleEvents.INITIALIZE_RUN, payload) - service.process_event( - RunLifecycleEvents.RUN_CONFIRMED_BY_OPERATOR, payload - ) - except RunLifecycleError as exc: - self.stderr.write( - f" {vehicle_id}: FSM error — {exc.errors} — skipping" - ) - run.delete() - continue - - created_runs.append((vehicle_id, run.id, trip.trip_id)) - self.stdout.write( - f" ✓ {vehicle_id} run={run.id} trip={trip.trip_id} " - f"route={route_id} shape={shape_id}" - ) - - self.stdout.write( - self.style.SUCCESS( - f"\nBootstrap complete: {len(created_runs)}/{len(fleet)} runs in CONFIRMED state." - ) - ) - self.stdout.write("Start the simulator — first MQTT pings will trigger RUN_TRACKING_STARTED.") diff --git a/backend/realtime_engine/management/commands/mqtt_consumer.py b/backend/realtime_engine/management/commands/mqtt_consumer.py deleted file mode 100644 index ddf7a06..0000000 --- a/backend/realtime_engine/management/commands/mqtt_consumer.py +++ /dev/null @@ -1,146 +0,0 @@ -""" -Django management command: mqtt_consumer - -Long-lived MQTT subscriber that bridges vehicle telemetry into the run -lifecycle FSM. Run by the 'realtime-consumer' compose service. - -Topic pattern: transit/vehicle/+/{position,progression,occupancy,data} -""" -import json -import logging -import os -import time - -import redis -from django.core.management.base import BaseCommand -from django.utils.timezone import now - -import paho.mqtt.client as mqtt - -logger = logging.getLogger(__name__) - -MQTT_HOST = os.getenv("MQTT_HOST", "telemetry-broker") -MQTT_PORT = int(os.getenv("MQTT_PORT", "1883")) - -_redis = redis.Redis( - host=os.getenv("REDIS_HOST", "state"), - port=int(os.getenv("REDIS_PORT", "6379")), - db=0, - decode_responses=True, -) - - -def _vehicle_id_from_topic(topic: str) -> str | None: - """Extract vehicle_id from transit/vehicle//.""" - parts = topic.split("/") - if len(parts) == 4 and parts[0] == "transit" and parts[1] == "vehicle": - return parts[2] - return None - - -def _leaf_from_topic(topic: str) -> str | None: - parts = topic.split("/") - return parts[-1] if len(parts) == 4 else None - - -def _handle_telemetry(vehicle_id: str, leaf: str, payload_bytes: bytes) -> None: - try: - data = json.loads(payload_bytes) - except (json.JSONDecodeError, ValueError): - logger.warning("Non-JSON payload on vehicle %s/%s — ignored", vehicle_id, leaf) - return - - run_id_bytes = _redis.get(f"vehicle:{vehicle_id}:current_run") - if not run_id_bytes: - logger.debug("No active run for vehicle %s — dropping %s", vehicle_id, leaf) - return - - run_id = run_id_bytes - - # Persist raw telemetry so GTFS-RT builders can read it - if isinstance(data, dict): - _redis.hset(f"vehicle:{vehicle_id}:{leaf}", mapping={k: str(v) for k, v in data.items()}) - - # Update last-seen timestamp - _redis.set(f"runs:last_seen:{run_id}", now().isoformat()) - - # Determine which lifecycle event (if any) to fire - _maybe_fire_lifecycle_event(run_id, vehicle_id, leaf, data) - - -def _maybe_fire_lifecycle_event( - run_id: str, vehicle_id: str, leaf: str, data: dict -) -> None: - from realtime_engine.tasks import run_lifecycle_event - - run_state = _redis.hget(f"run:{run_id}", "run_lifecycle_state") - if not run_state: - return - - payload = { - "run_id": run_id, - "vehicle_id": vehicle_id, - "last_seen_at": now().isoformat(), - **data, - } - - if run_state == "Confirmed": - # CONFIRMED → TRACKING: first valid ping - _redis.sadd("runs:tracking", run_id) - run_lifecycle_event.delay("run_tracking_started", payload) - - elif run_state == "Tracking" and leaf == "position": - speed = float(data.get("speed", 0)) - if speed > 0.5: - # TRACKING → IN_PROGRESS: vehicle is moving - run_lifecycle_event.delay("run_started", payload) - - elif run_state == "No Signal": - # NO_SIGNAL → IN_PROGRESS: signal restored - _redis.sadd("runs:tracking", run_id) - run_lifecycle_event.delay("run_tracking_restored", payload) - - elif run_state == "In Progress" and leaf == "progression": - current_status = data.get("current_status", "") - stop_id = data.get("stop_id", "") - if current_status == "STOPPED_AT" and stop_id: - # May be at terminal stop — fire COMPLETE_RUN and let the guard decide - run_lifecycle_event.delay("complete_run", {**payload, "stop_id": stop_id}) - - -def _on_connect(client: mqtt.Client, userdata, flags, rc): - if rc == 0: - logger.info("Connected to MQTT broker %s:%s", MQTT_HOST, MQTT_PORT) - client.subscribe("transit/vehicle/+/position", qos=0) - client.subscribe("transit/vehicle/+/progression", qos=0) - client.subscribe("transit/vehicle/+/occupancy", qos=0) - else: - logger.error("MQTT connection refused, rc=%d", rc) - - -def _on_message(client: mqtt.Client, userdata, msg: mqtt.MQTTMessage): - vehicle_id = _vehicle_id_from_topic(msg.topic) - leaf = _leaf_from_topic(msg.topic) - if not vehicle_id or not leaf: - return - try: - _handle_telemetry(vehicle_id, leaf, msg.payload) - except Exception: - logger.exception("Error processing telemetry for vehicle %s/%s", vehicle_id, leaf) - - -class Command(BaseCommand): - help = "Long-lived MQTT subscriber — bridges vehicle telemetry into the run lifecycle FSM" - - def handle(self, *args, **options): - client = mqtt.Client(client_id="databus-realtime-consumer", clean_session=True) - client.on_connect = _on_connect - client.on_message = _on_message - - while True: - try: - client.connect(MQTT_HOST, MQTT_PORT, keepalive=60) - client.loop_forever() - except Exception: - logger.exception("MQTT connection failed — retrying in 5s") - time.sleep(5) diff --git a/backend/realtime_engine/mqtt.py b/backend/realtime_engine/mqtt.py new file mode 100644 index 0000000..70434e0 --- /dev/null +++ b/backend/realtime_engine/mqtt.py @@ -0,0 +1,187 @@ +""" +MQTT telemetry consumer — runs as a Celery bootstep inside the realtime-engine +worker process. + +The bootstep is registered for every worker that loads ``databus.celery`` but +only activates when ``MQTT_CONSUMER_ENABLED`` is truthy. Compose sets this only +on the realtime-engine service, so other workers (schedule-engine, beat) skip +it and don't double-subscribe to the broker. + +Topic pattern: ``transit/vehicle//{position,progression,occupancy}``. +""" +import json +import logging +import os +from typing import Any + +import paho.mqtt.client as mqtt +import redis +from celery import bootsteps +from django.utils.timezone import now + +logger = logging.getLogger(__name__) + +MQTT_HOST = os.getenv("MQTT_HOST", "telemetry-broker") +MQTT_PORT = int(os.getenv("MQTT_PORT", "1883")) +MQTT_CONSUMER_ENABLED = os.getenv("MQTT_CONSUMER_ENABLED", "false").lower() in ( + "1", + "true", + "yes", +) + +_redis = redis.Redis( + host=os.getenv("REDIS_HOST", "state"), + port=int(os.getenv("REDIS_PORT", "6379")), + db=0, + decode_responses=True, +) + + +def _vehicle_id_from_topic(topic: str) -> str | None: + """Extract vehicle_id from transit/vehicle//.""" + parts = topic.split("/") + if len(parts) == 4 and parts[0] == "transit" and parts[1] == "vehicle": + return parts[2] + return None + + +def _leaf_from_topic(topic: str) -> str | None: + parts = topic.split("/") + return parts[-1] if len(parts) == 4 else None + + +def _handle_telemetry(vehicle_id: str, leaf: str, payload_bytes: bytes) -> None: + try: + data = json.loads(payload_bytes) + except (json.JSONDecodeError, ValueError): + logger.warning("Non-JSON payload on vehicle %s/%s — ignored", vehicle_id, leaf) + return + + run_id = _redis.get(f"vehicle:{vehicle_id}:current_run") + if not run_id: + logger.debug("No active run for vehicle %s — dropping %s", vehicle_id, leaf) + return + + if isinstance(data, dict): + _redis.hset( + f"vehicle:{vehicle_id}:{leaf}", + mapping={k: str(v) for k, v in data.items()}, + ) + + _redis.set(f"runs:last_seen:{run_id}", now().isoformat()) + _maybe_fire_lifecycle_event(run_id, vehicle_id, leaf, data) + + +def _maybe_fire_lifecycle_event( + run_id: str, vehicle_id: str, leaf: str, data: dict[str, Any] +) -> None: + from realtime_engine.tasks import run_lifecycle_event + + run_state = _redis.hget(f"run:{run_id}", "run_lifecycle_state") + if not run_state: + return + + payload = { + "run_id": run_id, + "vehicle_id": vehicle_id, + "last_seen_at": now().isoformat(), + **data, + } + + if run_state == "Confirmed": + _redis.sadd("runs:tracking", run_id) + run_lifecycle_event.delay("run_tracking_started", payload) + + elif run_state == "Tracking" and leaf == "position": + speed = float(data.get("speed", 0)) + if speed > 0.5: + run_lifecycle_event.delay("run_started", payload) + + elif run_state == "No Signal": + _redis.sadd("runs:tracking", run_id) + run_lifecycle_event.delay("run_tracking_restored", payload) + + elif run_state == "In Progress" and leaf == "progression": + current_status = data.get("current_status", "") + stop_id = data.get("stop_id", "") + if current_status == "STOPPED_AT" and stop_id: + run_lifecycle_event.delay( + "complete_run", {**payload, "stop_id": stop_id} + ) + + +def _on_connect(client: mqtt.Client, userdata, flags, rc) -> None: + if rc == 0: + logger.info("MQTT connected: %s:%s", MQTT_HOST, MQTT_PORT) + client.subscribe("transit/vehicle/+/position", qos=0) + client.subscribe("transit/vehicle/+/progression", qos=0) + client.subscribe("transit/vehicle/+/occupancy", qos=0) + else: + logger.error("MQTT connection refused: rc=%d", rc) + + +def _on_message(client: mqtt.Client, userdata, msg: mqtt.MQTTMessage) -> None: + vehicle_id = _vehicle_id_from_topic(msg.topic) + leaf = _leaf_from_topic(msg.topic) + if not vehicle_id or not leaf: + return + try: + _handle_telemetry(vehicle_id, leaf, msg.payload) + except Exception: + logger.exception( + "Telemetry handling failed for %s/%s", vehicle_id, leaf + ) + + +def build_client() -> mqtt.Client: + client = mqtt.Client(client_id="databus-mqtt-consumer", clean_session=True) + client.on_connect = _on_connect + client.on_message = _on_message + client.reconnect_delay_set(min_delay=1, max_delay=30) + return client + + +class MQTTConsumerStep(bootsteps.StartStopStep): + """Celery worker bootstep that runs the MQTT subscriber in-process. + + paho's ``loop_start()`` spawns its own background thread and handles + reconnects internally, so the bootstep only orchestrates lifecycle: + start on worker boot, stop on worker shutdown. + """ + + requires = {"celery.worker.components:Pool"} + + def __init__(self, worker, **kwargs): + super().__init__(worker, **kwargs) + self.client: mqtt.Client | None = None + + def start(self, worker) -> None: + if not MQTT_CONSUMER_ENABLED: + logger.info( + "MQTT consumer bootstep disabled (MQTT_CONSUMER_ENABLED=%s)", + os.getenv("MQTT_CONSUMER_ENABLED", ""), + ) + return + logger.info( + "Starting MQTT consumer bootstep (%s:%s)", MQTT_HOST, MQTT_PORT + ) + client = build_client() + try: + client.connect_async(MQTT_HOST, MQTT_PORT, keepalive=60) + client.loop_start() + self.client = client + except Exception: + logger.exception("MQTT consumer failed to start") + self.client = None + + def stop(self, worker) -> None: + if self.client is None: + return + logger.info("Stopping MQTT consumer bootstep") + try: + self.client.loop_stop() + self.client.disconnect() + except Exception: + logger.exception("MQTT consumer shutdown failed") + finally: + self.client = None diff --git a/backend/uv.lock b/backend/uv.lock index dc502f3..7c253d4 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -2,9 +2,12 @@ version = 1 revision = 3 requires-python = ">=3.14" resolution-markers = [ - "sys_platform == 'win32'", - "sys_platform == 'emscripten'", - "sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version < '3.15' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version < '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] [manifest] @@ -90,7 +93,7 @@ wheels = [ [[package]] name = "apprise" -version = "1.9.9" +version = "1.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -101,9 +104,9 @@ dependencies = [ { name = "requests-oauthlib" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/20/f4/be5c7e39b83a2285ab62ae7c19bb10704836f59c0a5b4c471730f54c9f98/apprise-1.9.9.tar.gz", hash = "sha256:fd622c0df16bdc79ed385539735573488cafe2405d25747e87eebd6b09b26012", size = 2032822, upload-time = "2026-03-21T17:49:14.041Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/74/9c16829d3e7e45ce7daf1b704687fa4fde7ea00d72eafe8de18c72bf5995/apprise-1.10.0.tar.gz", hash = "sha256:b768f32d99e45ed5f4c3eef1f67903e803c97f97ba61a531a5d0a45d40df90a8", size = 2188611, upload-time = "2026-04-26T14:23:51.928Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/2f/54d068d7e011a8b4e0aae3e93b09a30b33bcf780829fe70c6e8876aeb0e0/apprise-1.9.9-py3-none-any.whl", hash = "sha256:55ceb8827a1c783d683881c9f77fa42eb43b3fc91b854419c452d557101c7068", size = 1519940, upload-time = "2026-03-21T17:49:11.847Z" }, + { url = "https://files.pythonhosted.org/packages/69/f9/177a73589d34e676d10bc4c6a8328710e28af5907234e9f25bb149a04eec/apprise-1.10.0-py3-none-any.whl", hash = "sha256:e685303d3568bb7a057d6ddeafd27ee12fff183ca36483ad4bacc0b9b4efa82c", size = 1632292, upload-time = "2026-04-26T14:23:49.28Z" }, ] [[package]] @@ -127,6 +130,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, ] +[[package]] +name = "ast-serialize" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, + { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, +] + [[package]] name = "asyncpg" version = "0.31.0" @@ -202,16 +245,15 @@ wheels = [ [[package]] name = "backrefs" -version = "6.2" +version = "7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/a6/e325ec73b638d3ede4421b5445d4a0b8b219481826cc079d510100af356c/backrefs-6.2.tar.gz", hash = "sha256:f44ff4d48808b243b6c0cdc6231e22195c32f77046018141556c66f8bab72a49", size = 7012303, upload-time = "2026-02-16T19:10:15.828Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/a7dd63622beef68cc0d3c3c36d472e143dd95443d5ebf14cd1a5b4dfbf11/backrefs-7.0.tar.gz", hash = "sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82", size = 7012453, upload-time = "2026-04-28T16:28:04.215Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/39/3765df263e08a4df37f4f43cb5aa3c6c17a4bdd42ecfe841e04c26037171/backrefs-6.2-py310-none-any.whl", hash = "sha256:0fdc7b012420b6b144410342caeb8adc54c6866cf12064abc9bb211302e496f8", size = 381075, upload-time = "2026-02-16T19:10:04.322Z" }, - { url = "https://files.pythonhosted.org/packages/0f/f0/35240571e1b67ffb19dafb29ab34150b6f59f93f717b041082cdb1bfceb1/backrefs-6.2-py311-none-any.whl", hash = "sha256:08aa7fae530c6b2361d7bdcbda1a7c454e330cc9dbcd03f5c23205e430e5c3be", size = 392874, upload-time = "2026-02-16T19:10:06.314Z" }, - { url = "https://files.pythonhosted.org/packages/e3/63/77e8c9745b4d227cce9f5e0a6f68041278c5f9b18588b35905f5f19c1beb/backrefs-6.2-py312-none-any.whl", hash = "sha256:c3f4b9cb2af8cda0d87ab4f57800b57b95428488477be164dd2b47be54db0c90", size = 398787, upload-time = "2026-02-16T19:10:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/c5/71/c754b1737ad99102e03fa3235acb6cb6d3ac9d6f596cbc3e5f236705abd8/backrefs-6.2-py313-none-any.whl", hash = "sha256:12df81596ab511f783b7d87c043ce26bc5b0288cf3bb03610fe76b8189282b2b", size = 400747, upload-time = "2026-02-16T19:10:09.791Z" }, - { url = "https://files.pythonhosted.org/packages/af/75/be12ba31a6eb20dccef2320cd8ccb3f7d9013b68ba4c70156259fee9e409/backrefs-6.2-py314-none-any.whl", hash = "sha256:e5f805ae09819caa1aa0623b4a83790e7028604aa2b8c73ba602c4454e665de7", size = 412602, upload-time = "2026-02-16T19:10:12.317Z" }, - { url = "https://files.pythonhosted.org/packages/21/f8/d02f650c47d05034dcd6f9c8cf94f39598b7a89c00ecda0ecb2911bc27e9/backrefs-6.2-py39-none-any.whl", hash = "sha256:664e33cd88c6840b7625b826ecf2555f32d491800900f5a541f772c485f7cda7", size = 381077, upload-time = "2026-02-16T19:10:13.74Z" }, + { url = "https://files.pythonhosted.org/packages/d4/39/39a31d7eae729ea14ed10c3ccef79371197177b9355a86cb3525709e8502/backrefs-7.0-py310-none-any.whl", hash = "sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9", size = 380824, upload-time = "2026-04-28T16:27:55.647Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b5/9302644225ba7dfa934a2ff2b9c7bb85701313a90dddb3dfaf693fa5bae2/backrefs-7.0-py311-none-any.whl", hash = "sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475", size = 392626, upload-time = "2026-04-28T16:27:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-none-any.whl", hash = "sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12", size = 398537, upload-time = "2026-04-28T16:27:58.913Z" }, + { url = "https://files.pythonhosted.org/packages/00/bb/90ba423612b6aa0adccc6b1874bcd4a9b44b660c0c16f346611e00f64ac3/backrefs-7.0-py313-none-any.whl", hash = "sha256:f2c52955d631b9e1ac4cd56209f0a3a946d592b98e7790e77699339ae01c102a", size = 400491, upload-time = "2026-04-28T16:28:00.928Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-none-any.whl", hash = "sha256:a6448b28180e3ca01134c9cf09dcebafad8531072e09903c5451748a05f24bc9", size = 412349, upload-time = "2026-04-28T16:28:02.412Z" }, ] [[package]] @@ -232,29 +274,53 @@ 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" }, ] +[[package]] +name = "burner-redis" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/89/54706febafc135095b2a9d797cfbd4eed2ab1ad7819808b99b587020471b/burner_redis-0.1.7.tar.gz", hash = "sha256:7474ff092669fd11ef765411572cdafcc3d89b8054aef4ca0617be6d6be4c680", size = 638644, upload-time = "2026-05-08T15:01:42.961Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/5d/198bd1d22e504b3034353430703afbdb3efe6e25cb90bf52d896e1d266a7/burner_redis-0.1.7-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f80c866996e0455d584eb3c0f3b067e411c632fb0519eab454e0968edf01e62c", size = 1288888, upload-time = "2026-05-08T15:01:26.103Z" }, + { url = "https://files.pythonhosted.org/packages/2f/4e/ce5c91b884ac37fcd380756402536f8810964014097950900517ce8bd30c/burner_redis-0.1.7-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a3d9569a376b690fb5876d454e4904443332dc3ad5c0057e149fc2ad220bf599", size = 1234282, upload-time = "2026-05-08T15:01:28.286Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/31c25cc88143eac2dddcc394151a0db627923d44c94376a83768552c9f13/burner_redis-0.1.7-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20eba1917e3bca9eea5957d5700ff8defcb5a209e57a7841d005549aa0151f44", size = 1337341, upload-time = "2026-05-08T15:01:30.397Z" }, + { url = "https://files.pythonhosted.org/packages/e1/32/95cfa1833316ca2b6b2e58150a4900bc1ad256043cdd36198f1887618ccc/burner_redis-0.1.7-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39111467059b8a28f15ea061d2414ec25c3e57c65759983f90f4d358e7d6a72d", size = 1366800, upload-time = "2026-05-08T15:01:32.891Z" }, + { url = "https://files.pythonhosted.org/packages/34/ad/93c3916f053f89b7b5760da5bf855cd78b7885d480f9cfcc64f3732c1dc2/burner_redis-0.1.7-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9b5adfe99aeb8407f468078f3769b2a63e9168fea12f7709df5d2a3b152706e4", size = 1538160, upload-time = "2026-05-08T15:01:34.667Z" }, + { url = "https://files.pythonhosted.org/packages/5c/b9/19bae42cb124932d71168bc8e5bcb1da33aa62b908e5e632b3d298d7cb15/burner_redis-0.1.7-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:591a9d20685f9d6d22bf0c863b50b12dfcf328b06111b3f62c33cd3185d48ce0", size = 1591491, upload-time = "2026-05-08T15:01:36.708Z" }, + { url = "https://files.pythonhosted.org/packages/f5/30/207f47f406619a5b564355d2946c3171f84231a28b800709b5645b06a5ae/burner_redis-0.1.7-cp310-abi3-win_amd64.whl", hash = "sha256:f6cf4ac666766b32fd63940aad0c120847905fd3102c17e5b6b305f91a21d079", size = 1117564, upload-time = "2026-05-08T15:01:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/76/6f/e9beaf46c5e9fd10dfcdb889ebf7d3aa85142c650c0ab17ab284194f58e1/burner_redis-0.1.7-cp310-abi3-win_arm64.whl", hash = "sha256:458f88feeddfb40a586cc3fcbd8e9384bbdfd2a4512a695af4900e06052570d4", size = 1040407, upload-time = "2026-05-08T15:01:41.235Z" }, +] + [[package]] name = "cachetools" -version = "7.0.5" +version = "7.1.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/dd/57fe3fdb6e65b25a5987fd2cdc7e22db0aef508b91634d2e57d22928d41b/cachetools-7.0.5.tar.gz", hash = "sha256:0cd042c24377200c1dcd225f8b7b12b0ca53cc2c961b43757e774ebe190fd990", size = 37367, upload-time = "2026-03-09T20:51:29.451Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/c1/67cfb86aa21144796ff51068326d467fbef8ee42f8d08a3a8a926106cf0c/cachetools-7.1.3.tar.gz", hash = "sha256:135cfe944bc3c1e805505f65dae0bef375a2f96261171ab66c79ef77d0bda39d", size = 45780, upload-time = "2026-05-18T18:21:03.819Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/f3/39cf3367b8107baa44f861dc802cbf16263c945b62d8265d36034fc07bea/cachetools-7.0.5-py3-none-any.whl", hash = "sha256:46bc8ebefbe485407621d0a4264b23c080cedd913921bad7ac3ed2f26c183114", size = 13918, upload-time = "2026-03-09T20:51:27.33Z" }, + { url = "https://files.pythonhosted.org/packages/68/52/8ff5c1a3b2e821ced9b2998fba3ee29aa4525c0bf51e5ee55dd6f61a4ed5/cachetools-7.1.3-py3-none-any.whl", hash = "sha256:9876787e2346e20584d5cca236cb5d49d04e7193de91646f230725b2e1e8b804", size = 16763, upload-time = "2026-05-18T18:21:02.386Z" }, ] [[package]] name = "cbor2" -version = "5.9.0" +version = "6.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/cb/09939728be094d155b5d4ac262e39877875f5f7e36eea66beb359f647bd0/cbor2-5.9.0.tar.gz", hash = "sha256:85c7a46279ac8f226e1059275221e6b3d0e370d2bb6bd0500f9780781615bcea", size = 111231, upload-time = "2026-03-22T15:56:50.638Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/db/810437bcfe13cf5e09b68bad1ce57c8fa04ca9272c68946bbf2f4fa522c8/cbor2-6.1.1.tar.gz", hash = "sha256:6f0644869e0fdcd6f3874330b8f1cebd009f33191de43acf609dc2409cd362c4", size = 86297, upload-time = "2026-05-14T10:57:42.231Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/7d/9ccc36d10ef96e6038e48046ebe1ce35a1e7814da0e1e204d09e6ef09b8d/cbor2-5.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23606d31ba1368bd1b6602e3020ee88fe9523ca80e8630faf6b2fc904fd84560", size = 71500, upload-time = "2026-03-22T15:56:31.876Z" }, - { url = "https://files.pythonhosted.org/packages/70/e1/a6cca2cc72e13f00030c6a649f57ae703eb2c620806ab70c40db8eab33fa/cbor2-5.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0322296b9d52f55880e300ba8ba09ecf644303b99b51138bbb1c0fb644fa7c3e", size = 286953, upload-time = "2026-03-22T15:56:33.292Z" }, - { url = "https://files.pythonhosted.org/packages/08/3c/24cd5ef488a957d90e016f200a3aad820e4c2f85edd61c9fe4523007a1ee/cbor2-5.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:422817286c1d0ce947fb2f7eca9212b39bddd7231e8b452e2d2cc52f15332dba", size = 285454, upload-time = "2026-03-22T15:56:34.703Z" }, - { url = "https://files.pythonhosted.org/packages/a4/35/dca96818494c0ba47cdd73e8d809b27fa91f8fa0ce32a068a09237687454/cbor2-5.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9a4907e0c3035bb8836116854ed8e56d8aef23909d601fa59706320897ec2551", size = 279441, upload-time = "2026-03-22T15:56:35.888Z" }, - { url = "https://files.pythonhosted.org/packages/a4/44/d3362378b16e53cf7e535a3f5aed8476e2109068154e24e31981ef5bde9e/cbor2-5.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fb7afe77f8d269e42d7c4b515c6fd14f1ccc0625379fb6829b269f493d16eddd", size = 279673, upload-time = "2026-03-22T15:56:37.08Z" }, - { url = "https://files.pythonhosted.org/packages/43/d1/3533a697e5842fff7c2f64912eb251f8dcab3a8b5d88e228d6eebc3b5021/cbor2-5.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:86baf870d4c0bfc6f79de3801f3860a84ab76d9c8b0abb7f081f2c14c38d79d3", size = 71940, upload-time = "2026-03-22T15:56:38.366Z" }, - { url = "https://files.pythonhosted.org/packages/ff/e2/c6ba75f3fb25dfa15ab6999cc8709c821987e9ed8e375d7f58539261bcb9/cbor2-5.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:7221483fad0c63afa4244624d552abf89d7dfdbc5f5edfc56fc1ff2b4b818975", size = 67639, upload-time = "2026-03-22T15:56:39.39Z" }, - { url = "https://files.pythonhosted.org/packages/42/ff/b83492b096fbef26e9cb62c1a4bf2d3cef579ea7b33138c6c37c4ae66f67/cbor2-5.9.0-py3-none-any.whl", hash = "sha256:27695cbd70c90b8de5c4a284642c2836449b14e2c2e07e3ffe0744cb7669a01b", size = 24627, upload-time = "2026-03-22T15:56:48.847Z" }, + { url = "https://files.pythonhosted.org/packages/aa/74/d2d6e0e3da305a625d710a932080bf70f390c867dce73bd35ca6cd5a8d10/cbor2-6.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:86c65976e9e69154700ea5a447013f37ff8cb76431adf9df3ebbabe341b68b06", size = 407425, upload-time = "2026-05-14T10:57:17.814Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7d/08644318380306e0809ecc4756e67fb684b5e78a938ca9ff1c8c7f57fe73/cbor2-6.1.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:350beaac7a6049fe0a48309d7acd24611ab1176b4db1515f7fbcad20f5c09821", size = 453010, upload-time = "2026-05-14T10:57:19.593Z" }, + { url = "https://files.pythonhosted.org/packages/81/ff/43ef5f16a1a97ef4575c407d077d9355c01dfc54b1b1b8c5329b793c436b/cbor2-6.1.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:74bf0c3f48d215d49a99eb253fef6c00c19033339da22da4c29b53fe854093b8", size = 465110, upload-time = "2026-05-14T10:57:20.981Z" }, + { url = "https://files.pythonhosted.org/packages/c8/61/3069cee66bc4bedb95dce49b5e90d07e6c1ddf712435facf84ce0353da4a/cbor2-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a731277d123cee9c87e649077376f694892e4a2c3b0b1cb97132205c620947d8", size = 520269, upload-time = "2026-05-14T10:57:22.514Z" }, + { url = "https://files.pythonhosted.org/packages/f0/70/4b2ac02e0aa09419c13c434ce535cf508f08d5c411c6912d760c480ed8e6/cbor2-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:16e6df5a4971c2006805669be472a43bb382d0f3464c2236634b4e93095d7dd6", size = 532515, upload-time = "2026-05-14T10:57:24.289Z" }, + { url = "https://files.pythonhosted.org/packages/73/94/ab4ad4fd5929c1df56899c1135cc6957239a74a5b418e760502c9aadfb17/cbor2-6.1.1-cp314-cp314-win32.whl", hash = "sha256:0d0831b449567ee27afa25ff2756ac8719f11491f700396edb1dc1647ece7111", size = 285433, upload-time = "2026-05-14T10:57:25.665Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ed/995a3830ce4429be1ffeb57d2f11b2f06987573c04a4ea4112bd5d7de643/cbor2-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:91dac40fc0b8e0592a3e8d766377af3186e2736448c684465cca8606486e58ae", size = 308923, upload-time = "2026-05-14T10:57:27.019Z" }, + { url = "https://files.pythonhosted.org/packages/ea/88/1797af54eca15bca2d963cd2d3a7337758961a31fd03438f2e82ec94ea87/cbor2-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:be5ccd594ea6f1998cd83afb53b47e383e5efd7661a316a528216412109221c7", size = 299687, upload-time = "2026-05-14T10:57:28.656Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/ecc0797db8b627f889389d8ea8a4af389bdff7500685e56969a6c4449c01/cbor2-6.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47d2616d30212bd3db8c2897b453176401569e0e4ec3434b770e9652604d74c5", size = 403186, upload-time = "2026-05-14T10:57:30.111Z" }, + { url = "https://files.pythonhosted.org/packages/c6/28/780af53231e1a6afc36f2b922ff587a9e1a25df7756628101a6070a9312f/cbor2-6.1.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fd9d300ad983b860fbfb0ab148ddd3a379be25430bb141ad41344adc1c0792c1", size = 446311, upload-time = "2026-05-14T10:57:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/cc298ed16745995cf21caeec52213d157be8d5bfb405ee8ed420ffb5e038/cbor2-6.1.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:b8594563ccfd56f2bb56cdd8445f7a1f00d3065d84ea06f8e361da765abee08f", size = 459640, upload-time = "2026-05-14T10:57:32.967Z" }, + { url = "https://files.pythonhosted.org/packages/f1/37/e4d95459d48e8a739c086249884b27458541df5a7fc149debdb0e0c7becb/cbor2-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8df2a530b45c7769ed43c02e3f7c9841ed4990887e1c29858b08363a35067bf5", size = 511667, upload-time = "2026-05-14T10:57:34.465Z" }, + { url = "https://files.pythonhosted.org/packages/40/e8/32e529bd938c71456d38d7c6a62d0d75399e720553d6514a467fee9b004d/cbor2-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d63181b5b213ab72eed01e62bfa4c994fe7de68433d12548d54156411ba0aac4", size = 527195, upload-time = "2026-05-14T10:57:36.09Z" }, + { url = "https://files.pythonhosted.org/packages/be/96/42275a7d34baa8457a686c5e5a3bf5240e753595a6bd79c2c419347a2083/cbor2-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:cba9a9ebc031267b76c2bdfd4a5a491874c27339d6ec9d0895fc4fde8f519565", size = 279851, upload-time = "2026-05-14T10:57:37.443Z" }, + { url = "https://files.pythonhosted.org/packages/e3/97/09053af3e4825aa3b83b1ec2306c9228efe665fbfb90229e441b9c1b3cd5/cbor2-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:81af6e3a031191b483ca42b16d152627c6a9dc61c1fbef270403820ab587fc86", size = 302537, upload-time = "2026-05-14T10:57:39.143Z" }, + { url = "https://files.pythonhosted.org/packages/4f/29/e257a381d494615348c7266fc173a36edce142533a5befe3c0967fd45ab4/cbor2-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f0bc04543c562bd0b35fc79a29528017fe63104757c1b421d8c1ddfbe6761eca", size = 290270, upload-time = "2026-05-14T10:57:40.597Z" }, ] [[package]] @@ -279,11 +345,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.5.20" 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/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } 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/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] [[package]] @@ -390,14 +456,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.2" +version = "8.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" }, ] [[package]] @@ -466,50 +532,50 @@ wheels = [ [[package]] name = "coolname" -version = "4.2.0" +version = "5.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8a/90/0cb2e364ed736c01790b264a2cafbe7702fc50090337ce37366e03384d20/coolname-4.2.0.tar.gz", hash = "sha256:b0f2d6d4654d627230e143f9efa5c5e1955b11e0cdd54211558c1170496f9f19", size = 38718, upload-time = "2026-04-11T17:26:13.142Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/45/deae12f31934301a488df985880240a200119aca2e4b5ceba71d73bc5e86/coolname-5.0.0.tar.gz", hash = "sha256:594bc6c98ebc75ddd51c0ce10efbb5d2556c14eac60b5b36900dfdd5db20eecf", size = 45800, upload-time = "2026-04-23T06:04:50.824Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/9f/9aec78a18b64333f9a54d18f9d2f479d51c25bc715dc57e46c12bfd5ce21/coolname-4.2.0-py3-none-any.whl", hash = "sha256:6a67db5e3c3f1b196a9ad4563af118da0d962875ed9b6e8a6dfd96e045847c3a", size = 40130, upload-time = "2026-04-11T17:26:10.382Z" }, + { url = "https://files.pythonhosted.org/packages/97/35/cf824e535233c432048bd7455b28808776471ab5861375772b2c98ea4cbd/coolname-5.0.0-py3-none-any.whl", hash = "sha256:b86eea9670aa0620965167d1bfadfe654fabec839a5b51e8da611c4a64f86192", size = 47368, upload-time = "2026-04-23T06:04:49.374Z" }, ] [[package]] name = "coverage" -version = "7.13.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, - { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, - { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, - { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, - { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, - { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, - { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, - { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, - { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, - { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, - { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, - { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, - { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, - { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, - { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, - { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, - { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, - { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, - { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, - { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, - { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, - { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +version = "7.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" }, + { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" }, + { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" }, + { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" }, + { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" }, + { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" }, + { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" }, + { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" }, + { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" }, + { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" }, + { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" }, + { 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]] @@ -531,60 +597,60 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.7" +version = "48.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, - { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, - { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, - { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, - { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, - { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, - { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, - { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, - { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, - { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, - { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, - { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, - { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, - { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, - { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, - { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, - { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, - { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, - { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, - { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, - { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, - { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, - { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, - { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, - { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, - { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, - { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, - { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, - { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, - { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, ] [[package]] name = "cyclopts" -version = "4.10.2" +version = "4.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -592,9 +658,9 @@ dependencies = [ { name = "rich" }, { name = "rich-rst" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/2c/fced34890f6e5a93a4b7afb2c71e8eee2a0719fb26193a0abf159ecb714d/cyclopts-4.10.2.tar.gz", hash = "sha256:d7b950457ef2563596d56331f80cbbbf86a2772535fb8b315c4f03bc7e6127f1", size = 166664, upload-time = "2026-04-08T23:57:45.805Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/75/c7988cb0b90ab9d6728d4c444f09256cfdc97606c1638d8a8090eeb57f7e/cyclopts-4.14.1.tar.gz", hash = "sha256:5d01fc3a940a0e4e872a81ed533a10c1892b5baaa4121fe69528a2b8c17fc9d0", size = 177281, upload-time = "2026-05-19T19:49:22.312Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/bd/05055d8360cef0757d79367157f3b15c0a0715e81e08f86a04018ec045f0/cyclopts-4.10.2-py3-none-any.whl", hash = "sha256:a1f2d6f8f7afac9456b48f75a40b36658778ddc9c6d406b520d017ae32c990fe", size = 204314, upload-time = "2026-04-08T23:57:46.969Z" }, + { url = "https://files.pythonhosted.org/packages/22/1d/f6c80f82a42c69ff5a86c2580e789133de387d067210c7c46677618a2ca7/cyclopts-4.14.1-py3-none-any.whl", hash = "sha256:b705fdaa3277554a5da03e9bb5ff96a44d204a1afe3099f1b56e1586488bbfd4", size = 214925, upload-time = "2026-05-19T19:49:23.559Z" }, ] [[package]] @@ -798,7 +864,7 @@ wheels = [ [[package]] name = "django-stubs" -version = "6.0.3" +version = "6.0.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, @@ -806,22 +872,22 @@ dependencies = [ { name = "types-pyyaml" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/86/0c/8d0d875af79bf774c1c3997c84aa118dba3a77be12086b9c14e130e8ec72/django_stubs-6.0.3.tar.gz", hash = "sha256:ee895f403c373608eeb50822f0733f9d9ec5ab12731d4ab58956053bb95fdd9e", size = 278214, upload-time = "2026-04-18T15:11:22.327Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/82/ccf2a2dc9cdb4bd9cbe91f11e887589bf2da7609506db00ccbc73bd8a6da/django_stubs-6.0.4.tar.gz", hash = "sha256:7aee77e8de9c14c0d9cf84988befe826d93cbc15a87e0ade2943f14d553451cf", size = 280019, upload-time = "2026-05-09T21:24:30.436Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/a3/6751b7684d20fc4f228bdd3dd8341d382ab3faaf65d3d050c0d59ab0a1b0/django_stubs-6.0.3-py3-none-any.whl", hash = "sha256:5fee22bcbbad59a78c727a820b6f4e68ff442ca76a922b7002e57c25dd7cb390", size = 541570, upload-time = "2026-04-18T15:11:20.711Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e7/5128914ada94dd6277626ef5a4a5680a4def7d2f9366214d26c1cd86723b/django_stubs-6.0.4-py3-none-any.whl", hash = "sha256:e991c68f77239663577a5f4fc75e99c84f867f378cafc97cbf4acc5aff378279", size = 543791, upload-time = "2026-05-09T21:24:28.218Z" }, ] [[package]] name = "django-stubs-ext" -version = "6.0.3" +version = "6.0.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/e6/5dcdaa785ec3eed5fc196c7e68fb7ad9d9fe6d5acccea4690e65f2546417/django_stubs_ext-6.0.3.tar.gz", hash = "sha256:3307d42132bc295d5744de6276bc5fdf6896efc70f891e21c0ae8bdf529d2762", size = 6663, upload-time = "2026-04-18T15:10:53.667Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/b0/94335dc59138483c2bd2edf81cb39240fdef5e72c8cf0a6c177db207617b/django_stubs_ext-6.0.4.tar.gz", hash = "sha256:ff21f7b4362928b56e18cda0595f296e33c665f3019f4e3e4231977385e76cac", size = 6684, upload-time = "2026-05-09T21:24:02.745Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/fa/0a3a05c29d6295dbd52fa3cb4047a95de11ba4f2696072d6f3f2c1e6f370/django_stubs_ext-6.0.3-py3-none-any.whl", hash = "sha256:9e4105955419ae310d7da9cfd808e039d4dae3092c628f021057bb4f2c237f8f", size = 10354, upload-time = "2026-04-18T15:10:52.395Z" }, + { url = "https://files.pythonhosted.org/packages/b4/42/7db8470c0e276e7c7763c468441381dfa8727a360c68473f33ef828422bb/django_stubs_ext-6.0.4-py3-none-any.whl", hash = "sha256:0434a912bb08a370afcac9e90305c53e6f4eed3c1d1d46962559da5f8dbb8f27", size = 10373, upload-time = "2026-05-09T21:24:01.291Z" }, ] [[package]] @@ -838,28 +904,28 @@ wheels = [ [[package]] name = "djangorestframework" -version = "3.16.1" +version = "3.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735, upload-time = "2025-08-06T17:50:53.251Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/d7/c016e69fac19ff8afdc89db9d31d9ae43ae031e4d1993b20aca179b8301a/djangorestframework-3.17.1.tar.gz", hash = "sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5", size = 905742, upload-time = "2026-03-24T16:58:33.705Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e1/2c516bdc83652b1a60c6119366ac2c0607b479ed05cd6093f916ca8928f8/djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457", size = 898844, upload-time = "2026-03-24T16:58:31.845Z" }, ] [[package]] name = "djangorestframework-gis" -version = "1.2.0" +version = "1.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, { name = "django-filter" }, { name = "djangorestframework" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/fb/36b4f5d9d0fccdb17ae620c580d4b340a7b3643830e49b8cc5d8fc1eb1ee/djangorestframework_gis-1.2.0.tar.gz", hash = "sha256:702ba9ad44173b7cc70e48c6c84da48c28f6f82612cc901a77fdb54c5c57c971", size = 50983, upload-time = "2025-06-02T19:22:41.411Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/cb/548d66f261c1bd94ccddafccf7db5a03e1db324075e4f1243b32185afe98/djangorestframework_gis-1.2.1.tar.gz", hash = "sha256:649ad0cb361dd85f2113b1796248eb2b198a01fa3d64c5e486106224c0ad51fd", size = 50304, upload-time = "2026-05-04T17:02:15.524Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/86/b7e85e372ecc97dc1344785f82dc0c51c2b2c8b1d2b8660d3d8752fd1b3c/djangorestframework_gis-1.2.0-py2.py3-none-any.whl", hash = "sha256:3924651b2f6dcb5a64b30df9692577af548a04725b0c2c36cbc385f7c50fc80a", size = 22254, upload-time = "2025-06-02T19:22:39.214Z" }, + { url = "https://files.pythonhosted.org/packages/8e/db/ffb8914cd0ee6d63d3e3f0f4a8d9d07d8b945e2287d6048316a542dcb4e5/djangorestframework_gis-1.2.1-py2.py3-none-any.whl", hash = "sha256:73acb51918b18e484407c0fff61aca11a1f5a216fa694bc4c0c734b7097ecc77", size = 21901, upload-time = "2026-05-04T17:02:13.632Z" }, ] [[package]] @@ -885,15 +951,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, ] -[[package]] -name = "docutils" -version = "0.22.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, -] - [[package]] name = "drf-spectacular" version = "0.29.0" @@ -920,27 +977,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] -[[package]] -name = "fakeredis" -version = "2.34.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "redis" }, - { name = "sortedcontainers" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/11/40/fd09efa66205eb32253d2b2ebc63537281384d2040f0a88bcd2289e120e4/fakeredis-2.34.1.tar.gz", hash = "sha256:4ff55606982972eecce3ab410e03d746c11fe5deda6381d913641fbd8865ea9b", size = 177315, upload-time = "2026-02-25T13:17:51.315Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/b5/82f89307d0d769cd9bf46a54fb9136be08e4e57c5570ae421db4c9a2ba62/fakeredis-2.34.1-py3-none-any.whl", hash = "sha256:0107ec99d48913e7eec2a5e3e2403d1bd5f8aa6489d1a634571b975289c48f12", size = 122160, upload-time = "2026-02-25T13:17:49.701Z" }, -] - -[package.optional-dependencies] -lua = [ - { name = "lupa" }, -] - [[package]] name = "fastapi" -version = "0.135.3" +version = "0.136.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -949,9 +988,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, ] [[package]] @@ -981,11 +1020,11 @@ wheels = [ [[package]] name = "fsspec" -version = "2026.3.0" +version = "2026.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/cf/b50ddf667c15276a9ab15a70ef5f257564de271957933ffea49d2cdbcdfb/fsspec-2026.3.0.tar.gz", hash = "sha256:1ee6a0e28677557f8c2f994e3eea77db6392b4de9cd1f5d7a9e87a0ae9d01b41", size = 313547, upload-time = "2026-03-27T19:11:14.892Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/8d/1c51c094345df128ca4a990d633fe1a0ff28726c9e6b3c41ba65087bba1d/fsspec-2026.4.0.tar.gz", hash = "sha256:301d8ac70ae90ef3ad05dcf94d6c3754a097f9b5fe4667d2787aa359ec7df7e4", size = 312760, upload-time = "2026-04-29T20:42:38.635Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl", hash = "sha256:d2ceafaad1b3457968ed14efa28798162f1638dbb5d2a6868a2db002a5ee39a4", size = 202595, upload-time = "2026-03-27T19:11:13.595Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl", hash = "sha256:11ef7bb35dab8a394fde6e608221d5cf3e8499401c249bebaeaad760a1a8dec2", size = 203402, upload-time = "2026-04-29T20:42:36.842Z" }, ] [[package]] @@ -1028,29 +1067,49 @@ wheels = [ [[package]] name = "greenlet" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" }, - { url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" }, - { url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" }, - { url = "https://files.pythonhosted.org/packages/d2/4a/74078d3936712cff6d3c91a930016f476ce4198d84e224fe6d81d3e02880/greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19", size = 673405, upload-time = "2026-04-08T16:40:42.527Z" }, - { url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" }, - { url = "https://files.pythonhosted.org/packages/79/3e/df8a83ab894751bc31e1106fdfaa80ca9753222f106b04de93faaa55feb7/greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd", size = 471670, upload-time = "2026-04-08T16:43:08.512Z" }, - { url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" }, - { url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" }, - { url = "https://files.pythonhosted.org/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802", size = 236932, upload-time = "2026-04-08T17:04:33.551Z" }, - { url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" }, - { url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" }, - { url = "https://files.pythonhosted.org/packages/ef/92/466b0d9afd44b8af623139a3599d651c7564fa4152f25f117e1ee5949ffb/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa", size = 665872, upload-time = "2026-04-08T16:40:43.912Z" }, - { url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" }, - { url = "https://files.pythonhosted.org/packages/0d/14/3395a7ef3e260de0325152ddfe19dffb3e49fe10873b94654352b53ad48e/greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72", size = 489237, upload-time = "2026-04-08T16:43:09.993Z" }, - { url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" }, - { url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" }, - { url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" }, +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, + { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" }, + { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, + { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" }, + { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, + { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" }, + { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, + { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, + { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, + { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, + { url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" }, + { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, + { url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" }, + { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, + { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" }, + { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, + { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, + { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" }, ] [[package]] @@ -1176,14 +1235,14 @@ wheels = [ [[package]] name = "gunicorn" -version = "25.3.0" +version = "26.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" }, + { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, ] [[package]] @@ -1282,23 +1341,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, -] - -[[package]] -name = "importlib-metadata" -version = "8.7.1" +version = "3.15" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] @@ -1421,90 +1468,48 @@ wheels = [ [[package]] name = "librt" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/6b/3d5c13fb3e3c4f43206c8f9dfed13778c2ed4f000bacaa0b7ce3c402a265/librt-0.9.0.tar.gz", hash = "sha256:a0951822531e7aee6e0dfb556b30d5ee36bbe234faf60c20a16c01be3530869d", size = 184368, upload-time = "2026-04-09T16:06:26.173Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/c1/184e539543f06ea2912f4b92a5ffaede4f9b392689e3f00acbf8134bee92/librt-0.9.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:3f05d145df35dca5056a8bc3838e940efebd893a54b3e19b2dda39ceaa299bcb", size = 67830, upload-time = "2026-04-09T16:05:34.517Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ad/23399bdcb7afca819acacdef31b37ee59de261bd66b503a7995c03c4b0dc/librt-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1c587494461ebd42229d0f1739f3aa34237dd9980623ecf1be8d3bcba79f4499", size = 70280, upload-time = "2026-04-09T16:05:35.649Z" }, - { url = "https://files.pythonhosted.org/packages/9f/0b/4542dc5a2b8772dbf92cafb9194701230157e73c14b017b6961a23598b03/librt-0.9.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0a2040f801406b93657a70b72fa12311063a319fee72ce98e1524da7200171f", size = 201925, upload-time = "2026-04-09T16:05:36.739Z" }, - { url = "https://files.pythonhosted.org/packages/31/d4/8ee7358b08fd0cfce051ef96695380f09b3c2c11b77c9bfbc367c921cce5/librt-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f38bc489037eca88d6ebefc9c4d41a4e07c8e8b4de5188a9e6d290273ad7ebb1", size = 212381, upload-time = "2026-04-09T16:05:38.043Z" }, - { url = "https://files.pythonhosted.org/packages/f2/94/a2025fe442abedf8b038038dab3dba942009ad42b38ea064a1a9e6094241/librt-0.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3fd278f5e6bf7c75ccd6d12344eb686cc020712683363b66f46ac79d37c799f", size = 227065, upload-time = "2026-04-09T16:05:39.394Z" }, - { url = "https://files.pythonhosted.org/packages/7c/e9/b9fcf6afa909f957cfbbf918802f9dada1bd5d3c1da43d722fd6a310dc3f/librt-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fcbdf2a9ca24e87bbebb47f1fe34e531ef06f104f98c9ccfc953a3f3344c567a", size = 221333, upload-time = "2026-04-09T16:05:40.999Z" }, - { url = "https://files.pythonhosted.org/packages/ac/7c/ba54cd6aa6a3c8cd12757a6870e0c79a64b1e6327f5248dcff98423f4d43/librt-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e306d956cfa027fe041585f02a1602c32bfa6bb8ebea4899d373383295a6c62f", size = 229051, upload-time = "2026-04-09T16:05:42.605Z" }, - { url = "https://files.pythonhosted.org/packages/4b/4b/8cfdbad314c8677a0148bf0b70591d6d18587f9884d930276098a235461b/librt-0.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:465814ab157986acb9dfa5ccd7df944be5eefc0d08d31ec6e8d88bc71251d845", size = 222492, upload-time = "2026-04-09T16:05:43.842Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d1/2eda69563a1a88706808decdce035e4b32755dbfbb0d05e1a65db9547ed1/librt-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:703f4ae36d6240bfe24f542bac784c7e4194ec49c3ba5a994d02891649e2d85b", size = 223849, upload-time = "2026-04-09T16:05:45.054Z" }, - { url = "https://files.pythonhosted.org/packages/04/44/b2ed37df6be5b3d42cfe36318e0598e80843d5c6308dd63d0bf4e0ce5028/librt-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3be322a15ee5e70b93b7a59cfd074614f22cc8c9ff18bd27f474e79137ea8d3b", size = 245001, upload-time = "2026-04-09T16:05:46.34Z" }, - { url = "https://files.pythonhosted.org/packages/47/e7/617e412426df89169dd2a9ed0cc8752d5763336252c65dbf945199915119/librt-0.9.0-cp314-cp314-win32.whl", hash = "sha256:b8da9f8035bb417770b1e1610526d87ad4fc58a2804dc4d79c53f6d2cf5a6eb9", size = 51799, upload-time = "2026-04-09T16:05:47.738Z" }, - { url = "https://files.pythonhosted.org/packages/24/ed/c22ca4db0ca3cbc285e4d9206108746beda561a9792289c3c31281d7e9df/librt-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:b8bd70d5d816566a580d193326912f4a76ec2d28a97dc4cd4cc831c0af8e330e", size = 59165, upload-time = "2026-04-09T16:05:49.198Z" }, - { url = "https://files.pythonhosted.org/packages/24/56/875398fafa4cbc8f15b89366fc3287304ddd3314d861f182a4b87595ace0/librt-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:fc5758e2b7a56532dc33e3c544d78cbaa9ecf0a0f2a2da2df882c1d6b99a317f", size = 49292, upload-time = "2026-04-09T16:05:50.362Z" }, - { url = "https://files.pythonhosted.org/packages/4c/61/bc448ecbf9b2d69c5cff88fe41496b19ab2a1cbda0065e47d4d0d51c0867/librt-0.9.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f24b90b0e0c8cc9491fb1693ae91fe17cb7963153a1946395acdbdd5818429a4", size = 70175, upload-time = "2026-04-09T16:05:51.564Z" }, - { url = "https://files.pythonhosted.org/packages/60/f2/c47bb71069a73e2f04e70acbd196c1e5cc411578ac99039a224b98920fd4/librt-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fe56e80badb66fdcde06bef81bbaa5bfcf6fbd7aefb86222d9e369c38c6b228", size = 72951, upload-time = "2026-04-09T16:05:52.699Z" }, - { url = "https://files.pythonhosted.org/packages/29/19/0549df59060631732df758e8886d92088da5fdbedb35b80e4643664e8412/librt-0.9.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:527b5b820b47a09e09829051452bb0d1dd2122261254e2a6f674d12f1d793d54", size = 225864, upload-time = "2026-04-09T16:05:53.895Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f8/3b144396d302ac08e50f89e64452c38db84bc7b23f6c60479c5d3abd303c/librt-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d429bdd4ac0ab17c8e4a8af0ed2a7440b16eba474909ab357131018fe8c7e71", size = 241155, upload-time = "2026-04-09T16:05:55.191Z" }, - { url = "https://files.pythonhosted.org/packages/7a/ce/ee67ec14581de4043e61d05786d2aed6c9b5338816b7859bcf07455c6a9f/librt-0.9.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7202bdcac47d3a708271c4304a474a8605a4a9a4a709e954bf2d3241140aa938", size = 252235, upload-time = "2026-04-09T16:05:56.549Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fa/0ead15daa2b293a54101550b08d4bafe387b7d4a9fc6d2b985602bae69b6/librt-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0d620e74897f8c2613b3c4e2e9c1e422eb46d2ddd07df540784d44117836af3", size = 244963, upload-time = "2026-04-09T16:05:57.858Z" }, - { url = "https://files.pythonhosted.org/packages/29/68/9fbf9a9aa704ba87689e40017e720aced8d9a4d2b46b82451d8142f91ec9/librt-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d69fc39e627908f4c03297d5a88d9284b73f4d90b424461e32e8c2485e21c283", size = 257364, upload-time = "2026-04-09T16:05:59.686Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8d/9d60869f1b6716c762e45f66ed945b1e5dd649f7377684c3b176ae424648/librt-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c2640e23d2b7c98796f123ffd95cf2022c7777aa8a4a3b98b36c570d37e85eee", size = 247661, upload-time = "2026-04-09T16:06:00.938Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/a5c365093962310bfdb4f6af256f191085078ffb529b3f0cbebb5b33ebe2/librt-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:451daa98463b7695b0a30aa56bf637831ea559e7b8101ac2ef6382e8eb15e29c", size = 248238, upload-time = "2026-04-09T16:06:02.537Z" }, - { url = "https://files.pythonhosted.org/packages/a0/3c/2d34365177f412c9e19c0a29f969d70f5343f27634b76b765a54d8b27705/librt-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:928bd06eca2c2bbf4349e5b817f837509b0604342e65a502de1d50a7570afd15", size = 269457, upload-time = "2026-04-09T16:06:03.833Z" }, - { url = "https://files.pythonhosted.org/packages/bc/cd/de45b239ea3bdf626f982a00c14bfcf2e12d261c510ba7db62c5969a27cd/librt-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:a9c63e04d003bc0fb6a03b348018b9a3002f98268200e22cc80f146beac5dc40", size = 52453, upload-time = "2026-04-09T16:06:05.229Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f9/bfb32ae428aa75c0c533915622176f0a17d6da7b72b5a3c6363685914f70/librt-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f162af66a2ed3f7d1d161a82ca584efd15acd9c1cff190a373458c32f7d42118", size = 60044, upload-time = "2026-04-09T16:06:06.398Z" }, - { url = "https://files.pythonhosted.org/packages/aa/47/7d70414bcdbb3bc1f458a8d10558f00bbfdb24e5a11740fc8197e12c3255/librt-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a4b25c6c25cac5d0d9d6d6da855195b254e0021e513e0249f0e3b444dc6e0e61", size = 50009, upload-time = "2026-04-09T16:06:07.995Z" }, -] - -[[package]] -name = "lupa" -version = "2.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c4/a0/327c40cdd59f0ce9dc6faaf73bf6e52b0b22188f1ee2366ef36d0e5c1b85/lupa-2.7.tar.gz", hash = "sha256:73a64ce5dc8cd95b75a330c1513e46e098d40fceed3fea516c09f6595eade889", size = 8121014, upload-time = "2026-04-07T08:54:54.179Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/b7/18f7e66df0245cee685b22f458e9c614fdff9406aaf77a2e81b452a56da6/lupa-2.7-cp310-abi3-win32.whl", hash = "sha256:9992c5afa5adddabb953685b205cfd42cb6cdaf44316f4e4e2cf1c5dc4815f74", size = 1594773, upload-time = "2026-04-07T08:52:36.557Z" }, - { url = "https://files.pythonhosted.org/packages/29/e5/390470a09b8972772f547b51c29baafd1708b20c46f92ac8c251d1db2fbf/lupa-2.7-cp310-abi3-win_arm64.whl", hash = "sha256:e353bad751b55d48d1b909a6b48fb5b306a2d6cd8404981a1554b356da2cf050", size = 1371622, upload-time = "2026-04-07T08:52:38.976Z" }, - { url = "https://files.pythonhosted.org/packages/58/b3/83836d5f3d4af2c135b12dd4483592c4064339b075158cbcc8fbfe5e239b/lupa-2.7-cp312-abi3-macosx_10_13_x86_64.whl", hash = "sha256:5bb4461f6127293c866819a22f39a3878d49314f4463b4adf45d3bf968d15c92", size = 1193921, upload-time = "2026-04-07T08:52:59.073Z" }, - { url = "https://files.pythonhosted.org/packages/81/22/9b3db3535ec25f3e091ffd111b39e25a16bd17bcddea5e5a269075ec104d/lupa-2.7-cp312-abi3-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:9de6c9263a82bc4f0db73d0c8534bdb8a3a1fd13f448c967d7f3e085dbc50c05", size = 1434166, upload-time = "2026-04-07T08:53:01.119Z" }, - { url = "https://files.pythonhosted.org/packages/8b/b7/a7b8561ceefca78c6b38fcd2edd624dbbd66818d6a7cacdb49155745f44c/lupa-2.7-cp312-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23915808a2475705582e7c8adc17fac5ca9f3a803be184798e06cbd8c7350580", size = 1149951, upload-time = "2026-04-07T08:53:02.814Z" }, - { url = "https://files.pythonhosted.org/packages/1f/1d/ae4f7cc90eb3e42021e0ca7ef5f4ce5e9894a3f46b47d3dfa40d9b749c94/lupa-2.7-cp312-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5f720ce493fa9049917954ae1270a5d3c41987f792e34a633f725fa0fc85781e", size = 1409419, upload-time = "2026-04-07T08:53:04.706Z" }, - { url = "https://files.pythonhosted.org/packages/40/17/bd577543997b3e0b9f49525b4a9966817808048e34a30ca3e15939d9de40/lupa-2.7-cp312-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8046dedc75cbd93ee4ec01d3088511079425d9438ffddd7c0de8bac54db6701d", size = 1242571, upload-time = "2026-04-07T08:53:06.427Z" }, - { url = "https://files.pythonhosted.org/packages/0e/68/3e3c32342dfa906c9c3ab2a88084688c1e22f77be1cc4da44669faa071d3/lupa-2.7-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:319c02f8e4ec2dbdccb47bb3676a139f8634b255efafbb433a2c705e315fbb76", size = 1855925, upload-time = "2026-04-07T08:53:08.573Z" }, - { url = "https://files.pythonhosted.org/packages/30/f5/c8a7a80dc6f31216fdce524089cea475af7ae1e6b28952fecba076d8a166/lupa-2.7-cp312-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:9545a538067f517330faa56d59ff08b0c74910711597dcccc24ad71a97c9cb8c", size = 1128866, upload-time = "2026-04-07T08:53:10.848Z" }, - { url = "https://files.pythonhosted.org/packages/64/fa/a9fe2aaf0605b5556ebb8539c19c023fdd3bc78a0d07811ceba27d3f18c8/lupa-2.7-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:90c0adf6519cac6f2ab1fc094e6ea40650198571e9aace52b8a7e69e316a2b89", size = 1457480, upload-time = "2026-04-07T08:53:13.228Z" }, - { url = "https://files.pythonhosted.org/packages/5f/c0/dd451cb97c52bcb69b8ecc1e92c87426dd88cb51e79d128accfda2b66949/lupa-2.7-cp312-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:ab47477a37c562c6512a222c0acaddb11d9d69ec16e50913214bc15b2ab3d8bc", size = 1425606, upload-time = "2026-04-07T08:53:14.835Z" }, - { url = "https://files.pythonhosted.org/packages/31/67/af8600ce0268e675f7d23e970bb2a5f6c68a3686884ef2518c9d24c75379/lupa-2.7-cp312-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:4615785072c1185b0ed1caf0dea188abd7890667e730c6d6717d84b317d10e78", size = 1253143, upload-time = "2026-04-07T08:53:17.078Z" }, - { url = "https://files.pythonhosted.org/packages/2b/ec/5ba9bad40d46baebc2f85d22c21b44c1afa38363e00cf8f75ca2add07267/lupa-2.7-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d0bf6bd82d639d6cd527983ecff76450bc8878a75e4434c0fbde5edf16aa2bb4", size = 2395157, upload-time = "2026-04-07T08:53:19.409Z" }, - { url = "https://files.pythonhosted.org/packages/1b/84/69bbd0cba804e33761709775b769db54187003f98000350ae2807793821a/lupa-2.7-cp312-abi3-win32.whl", hash = "sha256:093e03d520d294a372f2521e5948b5660874c889f189f23b538d59fa1841c365", size = 1606024, upload-time = "2026-04-07T08:53:22.219Z" }, - { url = "https://files.pythonhosted.org/packages/3b/a1/4eee20d28e7170fb50e7b2389946e9b385b9e23c079391648ef7a7b3db10/lupa-2.7-cp312-abi3-win_arm64.whl", hash = "sha256:5b4630d86f3d97613f08cb0cb302ecb2a5266e67fbb2d50eb69ba69f259b5ee0", size = 1364378, upload-time = "2026-04-07T08:53:24.858Z" }, - { url = "https://files.pythonhosted.org/packages/ba/01/198d43e8abee9febca7bbed5cbd2bbe471f4a08fd20860f5544fb5fcf782/lupa-2.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e1d832452975b2251bda891a12e5fc239151f347802fd2f6e2224b3adf5caf49", size = 1209249, upload-time = "2026-04-07T08:53:48.153Z" }, - { url = "https://files.pythonhosted.org/packages/32/c4/433da832d0d1b551f8d699a23f63ee02203b13522e9956a18b19a4770b89/lupa-2.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fbc5dfabab4ef6da94284db04132da1702696cbdb39b57e83e7e577c30d12e3", size = 1826708, upload-time = "2026-04-07T08:53:50.641Z" }, - { url = "https://files.pythonhosted.org/packages/c6/96/060b5e32c70af7a8b47c1ac8b497ade1817fada345f904edaafb28928894/lupa-2.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c8b7012f1389e7b8399edf7520e6f3aba1ff99d192b269ccd0a1c452d1fc2064", size = 2366778, upload-time = "2026-04-07T08:53:53.493Z" }, - { url = "https://files.pythonhosted.org/packages/e9/eb/05a4169973a4e9498268f66fcc778a56331b4fce94feb15b9b41a3f831ed/lupa-2.7-cp314-cp314-win_amd64.whl", hash = "sha256:a2de0c293cb0c67c38963b676017ed2e38c5b76254316e956451ca21589b44fe", size = 1994579, upload-time = "2026-04-07T08:54:05.849Z" }, - { url = "https://files.pythonhosted.org/packages/13/f7/651916ffd568ac4261298fdcb64dd8213603ed22ebaab8541bb8114c73d9/lupa-2.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8e0fd2c9895e8a456f583ac05b210b406924cf25921a675c42b00806cb14a8f4", size = 1251117, upload-time = "2026-04-07T08:53:55.59Z" }, - { url = "https://files.pythonhosted.org/packages/aa/e5/3b9aea6bad4141c9379f0612c94e7c465204d64738eca25fb03c688fab35/lupa-2.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4abb197d3adb7607651df03853c0f7656d23a8f6d91f43b72d8d7a1032c6e82", size = 1814587, upload-time = "2026-04-07T08:53:57.711Z" }, - { url = "https://files.pythonhosted.org/packages/bf/5d/6c31a442643ad5b3fb0fd74236fc4bdf835162e22686213ead92600c560e/lupa-2.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2dc4956154e0fb556f1b4d37f97ee83f8ab4288151172ebb869209fb1732070e", size = 2348299, upload-time = "2026-04-07T08:54:00.782Z" }, - { url = "https://files.pythonhosted.org/packages/41/b2/2a18c34abf3e63daa539c29dcbf595de4c1fa482aa632b0045555df333c1/lupa-2.7-cp314-cp314t-win_amd64.whl", hash = "sha256:8e01cefd60857ab39a9fd648c594d9a77872f768d9411e3ba0d84373dafae8fc", size = 2209127, upload-time = "2026-04-07T08:54:03.795Z" }, - { url = "https://files.pythonhosted.org/packages/78/c1/9976b81671b339196a69f4ee96a885864b96cf9541c6c1c76ce00cb08df4/lupa-2.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:10d679932e759d628cd9df56590d6e90dd81040d10552172d01b2419e8f16565", size = 1185876, upload-time = "2026-04-07T08:54:18.172Z" }, - { url = "https://files.pythonhosted.org/packages/08/3e/17109c4f7e333205a08f01c2d3d4222c76ce6f8b6c72791a739b11c6f43a/lupa-2.7-cp39-abi3-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:aa5d3f540fed4534cac696d0a5ee6cb267180b45241501a43db5ca1699d46746", size = 1468828, upload-time = "2026-04-07T08:54:20.621Z" }, - { url = "https://files.pythonhosted.org/packages/4e/25/9bcbd18a5742d0b1b80b783548007dcc57d4159075ff35fd833bf53548a5/lupa-2.7-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:07f2cd100f7278888f23f79d7ad49f3544835501543fa65be74bc5bfd2369aea", size = 1172884, upload-time = "2026-04-07T08:54:22.544Z" }, - { url = "https://files.pythonhosted.org/packages/5a/3f/7e0a6660a84ae3b6d8f9834ff183d0522b123b3b2329f0343f52d469fd1a/lupa-2.7-cp39-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c98d4d085e438d5fcee10e555baf67bc84553417efbe1d163fcc152466696a69", size = 1449860, upload-time = "2026-04-07T08:54:24.954Z" }, - { url = "https://files.pythonhosted.org/packages/8a/18/66e5289a6d086235723fa7516049313eb994a5d6ead9eb8f7f7eb8523172/lupa-2.7-cp39-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3cd273b4fa9c0fcdaed0a541e37ced22117c7aeab8af14d75ece213ce4c37506", size = 1281828, upload-time = "2026-04-07T08:54:27.633Z" }, - { url = "https://files.pythonhosted.org/packages/32/8f/5499f13dac1329a9759fd9344622656b3f00c52ea70f9be94de9fb273256/lupa-2.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:14b26aaa7f600c670eec2afeb5cf312cc398c0a1bd958f97f5328e8162d11f78", size = 1910337, upload-time = "2026-04-07T08:54:30.047Z" }, - { url = "https://files.pythonhosted.org/packages/d1/de/cb2dca1f39ada99f311e54f4ede0235ec47398b712482528fc64737df407/lupa-2.7-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:02e24aaf1bc55242fdd4dd6c6b556f927f1c2a7a669deb902d4d1e73d2c9d1d6", size = 1155434, upload-time = "2026-04-07T08:54:31.929Z" }, - { url = "https://files.pythonhosted.org/packages/37/2a/8cfc3ae8ec1d474beaa07839b3e200ef0710f0439959cc91a97ef4e432f9/lupa-2.7-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:f810b920787dfb46b3bf3f54d370f2e3c78ea37db214954fc025d2d1e760eff7", size = 1489117, upload-time = "2026-04-07T08:54:33.901Z" }, - { url = "https://files.pythonhosted.org/packages/0e/28/4e4cbc45a36000a37a1109ce4c375a8621e9fa195e86ec1e228138e88a39/lupa-2.7-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:1129d12951c221941c471251ea478db6faa34175ffafadd3dca1f75c9d83e84a", size = 1466206, upload-time = "2026-04-07T08:54:35.865Z" }, - { url = "https://files.pythonhosted.org/packages/42/89/6ed7cb2441213569d5732b9d2b955c4fb0484c94dbb875f3af502e083650/lupa-2.7-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:6dcf4cba4bb5936859a675bb22f0d9291914f3e2840366653eb829b7d8b3035d", size = 1288464, upload-time = "2026-04-07T08:54:37.763Z" }, - { url = "https://files.pythonhosted.org/packages/14/77/5df2e2296eac345c6d391632b50138615cabc4834742acf82d318fe2f89b/lupa-2.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8f899ceacf2d38bdd4c54aa777cec4deafcc7a6b02a0aa65dbca96e57e11d260", size = 2444753, upload-time = "2026-04-07T08:54:39.785Z" }, +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] [[package]] name = "mako" -version = "1.3.11" +version = "1.3.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/8a/805404d0c0b9f3d7a326475ca008db57aea9c5c9f2e1e39ed0faa335571c/mako-1.3.11.tar.gz", hash = "sha256:071eb4ab4c5010443152255d77db7faa6ce5916f35226eb02dc34479b6858069", size = 399811, upload-time = "2026-04-14T20:19:51.493Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/a5/19d7aaa7e433713ffe881df33705925a196afb9532efc8475d26593921a6/mako-1.3.11-py3-none-any.whl", hash = "sha256:e372c6e333cf004aa736a15f425087ec977e1fcbd2966aae7f17c8dc1da27a77", size = 78503, upload-time = "2026-04-14T20:19:53.233Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, ] [[package]] @@ -1518,14 +1523,14 @@ wheels = [ [[package]] name = "markdown-it-py" -version = "4.0.0" +version = "4.2.0" 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/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } 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/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] [[package]] @@ -1718,31 +1723,32 @@ wheels = [ [[package]] name = "mypy" -version = "1.20.1" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "ast-serialize" }, { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/3d/5b373635b3146264eb7a68d09e5ca11c305bbb058dfffbb47c47daf4f632/mypy-1.20.1.tar.gz", hash = "sha256:6fc3f4ecd52de81648fed1945498bf42fa2993ddfad67c9056df36ae5757f804", size = 3815892, upload-time = "2026-04-13T02:46:51.474Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/cd/db831e84c81d57d4886d99feee14e372f64bbec6a9cb1a88a19e243f2ef5/mypy-1.20.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:12927b9c0ed794daedcf1dab055b6c613d9d5659ac511e8d936d96f19c087d12", size = 14483064, upload-time = "2026-04-13T02:45:26.901Z" }, - { url = "https://files.pythonhosted.org/packages/d5/82/74e62e7097fa67da328ac8ece8de09133448c04d20ddeaeba251a3000f01/mypy-1.20.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:752507dd481e958b2c08fc966d3806c962af5a9433b5bf8f3bdd7175c20e34fe", size = 13335694, upload-time = "2026-04-13T02:46:12.514Z" }, - { url = "https://files.pythonhosted.org/packages/74/c4/97e9a0abe4f3cdbbf4d079cb87a03b786efeccf5bf2b89fe4f96939ab2e6/mypy-1.20.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c614655b5a065e56274c6cbbe405f7cf7e96c0654db7ba39bc680238837f7b08", size = 13726365, upload-time = "2026-04-13T02:45:17.422Z" }, - { url = "https://files.pythonhosted.org/packages/d7/aa/a19d884a8d28fcd3c065776323029f204dbc774e70ec9c85eba228b680de/mypy-1.20.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c3f6221a76f34d5100c6d35b3ef6b947054123c3f8d6938a4ba00b1308aa572", size = 14693472, upload-time = "2026-04-13T02:46:41.253Z" }, - { url = "https://files.pythonhosted.org/packages/84/44/cc9324bd21cf786592b44bf3b5d224b3923c1230ec9898d508d00241d465/mypy-1.20.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4bdfc06303ac06500af71ea0cdbe995c502b3c9ba32f3f8313523c137a25d1b6", size = 14919266, upload-time = "2026-04-13T02:46:28.37Z" }, - { url = "https://files.pythonhosted.org/packages/6e/dc/779abb25a8c63e8f44bf5a336217fa92790fa17e0c40e0c725d10cb01bbd/mypy-1.20.1-cp314-cp314-win_amd64.whl", hash = "sha256:0131edd7eba289973d1ba1003d1a37c426b85cdef76650cd02da6420898a5eb3", size = 11049713, upload-time = "2026-04-13T02:45:57.673Z" }, - { url = "https://files.pythonhosted.org/packages/28/08/4172be2ad7de9119b5a92ca36abbf641afdc5cb1ef4ae0c3a8182f29674f/mypy-1.20.1-cp314-cp314-win_arm64.whl", hash = "sha256:33f02904feb2c07e1fdf7909026206396c9deeb9e6f34d466b4cfedb0aadbbe4", size = 9999819, upload-time = "2026-04-13T02:46:35.039Z" }, - { url = "https://files.pythonhosted.org/packages/2d/af/af9e46b0c8eabbce9fc04a477564170f47a1c22b308822282a59b7ff315f/mypy-1.20.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:168472149dd8cc505c98cefd21ad77e4257ed6022cd5ed2fe2999bed56977a5a", size = 15547508, upload-time = "2026-04-13T02:46:25.588Z" }, - { url = "https://files.pythonhosted.org/packages/a7/cd/39c9e4ad6ba33e069e5837d772a9e6c304b4a5452a14a975d52b36444650/mypy-1.20.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:eb674600309a8f22790cca883a97c90299f948183ebb210fbef6bcee07cb1986", size = 14399557, upload-time = "2026-04-13T02:46:10.021Z" }, - { url = "https://files.pythonhosted.org/packages/83/c1/3fd71bdc118ffc502bf57559c909927bb7e011f327f7bb8e0488e98a5870/mypy-1.20.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef2b2e4cc464ba9795459f2586923abd58a0055487cbe558cb538ea6e6bc142a", size = 15045789, upload-time = "2026-04-13T02:45:10.81Z" }, - { url = "https://files.pythonhosted.org/packages/8e/73/6f07ff8b57a7d7b3e6e5bf34685d17632382395c8bb53364ec331661f83e/mypy-1.20.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dee461d396dd46b3f0ed5a098dbc9b8860c81c46ad44fa071afcfbc149f167c9", size = 15850795, upload-time = "2026-04-13T02:45:03.349Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e2/f7dffec1c7767078f9e9adf0c786d1fe0ff30964a77eb213c09b8b58cb76/mypy-1.20.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e364926308b3e66f1361f81a566fc1b2f8cd47fc8525e8136d4058a65a4b4f02", size = 16088539, upload-time = "2026-04-13T02:46:17.841Z" }, - { url = "https://files.pythonhosted.org/packages/1a/76/e0dee71035316e75a69d73aec2f03c39c21c967b97e277fd0ef8fd6aec66/mypy-1.20.1-cp314-cp314t-win_amd64.whl", hash = "sha256:a0c17fbd746d38c70cbc42647cfd884f845a9708a4b160a8b4f7e70d41f4d7fa", size = 12575567, upload-time = "2026-04-13T02:45:34.795Z" }, - { url = "https://files.pythonhosted.org/packages/22/a8/7ed43c9d9c3d1468f86605e323a5d97e411a448790a00f07e779f3211a46/mypy-1.20.1-cp314-cp314t-win_arm64.whl", hash = "sha256:db2cb89654626a912efda69c0d5c1d22d948265e2069010d3dde3abf751c7d08", size = 10378823, upload-time = "2026-04-13T02:45:13.35Z" }, - { url = "https://files.pythonhosted.org/packages/d8/28/926bd972388e65a39ee98e188ccf67e81beb3aacfd5d6b310051772d974b/mypy-1.20.1-py3-none-any.whl", hash = "sha256:1aae28507f253fe82d883790d1c0a0d35798a810117c88184097fe8881052f06", size = 2636553, upload-time = "2026-04-13T02:46:30.45Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, ] [[package]] @@ -1756,31 +1762,31 @@ wheels = [ [[package]] name = "numpy" -version = "2.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, - { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, - { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, - { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, - { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, - { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, - { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, - { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, - { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, - { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, - { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, - { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, - { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, - { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, - { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, - { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, - { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, - { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, - { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, ] [[package]] @@ -1794,47 +1800,46 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.41.0" +version = "1.42.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/8e/3778a7e87801d994869a9396b9fc2a289e5f9be91ff54a27d41eace494b0/opentelemetry_api-1.41.0.tar.gz", hash = "sha256:9421d911326ec12dee8bc933f7839090cad7a3f13fcfb0f9e82f8174dc003c09", size = 71416, upload-time = "2026-04-09T14:38:34.544Z" } +sdist = { url = "https://files.pythonhosted.org/packages/86/ca/25288069c399be6769159d9fb7b1190b603537d82aad2fa2746a0cc2c8c6/opentelemetry_api-1.42.0.tar.gz", hash = "sha256:ea84c893ad177791d138e0349d6ceebd8d3bf006440900400ce220008dafc372", size = 72300, upload-time = "2026-05-19T09:46:29.885Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/ee/99ab786653b3bda9c37ade7e24a7b607a1b1f696063172768417539d876d/opentelemetry_api-1.41.0-py3-none-any.whl", hash = "sha256:0e77c806e6a89c9e4f8d372034622f3e1418a11bdbe1c80a50b3d3397ad0fa4f", size = 69007, upload-time = "2026-04-09T14:38:11.833Z" }, + { url = "https://files.pythonhosted.org/packages/1b/0b/be5daf659b82b525338fde371dfcfab09b606a19bb5620c37076964710ec/opentelemetry_api-1.42.0-py3-none-any.whl", hash = "sha256:558d88f88192a973579910ef6f2c13db47a268d5ec2e53e83e50e74a39a02922", size = 61310, upload-time = "2026-05-19T09:46:06.561Z" }, ] [[package]] name = "orjson" -version = "3.11.8" +version = "3.11.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/1b/2024d06792d0779f9dbc51531b61c24f76c75b9f4ce05e6f3377a1814cea/orjson-3.11.8.tar.gz", hash = "sha256:96163d9cdc5a202703e9ad1b9ae757d5f0ca62f4fa0cc93d1f27b0e180cc404e", size = 5603832, upload-time = "2026-03-31T16:16:27.878Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/35/b01910c3d6b85dc882442afe5060cbf719c7d1fc85749294beda23d17873/orjson-3.11.8-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ec795530a73c269a55130498842aaa762e4a939f6ce481a7e986eeaa790e9da4", size = 229171, upload-time = "2026-03-31T16:16:00.651Z" }, - { url = "https://files.pythonhosted.org/packages/c2/56/c9ec97bd11240abef39b9e5d99a15462809c45f677420fd148a6c5e6295e/orjson-3.11.8-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c492a0e011c0f9066e9ceaa896fbc5b068c54d365fea5f3444b697ee01bc8625", size = 128746, upload-time = "2026-03-31T16:16:02.673Z" }, - { url = "https://files.pythonhosted.org/packages/3b/e4/66d4f30a90de45e2f0cbd9623588e8ae71eef7679dbe2ae954ed6d66a41f/orjson-3.11.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:883206d55b1bd5f5679ad5e6ddd3d1a5e3cac5190482927fdb8c78fb699193b5", size = 131867, upload-time = "2026-03-31T16:16:04.342Z" }, - { url = "https://files.pythonhosted.org/packages/19/30/2a645fc9286b928675e43fa2a3a16fb7b6764aa78cc719dc82141e00f30b/orjson-3.11.8-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5774c1fdcc98b2259800b683b19599c133baeb11d60033e2095fd9d4667b82db", size = 124664, upload-time = "2026-03-31T16:16:05.837Z" }, - { url = "https://files.pythonhosted.org/packages/db/44/77b9a86d84a28d52ba3316d77737f6514e17118119ade3f91b639e859029/orjson-3.11.8-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7381c83dd3d4a6347e6635950aa448f54e7b8406a27c7ecb4a37e9f1ae08b", size = 129701, upload-time = "2026-03-31T16:16:07.407Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ea/eff3d9bfe47e9bc6969c9181c58d9f71237f923f9c86a2d2f490cd898c82/orjson-3.11.8-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14439063aebcb92401c11afc68ee4e407258d2752e62d748b6942dad20d2a70d", size = 141202, upload-time = "2026-03-31T16:16:09.48Z" }, - { url = "https://files.pythonhosted.org/packages/52/c8/90d4b4c60c84d62068d0cf9e4d8f0a4e05e76971d133ac0c60d818d4db20/orjson-3.11.8-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa72e71977bff96567b0f500fc5bfd2fdf915f34052c782a4c6ebbdaa97aa858", size = 127194, upload-time = "2026-03-31T16:16:11.02Z" }, - { url = "https://files.pythonhosted.org/packages/8d/c7/ea9e08d1f0ba981adffb629811148b44774d935171e7b3d780ae43c4c254/orjson-3.11.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7679bc2f01bb0d219758f1a5f87bb7c8a81c0a186824a393b366876b4948e14f", size = 133639, upload-time = "2026-03-31T16:16:13.434Z" }, - { url = "https://files.pythonhosted.org/packages/6c/8c/ddbbfd6ba59453c8fc7fe1d0e5983895864e264c37481b2a791db635f046/orjson-3.11.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14f7b8fcb35ef403b42fa5ecfa4ed032332a91f3dc7368fbce4184d59e1eae0d", size = 141914, upload-time = "2026-03-31T16:16:14.955Z" }, - { url = "https://files.pythonhosted.org/packages/4e/31/dbfbefec9df060d34ef4962cd0afcb6fa7a9ec65884cb78f04a7859526c3/orjson-3.11.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c2bdf7b2facc80b5e34f48a2d557727d5c5c57a8a450de122ae81fa26a81c1bc", size = 423800, upload-time = "2026-03-31T16:16:16.594Z" }, - { url = "https://files.pythonhosted.org/packages/87/cf/f74e9ae9803d4ab46b163494adba636c6d7ea955af5cc23b8aaa94cfd528/orjson-3.11.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ccd7ba1b0605813a0715171d39ec4c314cb97a9c85893c2c5c0c3a3729df38bf", size = 147837, upload-time = "2026-03-31T16:16:18.585Z" }, - { url = "https://files.pythonhosted.org/packages/64/e6/9214f017b5db85e84e68602792f742e5dc5249e963503d1b356bee611e01/orjson-3.11.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbc8c9c02463fef4d3c53a9ba3336d05496ec8e1f1c53326a1e4acc11f5c600", size = 136441, upload-time = "2026-03-31T16:16:20.151Z" }, - { url = "https://files.pythonhosted.org/packages/24/dd/3590348818f58f837a75fb969b04cdf187ae197e14d60b5e5a794a38b79d/orjson-3.11.8-cp314-cp314-win32.whl", hash = "sha256:0b57f67710a8cd459e4e54eb96d5f77f3624eba0c661ba19a525807e42eccade", size = 131983, upload-time = "2026-03-31T16:16:21.823Z" }, - { url = "https://files.pythonhosted.org/packages/3f/0f/b6cb692116e05d058f31ceee819c70f097fa9167c82f67fabe7516289abc/orjson-3.11.8-cp314-cp314-win_amd64.whl", hash = "sha256:735e2262363dcbe05c35e3a8869898022af78f89dde9e256924dc02e99fe69ca", size = 127396, upload-time = "2026-03-31T16:16:23.685Z" }, - { url = "https://files.pythonhosted.org/packages/c0/d1/facb5b5051fabb0ef9d26c6544d87ef19a939a9a001198655d0d891062dd/orjson-3.11.8-cp314-cp314-win_arm64.whl", hash = "sha256:6ccdea2c213cf9f3d9490cbd5d427693c870753df41e6cb375bd79bcbafc8817", size = 127330, upload-time = "2026-03-31T16:16:25.496Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, ] [[package]] name = "packaging" -version = "26.0" +version = "26.2" 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/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } 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/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] @@ -1857,40 +1862,40 @@ wheels = [ [[package]] name = "pandas" -version = "3.0.2" +version = "3.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, { name = "python-dateutil" }, { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855, upload-time = "2026-03-31T06:48:30.816Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/40/c6ea527147c73b24fc15c891c3fcffe9c019793119c5742b8784a062c7db/pandas-3.0.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db0dbfd2a6cdf3770aa60464d50333d8f3d9165b2f2671bcc299b72de5a6677b", size = 10326084, upload-time = "2026-03-31T06:47:43.834Z" }, - { url = "https://files.pythonhosted.org/packages/95/25/bdb9326c3b5455f8d4d3549fce7abcf967259de146fe2cf7a82368141948/pandas-3.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0555c5882688a39317179ab4a0ed41d3ebc8812ab14c69364bbee8fb7a3f6288", size = 9914146, upload-time = "2026-03-31T06:47:46.67Z" }, - { url = "https://files.pythonhosted.org/packages/8d/77/3a227ff3337aa376c60d288e1d61c5d097131d0ac71f954d90a8f369e422/pandas-3.0.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01f31a546acd5574ef77fe199bc90b55527c225c20ccda6601cf6b0fd5ed597c", size = 10444081, upload-time = "2026-03-31T06:47:49.681Z" }, - { url = "https://files.pythonhosted.org/packages/15/88/3cdd54fa279341afa10acf8d2b503556b1375245dccc9315659f795dd2e9/pandas-3.0.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deeca1b5a931fdf0c2212c8a659ade6d3b1edc21f0914ce71ef24456ca7a6535", size = 10897535, upload-time = "2026-03-31T06:47:53.033Z" }, - { url = "https://files.pythonhosted.org/packages/06/9d/98cc7a7624f7932e40f434299260e2917b090a579d75937cb8a57b9d2de3/pandas-3.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f48afd9bb13300ffb5a3316973324c787054ba6665cda0da3fbd67f451995db", size = 11446992, upload-time = "2026-03-31T06:47:56.193Z" }, - { url = "https://files.pythonhosted.org/packages/9a/cd/19ff605cc3760e80602e6826ddef2824d8e7050ed80f2e11c4b079741dc3/pandas-3.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c4d8458b97a35717b62469a4ea0e85abd5ed8687277f5ccfc67f8a5126f8c53", size = 11968257, upload-time = "2026-03-31T06:47:59.137Z" }, - { url = "https://files.pythonhosted.org/packages/db/60/aba6a38de456e7341285102bede27514795c1eaa353bc0e7638b6b785356/pandas-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:b35d14bb5d8285d9494fe93815a9e9307c0876e10f1e8e89ac5b88f728ec8dcf", size = 9865893, upload-time = "2026-03-31T06:48:02.038Z" }, - { url = "https://files.pythonhosted.org/packages/08/71/e5ec979dd2e8a093dacb8864598c0ff59a0cee0bbcdc0bfec16a51684d4f/pandas-3.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:63d141b56ef686f7f0d714cfb8de4e320475b86bf4b620aa0b7da89af8cbdbbb", size = 9188644, upload-time = "2026-03-31T06:48:05.045Z" }, - { url = "https://files.pythonhosted.org/packages/f1/6c/7b45d85db19cae1eb524f2418ceaa9d85965dcf7b764ed151386b7c540f0/pandas-3.0.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:140f0cffb1fa2524e874dde5b477d9defe10780d8e9e220d259b2c0874c89d9d", size = 10776246, upload-time = "2026-03-31T06:48:07.789Z" }, - { url = "https://files.pythonhosted.org/packages/a8/3e/7b00648b086c106e81766f25322b48aa8dfa95b55e621dbdf2fdd413a117/pandas-3.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae37e833ff4fed0ba352f6bdd8b73ba3ab3256a85e54edfd1ab51ae40cca0af8", size = 10424801, upload-time = "2026-03-31T06:48:10.897Z" }, - { url = "https://files.pythonhosted.org/packages/da/6e/558dd09a71b53b4008e7fc8a98ec6d447e9bfb63cdaeea10e5eb9b2dabe8/pandas-3.0.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d888a5c678a419a5bb41a2a93818e8ed9fd3172246555c0b37b7cc27027effd", size = 10345643, upload-time = "2026-03-31T06:48:13.7Z" }, - { url = "https://files.pythonhosted.org/packages/be/e3/921c93b4d9a280409451dc8d07b062b503bbec0531d2627e73a756e99a82/pandas-3.0.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b444dc64c079e84df91baa8bf613d58405645461cabca929d9178f2cd392398d", size = 10743641, upload-time = "2026-03-31T06:48:16.659Z" }, - { url = "https://files.pythonhosted.org/packages/56/ca/fd17286f24fa3b4d067965d8d5d7e14fe557dd4f979a0b068ac0deaf8228/pandas-3.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4544c7a54920de8eeacaa1466a6b7268ecfbc9bc64ab4dbb89c6bbe94d5e0660", size = 11361993, upload-time = "2026-03-31T06:48:19.475Z" }, - { url = "https://files.pythonhosted.org/packages/e4/a5/2f6ed612056819de445a433ca1f2821ac3dab7f150d569a59e9cc105de1d/pandas-3.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:734be7551687c00fbd760dc0522ed974f82ad230d4a10f54bf51b80d44a08702", size = 11815274, upload-time = "2026-03-31T06:48:22.695Z" }, - { url = "https://files.pythonhosted.org/packages/00/2f/b622683e99ec3ce00b0854bac9e80868592c5b051733f2cf3a868e5fea26/pandas-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:57a07209bebcbcf768d2d13c9b78b852f9a15978dac41b9e6421a81ad4cdd276", size = 10888530, upload-time = "2026-03-31T06:48:25.806Z" }, - { url = "https://files.pythonhosted.org/packages/cb/2b/f8434233fab2bd66a02ec014febe4e5adced20e2693e0e90a07d118ed30e/pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482", size = 9455341, upload-time = "2026-03-31T06:48:28.418Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, ] [[package]] name = "pathspec" -version = "1.0.4" +version = "1.1.1" 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/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } 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/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] @@ -1946,7 +1951,7 @@ wheels = [ [[package]] name = "prefect" -version = "3.6.26" +version = "3.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiosqlite" }, @@ -1965,7 +1970,6 @@ dependencies = [ { name = "dateparser" }, { name = "docker" }, { name = "exceptiongroup" }, - { name = "fakeredis" }, { name = "fastapi" }, { name = "fsspec" }, { name = "graphviz" }, @@ -2006,9 +2010,9 @@ dependencies = [ { name = "websockets" }, { name = "whenever" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/82/9911260b174f0e42d6abc825052d02d199e9b28c3ec91309191347f46811/prefect-3.6.26.tar.gz", hash = "sha256:192c7254823c4b65deae782de1082bf071da07d8f97326bf3ba1559eeafaf062", size = 11071348, upload-time = "2026-04-10T16:56:30.506Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/12/dde80bca1378f19ef5b45bbab75d30367796ff9358eafc21ff3c6aa43b55/prefect-3.7.1.tar.gz", hash = "sha256:7e1c59491f7d31f700539ed9084dade3b7e3bdb6c08b5a417c6a0a7502ba9c9d", size = 14056819, upload-time = "2026-05-16T02:43:21.545Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/1e/099dac0fd5ea0611f6375ee5b43f9a264bd18a48465938fd7f9f5a60ae44/prefect-3.6.26-py3-none-any.whl", hash = "sha256:df599bd04f136d7fe4634aaf7f4110e34386717935630a7ff086dfde21334dfa", size = 11978672, upload-time = "2026-04-10T16:56:28.036Z" }, + { url = "https://files.pythonhosted.org/packages/4b/88/bc1d779c8680e75fe77746511004a0963984b51aec65f5fb0bc64392c371/prefect-3.7.1-py3-none-any.whl", hash = "sha256:5113ecc7b6274eeea97bac29bdce9b45b3dd6bb5048202b8e5c4e2eacac5f968", size = 14933114, upload-time = "2026-05-16T02:43:17.972Z" }, ] [[package]] @@ -2034,36 +2038,36 @@ wheels = [ [[package]] name = "protobuf" -version = "7.34.1" +version = "7.35.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/fd/5b1491d9e4b586d621c54f4c36b888714164b6875f8d6afa3f9072906a51/protobuf-7.35.0.tar.gz", hash = "sha256:a2efd84605f41e559f1881b0912b44099d0a2ac9bf46b3474823f10fb393b0e6", size = 458677, upload-time = "2026-05-19T23:02:29.197Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" }, - { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" }, - { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" }, - { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, - { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, - { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, - { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, + { url = "https://files.pythonhosted.org/packages/83/ee/93d06e358a4aa32280b00e722d3ea0a1f25fc3cc5778d80581c9cca2c10e/protobuf-7.35.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:66be6c513931c794fa92c080ffee41671390da3d79da219cf9c0c0907f035dda", size = 433225, upload-time = "2026-05-19T23:02:19.884Z" }, + { url = "https://files.pythonhosted.org/packages/8b/39/1c76c2da93f3c507e958e0aecee2391cc44d4625de6c728bbc555195b5a8/protobuf-7.35.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:fcbe42a4ac09d3ec9c987ddfcd956afd0b15f1ff613bd8371bde9405ffd5c8e5", size = 328847, upload-time = "2026-05-19T23:02:22.3Z" }, + { url = "https://files.pythonhosted.org/packages/91/1a/39f7ce90a238c1a987a4d81ec26379e02ca0aff367de68e4a1fa474215b9/protobuf-7.35.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:4cbf5cc286130e06a6c9bbefac442431173906dfcc979712183d4adcc01b37ee", size = 344030, upload-time = "2026-05-19T23:02:23.591Z" }, + { url = "https://files.pythonhosted.org/packages/70/5b/6baf9008817964454055ff3fe65f1de0b5f1e26c80c82f7fb108b7cd4ea3/protobuf-7.35.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:6c0f98f10c8a05ea30f8993dfef2de093d27b490fdae78bb60c8343795d55011", size = 327130, upload-time = "2026-05-19T23:02:24.637Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e5/e46adb0badc388bfb84877a5f9f026aff63f60e611016cf64dbe77e05446/protobuf-7.35.0-cp310-abi3-win32.whl", hash = "sha256:4c4617b83ade0e279d1d2bfe04025a1adb87f9ed657de038620dc0ff959357f6", size = 428946, upload-time = "2026-05-19T23:02:25.741Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ab/547fbd9e16d879dd13c167478f8ae0a83a428008ca07a5e06acdc23ad473/protobuf-7.35.0-cp310-abi3-win_amd64.whl", hash = "sha256:f05bcadf9a2a6b8dda047007075135fb7d08c73d9177aabc067e1be46881a201", size = 439996, upload-time = "2026-05-19T23:02:26.808Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ef/50433d346c56657a70d27f156c7b349ac59a068b01de4eb796e747eecc43/protobuf-7.35.0-py3-none-any.whl", hash = "sha256:c13f325cf242bad135c350629eeb5d54b24228eb472fb3e2e9ebbd4c5dc20ca0", size = 171659, upload-time = "2026-05-19T23:02:27.842Z" }, ] [[package]] name = "psycopg2-binary" -version = "2.9.11" +version = "2.9.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" }, - { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" }, - { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" }, - { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" }, - { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" }, - { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" }, - { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" }, - { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" }, - { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" }, - { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" }, - { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" }, + { url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428, upload-time = "2026-04-20T23:35:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184, upload-time = "2026-04-20T23:35:25.92Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157, upload-time = "2026-04-20T23:35:28.542Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", size = 4274970, upload-time = "2026-04-20T23:35:30.418Z" }, + { url = "https://files.pythonhosted.org/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", size = 5895175, upload-time = "2026-04-20T23:35:33.584Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e", size = 4110658, upload-time = "2026-04-20T23:35:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", size = 3656251, upload-time = "2026-04-20T23:35:37.854Z" }, + { url = "https://files.pythonhosted.org/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", size = 3301810, upload-time = "2026-04-20T23:35:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", size = 3048977, upload-time = "2026-04-20T23:35:41.806Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", size = 3351466, upload-time = "2026-04-20T23:35:43.993Z" }, + { url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" }, ] [[package]] @@ -2125,7 +2129,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.13.0" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -2133,50 +2137,50 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/84/6b/69fd5c7194b21ebde0f8637e2a4ddc766ada29d472bfa6a5ca533d79549a/pydantic-2.13.0.tar.gz", hash = "sha256:b89b575b6e670ebf6e7448c01b41b244f471edd276cd0b6fe02e7e7aca320070", size = 843468, upload-time = "2026-04-13T10:51:35.571Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/d7/c3a52c61f5b7be648e919005820fbac33028c6149994cd64453f49951c17/pydantic-2.13.0-py3-none-any.whl", hash = "sha256:ab0078b90da5f3e2fd2e71e3d9b457ddcb35d0350854fbda93b451e28d56baaf", size = 471872, upload-time = "2026-04-13T10:51:33.343Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [[package]] name = "pydantic-core" -version = "2.46.0" +version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6f/0a/9414cddf82eda3976b14048cc0fa8f5b5d1aecb0b22e1dcd2dbfe0e139b1/pydantic_core-2.46.0.tar.gz", hash = "sha256:82d2498c96be47b47e903e1378d1d0f770097ec56ea953322f39936a7cf34977", size = 471441, upload-time = "2026-04-13T09:06:33.813Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/3b/914891d384cdbf9a6f464eb13713baa22ea1e453d4da80fb7da522079370/pydantic_core-2.46.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4fc801c290342350ffc82d77872054a934b2e24163727263362170c1db5416ca", size = 2113349, upload-time = "2026-04-13T09:04:59.407Z" }, - { url = "https://files.pythonhosted.org/packages/35/95/3a0c6f65e231709fb3463e32943c69d10285cb50203a2130a4732053a06d/pydantic_core-2.46.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0a36f2cc88170cc177930afcc633a8c15907ea68b59ac16bd180c2999d714940", size = 1949170, upload-time = "2026-04-13T09:06:09.935Z" }, - { url = "https://files.pythonhosted.org/packages/d1/63/d845c36a608469fe7bee226edeff0984c33dbfe7aecd755b0e7ab5a275c4/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a3912e0c568a1f99d4d6d3e41def40179d61424c0ca1c8c87c4877d7f6fd7fb", size = 1977914, upload-time = "2026-04-13T09:04:56.16Z" }, - { url = "https://files.pythonhosted.org/packages/08/6f/f2e7a7f85931fb31671f5378d1c7fc70606e4b36d59b1b48e1bd1ef5d916/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3534c3415ed1a19ab23096b628916a827f7858ec8db49ad5d7d1e44dc13c0d7b", size = 2050538, upload-time = "2026-04-13T09:05:06.789Z" }, - { url = "https://files.pythonhosted.org/packages/8c/97/f4aa7181dd9a16dd9059a99fc48fdab0c2aab68307283a5c04cf56de68c4/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21067396fc285609323a4db2f63a87570044abe0acddfcca8b135fc7948e3db7", size = 2236294, upload-time = "2026-04-13T09:07:03.2Z" }, - { url = "https://files.pythonhosted.org/packages/24/c1/6a5042fc32765c87101b500f394702890af04239c318b6002cfd627b710d/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2afd85b7be186e2fe7cdbb09a3d964bcc2042f65bbcc64ad800b3c7915032655", size = 2312954, upload-time = "2026-04-13T09:06:11.919Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e4/566101a561492ce8454f0844ca29c3b675a6b3a7b3ff577db85ed05c8c50/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67e2c2e171b78db8154da602de72ffdc473c6ee51de8a9d80c0f1cd4051abfc7", size = 2102533, upload-time = "2026-04-13T09:06:58.664Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ac/adc11ee1646a5c4dd9abb09a00e7909e6dc25beddc0b1310ca734bb9b48e/pydantic_core-2.46.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c16ae1f3170267b1a37e16dba5c297bdf60c8b5657b147909ca8774ce7366644", size = 2169447, upload-time = "2026-04-13T09:04:11.143Z" }, - { url = "https://files.pythonhosted.org/packages/26/73/408e686b45b82d28ac19e8229e07282254dbee6a5d24c5c7cf3cf3716613/pydantic_core-2.46.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:133b69e1c1ba34d3702eed73f19f7f966928f9aa16663b55c2ebce0893cca42e", size = 2200672, upload-time = "2026-04-13T09:03:54.056Z" }, - { url = "https://files.pythonhosted.org/packages/0a/3b/807d5b035ec891b57b9079ce881f48263936c37bd0d154a056e7fd152afb/pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:15ed8e5bde505133d96b41702f31f06829c46b05488211a5b1c7877e11de5eb5", size = 2188293, upload-time = "2026-04-13T09:07:07.614Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ed/719b307516285099d1196c52769fdbe676fd677da007b9c349ae70b7226d/pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:8cfc29a1c66a7f0fcb36262e92f353dd0b9c4061d558fceb022e698a801cb8ae", size = 2335023, upload-time = "2026-04-13T09:04:05.176Z" }, - { url = "https://files.pythonhosted.org/packages/8d/90/8718e4ae98c4e8a7325afdc079be82be1e131d7a47cb6c098844a9531ffe/pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e1155708540f13845bf68d5ac511a55c76cfe2e057ed12b4bf3adac1581fc5c2", size = 2377155, upload-time = "2026-04-13T09:06:18.081Z" }, - { url = "https://files.pythonhosted.org/packages/dd/dc/7172789283b963f81da2fc92b186e22de55687019079f71c4d570822502b/pydantic_core-2.46.0-cp314-cp314-win32.whl", hash = "sha256:de5635a48df6b2eef161d10ea1bc2626153197333662ba4cd700ee7ec1aba7f5", size = 1963078, upload-time = "2026-04-13T09:05:30.615Z" }, - { url = "https://files.pythonhosted.org/packages/e0/69/03a7ea4b6264def3a44eabf577528bcec2f49468c5698b2044dea54dc07e/pydantic_core-2.46.0-cp314-cp314-win_amd64.whl", hash = "sha256:f07a5af60c5e7cf53dd1ff734228bd72d0dc9938e64a75b5bb308ca350d9681e", size = 2068439, upload-time = "2026-04-13T09:04:57.729Z" }, - { url = "https://files.pythonhosted.org/packages/f5/eb/1c3afcfdee2ab6634b802ab0a0f1966df4c8b630028ec56a1cb0a710dc58/pydantic_core-2.46.0-cp314-cp314-win_arm64.whl", hash = "sha256:e7a77eca3c7d5108ff509db20aae6f80d47c7ed7516d8b96c387aacc42f3ce0f", size = 2026470, upload-time = "2026-04-13T09:05:08.654Z" }, - { url = "https://files.pythonhosted.org/packages/5c/30/1177dde61b200785c4739665e3aa03a9d4b2c25d2d0408b07d585e633965/pydantic_core-2.46.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5e7cdd4398bee1aaeafe049ac366b0f887451d9ae418fd8785219c13fea2f928", size = 2107447, upload-time = "2026-04-13T09:05:46.314Z" }, - { url = "https://files.pythonhosted.org/packages/b1/60/4e0f61f99bdabbbc309d364a2791e1ba31e778a4935bc43391a7bdec0744/pydantic_core-2.46.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5c2c92d82808e27cef3f7ab3ed63d657d0c755e0dbe5b8a58342e37bdf09bd2e", size = 1926927, upload-time = "2026-04-13T09:06:20.371Z" }, - { url = "https://files.pythonhosted.org/packages/1d/d0/67f89a8269152c1d6eaa81f04e75a507372ebd8ca7382855a065222caa80/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bab80af91cd7014b45d1089303b5f844a9d91d7da60eabf3d5f9694b32a6655", size = 1966613, upload-time = "2026-04-13T09:07:05.389Z" }, - { url = "https://files.pythonhosted.org/packages/cd/07/8dfdc3edc78f29a80fb31f366c50203ec904cff6a4c923599bf50ac0d0ff/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1e49ffdb714bc990f00b39d1ad1d683033875b5af15582f60c1f34ad3eeccfaa", size = 2032902, upload-time = "2026-04-13T09:06:42.47Z" }, - { url = "https://files.pythonhosted.org/packages/b0/2a/111c5e8fe24f99c46bcad7d3a82a8f6dbc738066e2c72c04c71f827d8c78/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca877240e8dbdeef3a66f751dc41e5a74893767d510c22a22fc5c0199844f0ce", size = 2244456, upload-time = "2026-04-13T09:05:36.484Z" }, - { url = "https://files.pythonhosted.org/packages/6b/7c/cfc5d11c15a63ece26e148572c77cfbb2c7f08d315a7b63ef0fe0711d753/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87e6843f89ecd2f596d7294e33196c61343186255b9880c4f1b725fde8b0e20d", size = 2294535, upload-time = "2026-04-13T09:06:01.689Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2c/f0d744e3dab7bd026a3f4670a97a295157cff923a2666d30a15a70a7e3d0/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e20bc5add1dd9bc3b9a3600d40632e679376569098345500799a6ad7c5d46c72", size = 2104621, upload-time = "2026-04-13T09:04:34.388Z" }, - { url = "https://files.pythonhosted.org/packages/a7/64/e7cc4698dc024264d214b51d5a47a2404221b12060dd537d76f831b2120a/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:ee6ff79a5f0289d64a9d6696a3ce1f98f925b803dd538335a118231e26d6d827", size = 2130718, upload-time = "2026-04-13T09:04:26.23Z" }, - { url = "https://files.pythonhosted.org/packages/0b/a8/224e655fec21f7d4441438ad2ecaccb33b5a3876ce7bb2098c74a49efc14/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:52d35cfb58c26323101c7065508d7bb69bb56338cda9ea47a7b32be581af055d", size = 2180738, upload-time = "2026-04-13T09:05:50.253Z" }, - { url = "https://files.pythonhosted.org/packages/32/7b/b3025618ed4c4e4cbaa9882731c19625db6669896b621760ea95bc1125ef/pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d14cc5a6f260fa78e124061eebc5769af6534fc837e9a62a47f09a2c341fa4ea", size = 2171222, upload-time = "2026-04-13T09:07:29.929Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e3/68170aa1d891920af09c1f2f34df61dc5ff3a746400027155523e3400e89/pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:4f7ff859d663b6635f6307a10803d07f0d09487e16c3d36b1744af51dbf948b2", size = 2320040, upload-time = "2026-04-13T09:06:35.732Z" }, - { url = "https://files.pythonhosted.org/packages/67/1b/5e65807001b84972476300c1f49aea2b4971b7e9fffb5c2654877dadd274/pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:8ef749be6ed0d69dba31902aaa8255a9bb269ae50c93888c4df242d8bb7acd9e", size = 2377062, upload-time = "2026-04-13T09:07:39.945Z" }, - { url = "https://files.pythonhosted.org/packages/75/03/48caa9dd5f28f7662bd52bff454d9a451f6b7e5e4af95e289e5e170749c9/pydantic_core-2.46.0-cp314-cp314t-win32.whl", hash = "sha256:d93ca72870133f86360e4bb0c78cd4e6ba2a0f9f3738a6486909ffc031463b32", size = 1951028, upload-time = "2026-04-13T09:04:20.224Z" }, - { url = "https://files.pythonhosted.org/packages/87/ed/e97ff55fe28c0e6e3cba641d622b15e071370b70e5f07c496b07b65db7c9/pydantic_core-2.46.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6ebb2668afd657e2127cb40f2ceb627dd78e74e9dfde14d9bf6cdd532a29ff59", size = 2048519, upload-time = "2026-04-13T09:05:10.464Z" }, - { url = "https://files.pythonhosted.org/packages/b6/51/e0db8267a287994546925f252e329eeae4121b1e77e76353418da5a3adf0/pydantic_core-2.46.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4864f5bbb7993845baf9209bae1669a8a76769296a018cb569ebda9dcb4241f5", size = 2026791, upload-time = "2026-04-13T09:04:37.724Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, ] [[package]] @@ -2194,26 +2198,26 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.13.1" +version = "2.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, ] [[package]] name = "pydocket" -version = "0.18.2" +version = "0.20.3" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "burner-redis" }, { name = "cloudpickle" }, { name = "cronsim" }, - { name = "fakeredis", extra = ["lua"] }, { name = "opentelemetry-api" }, { name = "prometheus-client" }, { name = "py-key-value-aio", extra = ["memory", "redis"] }, @@ -2225,9 +2229,9 @@ dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, { name = "uncalled-for" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b8/5f/82dde9fb6099b960a4203596d3b755d1bd2c0d0210fea104d015d6515d7f/pydocket-0.18.2.tar.gz", hash = "sha256:cc2051d15557f83bb164a83b0743fa9c12c2bfe9a9145cff3a5922b4935ce4f5", size = 354762, upload-time = "2026-03-10T13:09:22.52Z" } +sdist = { url = "https://files.pythonhosted.org/packages/41/4e/4994a407ae4e7561e84881b1edda8fedb58e7078c693845bdd1b66a73765/pydocket-0.20.3.tar.gz", hash = "sha256:ecf923cbcc8f7f26c7e76cad03a03d179536e4d947f322a89e9cc899a2631f72", size = 381244, upload-time = "2026-05-19T13:42:50.165Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/cf/8c1b6340baf81d7f6c97fe0181bda7cfd500d5e33bf469fbffbdae07b3c9/pydocket-0.18.2-py3-none-any.whl", hash = "sha256:19e48de15e83370f750e362610b777533ff9c0fa48bf36766ed581f91d266556", size = 99041, upload-time = "2026-03-10T13:09:20.598Z" }, + { url = "https://files.pythonhosted.org/packages/c5/18/2cf2fcabdc99c858401b7a44b7577e7a3002648048bb4d77517c22d8264d/pydocket-0.20.3-py3-none-any.whl", hash = "sha256:ba009b53270c5e677175def464fed2137d6289a9c9f57f376885f6c27f24a466", size = 110356, upload-time = "2026-05-19T13:42:47.816Z" }, ] [[package]] @@ -2241,15 +2245,15 @@ wheels = [ [[package]] name = "pymdown-extensions" -version = "10.21.2" +version = "10.21.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, + { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" }, ] [[package]] @@ -2279,14 +2283,14 @@ wheels = [ [[package]] name = "pyopenssl" -version = "26.0.0" +version = "26.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8e/11/a62e1d33b373da2b2c2cd9eb508147871c80f12b1cacde3c5d314922afdd/pyopenssl-26.0.0.tar.gz", hash = "sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc", size = 185534, upload-time = "2026-03-15T14:28:26.353Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/7d/d4f7d908fa8415571771b30669251d57c3cf313b36a856e6d7548ae01619/pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81", size = 57969, upload-time = "2026-03-15T14:28:24.864Z" }, + { url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" }, ] [[package]] @@ -2416,15 +2420,15 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.2.2" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" }, + { url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" }, ] [[package]] @@ -2459,11 +2463,11 @@ wheels = [ [[package]] name = "pytz" -version = "2026.1.post1" +version = "2026.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, ] [[package]] @@ -2525,11 +2529,11 @@ wheels = [ [[package]] name = "redis" -version = "6.4.0" +version = "7.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" }, + { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, ] [[package]] @@ -2547,47 +2551,47 @@ wheels = [ [[package]] name = "regex" -version = "2026.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/3a246dbf05666918bd3664d9d787f84a9108f6f43cc953a077e4a7dfdb7e/regex-2026.4.4.tar.gz", hash = "sha256:e08270659717f6973523ce3afbafa53515c4dc5dcad637dc215b6fd50f689423", size = 416000, upload-time = "2026-04-03T20:56:28.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/f5/ed97c2dc47b5fbd4b73c0d7d75f9ebc8eca139f2bbef476bba35f28c0a77/regex-2026.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2da82d643fa698e5e5210e54af90181603d5853cf469f5eedf9bfc8f59b4b8c7", size = 490343, upload-time = "2026-04-03T20:55:15.241Z" }, - { url = "https://files.pythonhosted.org/packages/80/e9/de4828a7385ec166d673a5790ad06ac48cdaa98bc0960108dd4b9cc1aef7/regex-2026.4.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:54a1189ad9d9357760557c91103d5e421f0a2dabe68a5cdf9103d0dcf4e00752", size = 291909, upload-time = "2026-04-03T20:55:17.558Z" }, - { url = "https://files.pythonhosted.org/packages/b4/d6/5cfbfc97f3201a4d24b596a77957e092030dcc4205894bc035cedcfce62f/regex-2026.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:76d67d5afb1fe402d10a6403bae668d000441e2ab115191a804287d53b772951", size = 289692, upload-time = "2026-04-03T20:55:20.561Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/f2212d9fd56fe897e36d0110ba30ba2d247bd6410c5bd98499c7e5a1e1f2/regex-2026.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7cd3e4ee8d80447a83bbc9ab0c8459781fa77087f856c3e740d7763be0df27f", size = 796979, upload-time = "2026-04-03T20:55:22.56Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e3/a016c12675fbac988a60c7e1c16e67823ff0bc016beb27bd7a001dbdabc6/regex-2026.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e19e18c568d2866d8b6a6dfad823db86193503f90823a8f66689315ba28fbe8", size = 866744, upload-time = "2026-04-03T20:55:24.646Z" }, - { url = "https://files.pythonhosted.org/packages/af/a4/0b90ca4cf17adc3cb43de80ec71018c37c88ad64987e8d0d481a95ca60b5/regex-2026.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7698a6f38730fd1385d390d1ed07bb13dce39aa616aca6a6d89bea178464b9a4", size = 911613, upload-time = "2026-04-03T20:55:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/8e/3b/2b3dac0b82d41ab43aa87c6ecde63d71189d03fe8854b8ca455a315edac3/regex-2026.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:173a66f3651cdb761018078e2d9487f4cf971232c990035ec0eb1cdc6bf929a9", size = 800551, upload-time = "2026-04-03T20:55:29.532Z" }, - { url = "https://files.pythonhosted.org/packages/25/fe/5365eb7aa0e753c4b5957815c321519ecab033c279c60e1b1ae2367fa810/regex-2026.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa7922bbb2cc84fa062d37723f199d4c0cd200245ce269c05db82d904db66b83", size = 776911, upload-time = "2026-04-03T20:55:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b3/7fb0072156bba065e3b778a7bc7b0a6328212be5dd6a86fd207e0c4f2dab/regex-2026.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:59f67cd0a0acaf0e564c20bbd7f767286f23e91e2572c5703bf3e56ea7557edb", size = 785751, upload-time = "2026-04-03T20:55:33.797Z" }, - { url = "https://files.pythonhosted.org/packages/02/1a/9f83677eb699273e56e858f7bd95acdbee376d42f59e8bfca2fd80d79df3/regex-2026.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:475e50f3f73f73614f7cba5524d6de49dee269df00272a1b85e3d19f6d498465", size = 860484, upload-time = "2026-04-03T20:55:35.745Z" }, - { url = "https://files.pythonhosted.org/packages/3b/7a/93937507b61cfcff8b4c5857f1b452852b09f741daa9acae15c971d8554e/regex-2026.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a1c0c7d67b64d85ac2e1879923bad2f08a08f3004055f2f406ef73c850114bd4", size = 765939, upload-time = "2026-04-03T20:55:37.972Z" }, - { url = "https://files.pythonhosted.org/packages/86/ea/81a7f968a351c6552b1670ead861e2a385be730ee28402233020c67f9e0f/regex-2026.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:1371c2ccbb744d66ee63631cc9ca12aa233d5749972626b68fe1a649dd98e566", size = 851417, upload-time = "2026-04-03T20:55:39.92Z" }, - { url = "https://files.pythonhosted.org/packages/4c/7e/323c18ce4b5b8f44517a36342961a0306e931e499febbd876bb149d900f0/regex-2026.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59968142787042db793348a3f5b918cf24ced1f23247328530e063f89c128a95", size = 789056, upload-time = "2026-04-03T20:55:42.303Z" }, - { url = "https://files.pythonhosted.org/packages/c0/af/e7510f9b11b1913b0cd44eddb784b2d650b2af6515bfce4cffcc5bfd1d38/regex-2026.4.4-cp314-cp314-win32.whl", hash = "sha256:59efe72d37fd5a91e373e5146f187f921f365f4abc1249a5ab446a60f30dd5f8", size = 272130, upload-time = "2026-04-03T20:55:44.995Z" }, - { url = "https://files.pythonhosted.org/packages/9a/51/57dae534c915e2d3a21490e88836fa2ae79dde3b66255ecc0c0a155d2c10/regex-2026.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:e0aab3ff447845049d676827d2ff714aab4f73f340e155b7de7458cf53baa5a4", size = 280992, upload-time = "2026-04-03T20:55:47.316Z" }, - { url = "https://files.pythonhosted.org/packages/0a/5e/abaf9f4c3792e34edb1434f06717fae2b07888d85cb5cec29f9204931bf8/regex-2026.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:a7a5bb6aa0cf62208bb4fa079b0c756734f8ad0e333b425732e8609bd51ee22f", size = 273563, upload-time = "2026-04-03T20:55:49.273Z" }, - { url = "https://files.pythonhosted.org/packages/ff/06/35da85f9f217b9538b99cbb170738993bcc3b23784322decb77619f11502/regex-2026.4.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:97850d0638391bdc7d35dc1c1039974dcb921eaafa8cc935ae4d7f272b1d60b3", size = 494191, upload-time = "2026-04-03T20:55:51.258Z" }, - { url = "https://files.pythonhosted.org/packages/54/5b/1bc35f479eef8285c4baf88d8c002023efdeebb7b44a8735b36195486ae7/regex-2026.4.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ee7337f88f2a580679f7bbfe69dc86c043954f9f9c541012f49abc554a962f2e", size = 293877, upload-time = "2026-04-03T20:55:53.214Z" }, - { url = "https://files.pythonhosted.org/packages/39/5b/f53b9ad17480b3ddd14c90da04bfb55ac6894b129e5dea87bcaf7d00e336/regex-2026.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7429f4e6192c11d659900c0648ba8776243bf396ab95558b8c51a345afeddde6", size = 292410, upload-time = "2026-04-03T20:55:55.736Z" }, - { url = "https://files.pythonhosted.org/packages/bb/56/52377f59f60a7c51aa4161eecf0b6032c20b461805aca051250da435ffc9/regex-2026.4.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4f10fbd5dd13dcf4265b4cc07d69ca70280742870c97ae10093e3d66000359", size = 811831, upload-time = "2026-04-03T20:55:57.802Z" }, - { url = "https://files.pythonhosted.org/packages/dd/63/8026310bf066f702a9c361f83a8c9658f3fe4edb349f9c1e5d5273b7c40c/regex-2026.4.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a152560af4f9742b96f3827090f866eeec5becd4765c8e0d3473d9d280e76a5a", size = 871199, upload-time = "2026-04-03T20:56:00.333Z" }, - { url = "https://files.pythonhosted.org/packages/20/9f/a514bbb00a466dbb506d43f187a04047f7be1505f10a9a15615ead5080ee/regex-2026.4.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54170b3e95339f415d54651f97df3bff7434a663912f9358237941bbf9143f55", size = 917649, upload-time = "2026-04-03T20:56:02.445Z" }, - { url = "https://files.pythonhosted.org/packages/cb/6b/8399f68dd41a2030218839b9b18360d79b86d22b9fab5ef477c7f23ca67c/regex-2026.4.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07f190d65f5a72dcb9cf7106bfc3d21e7a49dd2879eda2207b683f32165e4d99", size = 816388, upload-time = "2026-04-03T20:56:04.595Z" }, - { url = "https://files.pythonhosted.org/packages/1e/9c/103963f47c24339a483b05edd568594c2be486188f688c0170fd504b2948/regex-2026.4.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9a2741ce5a29d3c84b0b94261ba630ab459a1b847a0d6beca7d62d188175c790", size = 785746, upload-time = "2026-04-03T20:56:07.13Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ee/7f6054c0dec0cee3463c304405e4ff42e27cff05bf36fcb34be549ab17bd/regex-2026.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b26c30df3a28fd9793113dac7385a4deb7294a06c0f760dd2b008bd49a9139bc", size = 801483, upload-time = "2026-04-03T20:56:09.365Z" }, - { url = "https://files.pythonhosted.org/packages/30/c2/51d3d941cf6070dc00c3338ecf138615fc3cce0421c3df6abe97a08af61a/regex-2026.4.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:421439d1bee44b19f4583ccf42670ca464ffb90e9fdc38d37f39d1ddd1e44f1f", size = 866331, upload-time = "2026-04-03T20:56:12.039Z" }, - { url = "https://files.pythonhosted.org/packages/16/e8/76d50dcc122ac33927d939f350eebcfe3dbcbda96913e03433fc36de5e63/regex-2026.4.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b40379b53ecbc747fd9bdf4a0ea14eb8188ca1bd0f54f78893a39024b28f4863", size = 772673, upload-time = "2026-04-03T20:56:14.558Z" }, - { url = "https://files.pythonhosted.org/packages/a5/6e/5f6bf75e20ea6873d05ba4ec78378c375cbe08cdec571c83fbb01606e563/regex-2026.4.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:08c55c13d2eef54f73eeadc33146fb0baaa49e7335eb1aff6ae1324bf0ddbe4a", size = 857146, upload-time = "2026-04-03T20:56:16.663Z" }, - { url = "https://files.pythonhosted.org/packages/0b/33/3c76d9962949e487ebba353a18e89399f292287204ac8f2f4cfc3a51c233/regex-2026.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9776b85f510062f5a75ef112afe5f494ef1635607bf1cc220c1391e9ac2f5e81", size = 803463, upload-time = "2026-04-03T20:56:18.923Z" }, - { url = "https://files.pythonhosted.org/packages/19/eb/ef32dcd2cb69b69bc0c3e55205bce94a7def48d495358946bc42186dcccc/regex-2026.4.4-cp314-cp314t-win32.whl", hash = "sha256:385edaebde5db5be103577afc8699fea73a0e36a734ba24870be7ffa61119d74", size = 275709, upload-time = "2026-04-03T20:56:20.996Z" }, - { url = "https://files.pythonhosted.org/packages/a0/86/c291bf740945acbf35ed7dbebf8e2eea2f3f78041f6bd7cdab80cb274dc0/regex-2026.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:5d354b18839328927832e2fa5f7c95b7a3ccc39e7a681529e1685898e6436d45", size = 285622, upload-time = "2026-04-03T20:56:23.641Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e7/ec846d560ae6a597115153c02ca6138a7877a1748b2072d9521c10a93e58/regex-2026.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:af0384cb01a33600c49505c27c6c57ab0b27bf84a74e28524c92ca897ebdac9d", size = 275773, upload-time = "2026-04-03T20:56:26.07Z" }, +version = "2026.5.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074, upload-time = "2026-05-09T23:15:19.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/3e/9c3cd292d8808b3645a2ce517e200179b6d0e903f176300bd8b542e14de5/regex-2026.5.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:1bd7587a2948b4085195d5a3374eaf4a425dc3e55784c038175355ecf3bbbf8a", size = 490376, upload-time = "2026-05-09T23:14:09.64Z" }, + { url = "https://files.pythonhosted.org/packages/60/70/d43ee8a2ca0a8b68d167f21658b85520ac0574617c7f320367c5047f7556/regex-2026.5.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dea2e88e1cce4522496cce630e11e67b98b7076620bc4336c3f674bc21a375f4", size = 291964, upload-time = "2026-05-09T23:14:11.424Z" }, + { url = "https://files.pythonhosted.org/packages/21/91/9d50b433828d8e74196904e168a43abf1e6e88b2a15d47ed742456720c37/regex-2026.5.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2099f7e7ff7b6aa3192312650a56e91cc091e49d50b04e4f6f8b6e28b3b27f1c", size = 289682, upload-time = "2026-05-09T23:14:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/b835e3cafbb9d977736912436259ff551d60919f7d7b3d37d46659c63564/regex-2026.5.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd353045824e4477562a2ac718c25799cdaaa41f7aa925a806a8a3e6848a5b9", size = 796996, upload-time = "2026-05-09T23:14:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a6/9f992d00019166b9de01c546dd4549bc679f2a68df11b877740b0760b7c2/regex-2026.5.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65c8c8c37377794bd5b2f3ebe51919042bf17aec802e23c833d89782ed0c78af", size = 866089, upload-time = "2026-05-09T23:14:17.757Z" }, + { url = "https://files.pythonhosted.org/packages/e0/08/4d32af657e049b19cb62b02e46e38fe1518797bfb2203ee93a510b21b0dc/regex-2026.5.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b73ab8afcf66c622db143d1c6fda4e58e4d537ee4f125229ad47b1ab80f34c0", size = 911530, upload-time = "2026-05-09T23:14:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/d9/27/2af43dd1dc201d1fecefda64a45f4ad0995855b92724f795a777b402ee69/regex-2026.5.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0de5cf193997384ed2ca6f1cd4f78055b255d93d82d5a8cd6ba0d11c10b167e4", size = 800643, upload-time = "2026-05-09T23:14:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/a4/dd/23a249047013b5321d4a60c4d2437462086f601b061776a525e5fba2a59f/regex-2026.5.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d641a8c9a61618047796d572a39a79b26167b0411d2c3031937b2fe2d081e2cf", size = 777223, upload-time = "2026-05-09T23:14:24.179Z" }, + { url = "https://files.pythonhosted.org/packages/94/6a/e85ed9538cd19586d0465076a4578a12e093ce776d15f3f8ce92733a8dd6/regex-2026.5.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24b2355ef5cc9aa5b8f07d17704face1c166fdcc2290fa7bd6e6c925655a8346", size = 785760, upload-time = "2026-05-09T23:14:26.065Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c4/f25473209438638e947c55f9156fd8f236f74169229028cc99116380868e/regex-2026.5.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a24852d3c29ad9e47593593d8a247c44ccc3d0548ef12c822d6ed0810affe676", size = 860891, upload-time = "2026-05-09T23:14:28.17Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f7/f4f86e3c74419c37370e91f150ae0c2ef7d34b2e0e4cdd5da046a02e4022/regex-2026.5.9-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:916714069da19329ef7de197dcbc77bb3104145c7c2c864dbfbe318f46b88b14", size = 765891, upload-time = "2026-05-09T23:14:30.06Z" }, + { url = "https://files.pythonhosted.org/packages/26/70/704d8e13765939146b1cd0ef4e2feb71d7929727d2290f026eed10095955/regex-2026.5.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fa411799ca8da32a8d38d020a88faa5b6f91657d284761352940ecf9f7c3bbdd", size = 851380, upload-time = "2026-05-09T23:14:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/1a13582a8460038edc38e49f64ceb0dd7c60f5caba77571f4bf6601965d9/regex-2026.5.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e6da47d679b7010ef27556b6e0f99771b744936db1792a10ceac6547ae1503e", size = 789350, upload-time = "2026-05-09T23:14:34.799Z" }, + { url = "https://files.pythonhosted.org/packages/73/56/3dcafe34fc72e271d62ad9a291801e88a1457bb251c132f15fcc2e5aad1a/regex-2026.5.9-cp314-cp314-win32.whl", hash = "sha256:98bd73080e8756255137e1bd3f3f00295bbc5aa383c0e0f973920e9134d7c4ad", size = 272130, upload-time = "2026-05-09T23:14:36.729Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/02eebf0be95efe416c664db7fb8b6b05b7a0b06a7544f2884f2558b0526f/regex-2026.5.9-cp314-cp314-win_amd64.whl", hash = "sha256:ff8d372ac2acdc048d1c19916f27ee61bc5722728458ba6ca5052f2c72d51763", size = 280999, upload-time = "2026-05-09T23:14:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/70/5a/1dd1abee76cb7a846a0bcf42fdc87e5720c3c33c24f3e37814310a513d9f/regex-2026.5.9-cp314-cp314-win_arm64.whl", hash = "sha256:e1d93bf647916292e8edcec150c07ddf3dc50179ccaf770c04a7f9e452155372", size = 273500, upload-time = "2026-05-09T23:14:41.059Z" }, + { url = "https://files.pythonhosted.org/packages/86/c1/c5f619b0057a7965cb78ec559c1d7a45ce8c99a35bea95483d64959a93d9/regex-2026.5.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:83d0ee4a57d1c87cb549e195ec300b8f0ec3a82eba66d835e4e2ed8634fe4499", size = 494269, upload-time = "2026-05-09T23:14:42.869Z" }, + { url = "https://files.pythonhosted.org/packages/05/2c/5d01f1aee33de4bbe60c8452945bfc8477ca7c5ae4450f6bfe711036cb36/regex-2026.5.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d3d7eb5c9a7f6df82ed3cfac9beb93882a5cbcb5b8b157b56cb2b3b276574ac1", size = 293954, upload-time = "2026-05-09T23:14:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/7a/fe/e8988b2ae2108c6ef71bd4aa8d87fbe257976dd0810e826cd75f701c68b6/regex-2026.5.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:075160bf16658e16d35233300b8453aac25de4cbea808d22348b6979668e924d", size = 292405, upload-time = "2026-05-09T23:14:47.211Z" }, + { url = "https://files.pythonhosted.org/packages/79/34/d2b0937faa7859263f7f0a3c6b103a1296306be6952dc173d0154e9a2f49/regex-2026.5.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45375819235558a4ff1c4971dc32881f022613abdb180128f5cb4768c1765a1c", size = 811855, upload-time = "2026-05-09T23:14:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/80/fe/daf53a47457a8486db66c66c01ceb9c2303eecee3f87197f1e77eb1a736d/regex-2026.5.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ead4b163ac30a29574510cd4b3e2e985ac5290c05fc7095557d6a5f403fc31b5", size = 871189, upload-time = "2026-05-09T23:14:51.555Z" }, + { url = "https://files.pythonhosted.org/packages/1c/75/058fc4470cbfbf57d800aff1a0022b929a3f9fa553ee10a0cdf2070eb31f/regex-2026.5.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c6e4218fbdfbcd4f6c19efca40930d24a621bf4b48cb76bc6640543bd28ef20", size = 917485, upload-time = "2026-05-09T23:14:53.633Z" }, + { url = "https://files.pythonhosted.org/packages/88/e7/179cfda3a28bc843b5c6cfe7f79f23489c791ed95f151083803660878432/regex-2026.5.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6351571c8a42b505eb555c0dc47d740d0fb66977dc142919eea6f4325b7c56a0", size = 816369, upload-time = "2026-05-09T23:14:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/41/90/6f0cc422071688266d344fca8462d787cba0a2c144acb25721f9a61ec265/regex-2026.5.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:002205cafd2a9e78c6290c7d1df277bf3277b3b7a30e0b4bb0dac2e2e3f7cb2d", size = 785869, upload-time = "2026-05-09T23:14:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/02/67/a31f1760f09c27b251ef39e9beb541f462cf977381d067faa764c2c0e393/regex-2026.5.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8abd33fef90b2a9efac5557d6033ca82d1195ed3a15fea5af15ba7b463c6a63b", size = 801427, upload-time = "2026-05-09T23:15:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c4/1a80654597b6bc1e1ea0494824c31200e8a956abe290afae9b19a166a148/regex-2026.5.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:31037c82eccb44b7ea2e9e221d7c01429430e989a1f4b91ea5a855f6017b509a", size = 866482, upload-time = "2026-05-09T23:15:03.384Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/960724e06482c08466ff5611e242e86f80062949cdf6b4b9cc317b9dd93d/regex-2026.5.9-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5604dfd046dc37eca90250fc3be938b076c8059fa772ac0ed6f499b0f0fb0415", size = 773022, upload-time = "2026-05-09T23:15:05.625Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/a9979c3e7918280e93159ebcab5ef1a65116dd4f3bd6091be0eae4a126e8/regex-2026.5.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e1b1b4e496afbb24f4a62aba855ee4f88f25578927697b340702e48c9ee6bc2", size = 856642, upload-time = "2026-05-09T23:15:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d4/a9b732f2f0072c0ab12227483abb24fffcb9f73f8a2b203df0a6d0434735/regex-2026.5.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be3372b9df6ddecff6486d37e19095a7b4973137caf5512407a89f4455361f41", size = 803552, upload-time = "2026-05-09T23:15:10.215Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fe/1b3113817447a1d4155e4ac76d2e072f42c0bcba2f43fa8a0e756ea2cd91/regex-2026.5.9-cp314-cp314t-win32.whl", hash = "sha256:3ddd90103f9e5c471c49c7852ecc1fe27c7e45eb99e977aefe7caa4e779f4f58", size = 275746, upload-time = "2026-05-09T23:15:12.609Z" }, + { url = "https://files.pythonhosted.org/packages/92/73/93d42045302636c91f2e5ef588b65b84b01428f28ec77de256b1dfdfbe5c/regex-2026.5.9-cp314-cp314t-win_amd64.whl", hash = "sha256:ca518ed29c46eecba6010b15f1b9a479314d2de409536e71b6a13aa04e3b8a77", size = 285685, upload-time = "2026-05-09T23:15:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/da/80/35b4c33c804a165a7f55289afda3ea9e3eb6d15800341a2d66455c0f1f30/regex-2026.5.9-cp314-cp314t-win_arm64.whl", hash = "sha256:5e41809d2683fcde7d5a8c87a6567ba1fb1ce0de9f31bff578de00a4b2d76daa", size = 275713, upload-time = "2026-05-09T23:15:16.98Z" }, ] [[package]] name = "requests" -version = "2.33.1" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -2595,9 +2599,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] @@ -2627,28 +2631,28 @@ wheels = [ [[package]] name = "rich" -version = "14.3.4" +version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/67/cae617f1351490c25a4b8ac3b8b63a4dda609295d8222bad12242dfdc629/rich-14.3.4.tar.gz", hash = "sha256:817e02727f2b25b40ef56f5aa2217f400c8489f79ca8f46ea2b70dd5e14558a9", size = 230524, upload-time = "2026-04-11T02:57:45.419Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/76/6d163cfac87b632216f71879e6b2cf17163f773ff59c00b5ff4900a80fa3/rich-14.3.4-py3-none-any.whl", hash = "sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952", size = 310480, upload-time = "2026-04-11T02:57:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] [[package]] name = "rich-rst" -version = "1.3.2" +version = "2.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "docutils" }, + { name = "pygments" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/56/3191bae66b08ccc637ea8120426068bcb361cc323c96404c310886937067/rich_rst-2.0.1.tar.gz", hash = "sha256:cbe236ed0901d1ec8427cc6a50bf0a34353ba28ad014dc24def68bfe7f3b9e68", size = 300570, upload-time = "2026-05-16T00:47:57.362Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/a0/3d/55c17d3ebdf3cd81356002afe5bef9bb8af631db2819785b6eac845b925b/rich_rst-2.0.1-py3-none-any.whl", hash = "sha256:7ee15f345ce25fa02b582c272a6cdbaf0c21243e38061cea273cff659bf3ef61", size = 272922, upload-time = "2026-05-16T00:47:55.508Z" }, ] [[package]] @@ -2717,27 +2721,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" }, - { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" }, - { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" }, - { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" }, - { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" }, - { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" }, - { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" }, - { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" }, - { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" }, - { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" }, - { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" }, - { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" }, - { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" }, - { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" }, - { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" }, - { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, +version = "0.15.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" }, + { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" }, + { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" }, + { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" }, + { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" }, + { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" }, + { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" }, + { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, ] [[package]] @@ -2818,15 +2822,6 @@ 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" }, ] -[[package]] -name = "sortedcontainers" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } -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" }, -] - [[package]] name = "sqlalchemy" version = "2.0.49" @@ -2952,7 +2947,7 @@ wheels = [ [[package]] name = "tox" -version = "4.53.0" +version = "4.54.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cachetools" }, @@ -2966,14 +2961,14 @@ dependencies = [ { name = "tomli-w" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/01/d87a00063fa670ce4c48a9706b615a95ddf2c9ef5558d43af6071f166fd4/tox-4.53.0.tar.gz", hash = "sha256:62c780e42f87d34ee60f2ea20342156253794fdcbd6885fd797d98ee05009f22", size = 274048, upload-time = "2026-04-14T13:44:13.782Z" } +sdist = { url = "https://files.pythonhosted.org/packages/17/2c/7ca5edb5ecd6bcc5cc926fe87e62a84dcd3cbd03a32f9d0bee98d2bee7cf/tox-4.54.0.tar.gz", hash = "sha256:21e36fd8256590379620848d0b03b52f4d541b65b749de1a17c3e616978dad58", size = 279256, upload-time = "2026-05-12T19:13:05.937Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/03/02e2a03f3756cfb66e7e1bac41b06953f12cec75ddb961d56695d4d43dc4/tox-4.53.0-py3-none-any.whl", hash = "sha256:cc4e716d18c4889aa179d785175c438fa60c35deef20ce689ec288d8fb656096", size = 212164, upload-time = "2026-04-14T13:44:11.997Z" }, + { url = "https://files.pythonhosted.org/packages/26/18/20cf56a76c5d6117547179db9b5d31cc56e3e90507d1b0b748da74aa95c5/tox-4.54.0-py3-none-any.whl", hash = "sha256:a2d7c1177242ae9c3d9e404039e9f945ce16a3e5dfc66972c643e27d7e764f4b", size = 214527, upload-time = "2026-05-12T19:13:04.334Z" }, ] [[package]] name = "twisted" -version = "25.5.0" +version = "26.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -2984,9 +2979,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "zope-interface" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/13/0f/82716ed849bf7ea4984c21385597c949944f0f9b428b5710f79d0afc084d/twisted-25.5.0.tar.gz", hash = "sha256:1deb272358cb6be1e3e8fc6f9c8b36f78eb0fa7c2233d2dbe11ec6fee04ea316", size = 3545725, upload-time = "2025-06-07T09:52:24.858Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/97/6e9beb1e78247ae6dc34114f27d538cf2cb183c4afcd3609dfdf2b0439c8/twisted-26.4.0.tar.gz", hash = "sha256:dbfd0fe1ee409d0243fdd7a6a6ff14f4948cec1fd78e0376291f805e1501fae9", size = 3575095, upload-time = "2026-05-11T11:24:51.861Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/66/ab7efd8941f0bc7b2bd555b0f0471bff77df4c88e0cc31120c82737fec77/twisted-25.5.0-py3-none-any.whl", hash = "sha256:8559f654d01a54a8c3efe66d533d43f383531ebf8d81d9f9ab4769d91ca15df7", size = 3204767, upload-time = "2025-06-07T09:52:21.428Z" }, + { url = "https://files.pythonhosted.org/packages/a6/57/bcf4e2370dd218c9aa68a9140a65d86729c73f1d529f7e94786c2766fc72/twisted-26.4.0-py3-none-any.whl", hash = "sha256:dc25ea0ebf6511c24f03232ee9f4afa54b291c5d897990e3a39cc4d14a1ef4c0", size = 3230362, upload-time = "2026-05-11T11:24:49.5Z" }, ] [package.optional-dependencies] @@ -3007,7 +3002,7 @@ wheels = [ [[package]] name = "typer" -version = "0.24.1" +version = "0.25.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -3015,18 +3010,18 @@ dependencies = [ { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, ] [[package]] name = "types-pyyaml" -version = "6.0.12.20260408" +version = "6.0.12.20260518" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/74/73/b759b1e413c31034cc01ecdfb96b38115d0ab4db55a752a3929f0cd449fd/types_pyyaml-6.0.12.20260408.tar.gz", hash = "sha256:92a73f2b8d7f39ef392a38131f76b970f8c66e4c42b3125ae872b7c93b556307", size = 17735, upload-time = "2026-04-08T04:30:50.974Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/f0/c391068b86abb708882c6d75a08cd7d25b2c7227dab527b3a3685a3c635b/types_pyyaml-6.0.12.20260408-py3-none-any.whl", hash = "sha256:fbc42037d12159d9c801ebfcc79ebd28335a7c13b08a4cfbc6916df78fee9384", size = 20339, upload-time = "2026-04-08T04:30:50.113Z" }, + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, ] [[package]] @@ -3052,11 +3047,11 @@ wheels = [ [[package]] name = "tzdata" -version = "2026.1" +version = "2026.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, ] [[package]] @@ -3082,41 +3077,41 @@ wheels = [ [[package]] name = "ujson" -version = "5.12.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cb/3e/c35530c5ffc25b71c59ae0cd7b8f99df37313daa162ce1e2f7925f7c2877/ujson-5.12.0.tar.gz", hash = "sha256:14b2e1eb528d77bc0f4c5bd1a7ebc05e02b5b41beefb7e8567c9675b8b13bcf4", size = 7158451, upload-time = "2026-03-11T22:19:30.397Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/bd/9a8d693254bada62bfea75a507e014afcfdb6b9d047b6f8dd134bfefaf67/ujson-5.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85833bca01aa5cae326ac759276dc175c5fa3f7b3733b7d543cf27f2df12d1ef", size = 56499, upload-time = "2026-03-11T22:18:45.431Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2d/285a83df8176e18dcd675d1a4cff8f7620f003f30903ea43929406e98986/ujson-5.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d22cad98c2a10bbf6aa083a8980db6ed90d4285a841c4de892890c2b28286ef9", size = 53998, upload-time = "2026-03-11T22:18:47.184Z" }, - { url = "https://files.pythonhosted.org/packages/bf/8b/e2f09e16dabfa91f6a84555df34a4329fa7621e92ed054d170b9054b9bb2/ujson-5.12.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99cc80facad240b0c2fb5a633044420878aac87a8e7c348b9486450cba93f27c", size = 57783, upload-time = "2026-03-11T22:18:48.271Z" }, - { url = "https://files.pythonhosted.org/packages/68/fb/ba1d06f3658a0c36d0ab3869ec3914f202bad0a9bde92654e41516c7bb13/ujson-5.12.0-cp314-cp314-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:d1831c07bd4dce53c4b666fa846c7eba4b7c414f2e641a4585b7f50b72f502dc", size = 60011, upload-time = "2026-03-11T22:18:49.284Z" }, - { url = "https://files.pythonhosted.org/packages/64/2b/3e322bf82d926d9857206cd5820438d78392d1f523dacecb8bd899952f73/ujson-5.12.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e00cec383eab2406c9e006bd4edb55d284e94bb943fda558326048178d26961", size = 57465, upload-time = "2026-03-11T22:18:50.584Z" }, - { url = "https://files.pythonhosted.org/packages/e9/fd/af72d69603f9885e5136509a529a4f6d88bf652b457263ff96aefcd3ab7d/ujson-5.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f19b3af31d02a2e79c5f9a6deaab0fb3c116456aeb9277d11720ad433de6dfc6", size = 1037275, upload-time = "2026-03-11T22:18:51.998Z" }, - { url = "https://files.pythonhosted.org/packages/9c/a7/a2411ec81aef7872578e56304c3e41b3a544a9809e95c8e1df46923fc40b/ujson-5.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bacbd3c69862478cbe1c7ed4325caedec580d8acf31b8ee1b9a1e02a56295cad", size = 1196758, upload-time = "2026-03-11T22:18:53.548Z" }, - { url = "https://files.pythonhosted.org/packages/ed/85/aa18ae175dd03a118555aa14304d4f466f9db61b924c97c6f84388ecacb1/ujson-5.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94c5f1621cbcab83c03be46441f090b68b9f307b6c7ec44d4e3f6d5997383df4", size = 1089760, upload-time = "2026-03-11T22:18:55.336Z" }, - { url = "https://files.pythonhosted.org/packages/d3/d4/4b40b67ac7e916ebffc3041ae2320c5c0b8a045300d4c542b6e50930cca5/ujson-5.12.0-cp314-cp314-win32.whl", hash = "sha256:e6369ac293d2cc40d52577e4fa3d75a70c1aae2d01fa3580a34a4e6eff9286b9", size = 41043, upload-time = "2026-03-11T22:18:56.505Z" }, - { url = "https://files.pythonhosted.org/packages/24/38/a1496d2a3428981f2b3a2ffbb4656c2b05be6cc406301d6b10a6445f6481/ujson-5.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:31348a0ffbfc815ce78daac569d893349d85a0b57e1cd2cdbba50b7f333784da", size = 45303, upload-time = "2026-03-11T22:18:57.454Z" }, - { url = "https://files.pythonhosted.org/packages/85/d3/39dbd3159543d9c57ec3a82d36226152cf0d710784894ce5aa24b8220ac1/ujson-5.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:6879aed770557f0961b252648d36f6fdaab41079d37a2296b5649fd1b35608e0", size = 39860, upload-time = "2026-03-11T22:18:58.578Z" }, - { url = "https://files.pythonhosted.org/packages/c3/71/9b4dacb177d3509077e50497222d39eec04c8b41edb1471efc764d645237/ujson-5.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ddb08b3c2f9213df1f2e3eb2fbea4963d80ec0f8de21f0b59898e34f3b3d96d", size = 56845, upload-time = "2026-03-11T22:18:59.629Z" }, - { url = "https://files.pythonhosted.org/packages/24/c2/8abffa3be1f3d605c4a62445fab232b3e7681512ce941c6b23014f404d36/ujson-5.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a3ae28f0b209be5af50b54ca3e2123a3de3a57d87b75f1e5aa3d7961e041983", size = 54463, upload-time = "2026-03-11T22:19:00.697Z" }, - { url = "https://files.pythonhosted.org/packages/db/2e/60114a35d1d6796eb428f7affcba00a921831ff604a37d9142c3d8bbe5c5/ujson-5.12.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30ad4359413c8821cc7b3707f7ca38aa8bc852ba3b9c5a759ee2d7740157315", size = 58689, upload-time = "2026-03-11T22:19:01.739Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ad/010925c2116c21ce119f9c2ff18d01f48a19ade3ff4c5795da03ce5829fc/ujson-5.12.0-cp314-cp314t-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:02f93da7a4115e24f886b04fd56df1ee8741c2ce4ea491b7ab3152f744ad8f8e", size = 60618, upload-time = "2026-03-11T22:19:03.101Z" }, - { url = "https://files.pythonhosted.org/packages/9b/74/db7f638bf20282b1dccf454386cbd483faaaed3cdbb9cb27e06f74bb109e/ujson-5.12.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ff4ede90ed771140caa7e1890de17431763a483c54b3c1f88bd30f0cc1affc0", size = 58151, upload-time = "2026-03-11T22:19:04.175Z" }, - { url = "https://files.pythonhosted.org/packages/9c/7e/3ebaecfa70a2e8ce623db8e21bd5cb05d42a5ef943bcbb3309d71b5de68d/ujson-5.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bf9cc97f05048ac8f3e02cd58f0fe62b901453c24345bfde287f4305dcc31c", size = 1038117, upload-time = "2026-03-11T22:19:05.558Z" }, - { url = "https://files.pythonhosted.org/packages/2e/aa/e073eda7f0036c2973b28db7bb99faba17a932e7b52d801f9bb3e726271f/ujson-5.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2324d9a0502317ffc35d38e153c1b2fa9610ae03775c9d0f8d0cca7b8572b04e", size = 1197434, upload-time = "2026-03-11T22:19:06.92Z" }, - { url = "https://files.pythonhosted.org/packages/1c/01/b9a13f058fdd50c746b192c4447ca8d6352e696dcda912ccee10f032ff85/ujson-5.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:50524f4f6a1c839714dbaff5386a1afb245d2d5ec8213a01fbc99cea7307811e", size = 1090401, upload-time = "2026-03-11T22:19:08.383Z" }, - { url = "https://files.pythonhosted.org/packages/c4/37/3d1b4e0076b6e43379600b5229a5993db8a759ff2e1830ea635d876f6644/ujson-5.12.0-cp314-cp314t-win32.whl", hash = "sha256:f7a0430d765f9bda043e6aefaba5944d5f21ec43ff4774417d7e296f61917382", size = 41880, upload-time = "2026-03-11T22:19:09.671Z" }, - { url = "https://files.pythonhosted.org/packages/b1/c5/3c2a262a138b9f0014fe1134a6b5fdc2c54245030affbaac2fcbc0632138/ujson-5.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ccbfd94e59aad4a2566c71912b55f0547ac1680bfac25eb138e6703eb3dd434e", size = 46365, upload-time = "2026-03-11T22:19:10.662Z" }, - { url = "https://files.pythonhosted.org/packages/83/40/956dc20b7e00dc0ff3259871864f18dab211837fce3478778bedb3132ac1/ujson-5.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:42d875388fbd091c7ea01edfff260f839ba303038ffb23475ef392012e4d63dd", size = 40398, upload-time = "2026-03-11T22:19:11.666Z" }, +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/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" }, ] [[package]] name = "uncalled-for" -version = "0.3.1" +version = "0.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/68/35c1d87e608940badbcfeb630347aa0509897284684f61fab6423d02b253/uncalled_for-0.3.1.tar.gz", hash = "sha256:5e412ac6708f04b56bef5867b5dcf6690ebce4eb7316058d9c50787492bb4bca", size = 49693, upload-time = "2026-04-07T13:05:06.462Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/82/345cc927f7fbdae6065e7768759932fcc827fc20b29b45dfbafa2f1f7da4/uncalled_for-0.3.2.tar.gz", hash = "sha256:89f5dbcd71e2b8f47c030b1fa302e6cce2ec795d1ac565eeb6525c5fe55cb8a2", size = 50032, upload-time = "2026-05-06T13:38:25.204Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/e1/7ec67882ad8fc9f86384bef6421fa252c9cbe5744f8df6ce77afc9eca1f5/uncalled_for-0.3.1-py3-none-any.whl", hash = "sha256:074cdc92da8356278f93d0ded6f2a66dd883dbecaf9bc89437646ee2289cc200", size = 11361, upload-time = "2026-04-07T13:05:05.341Z" }, + { url = "https://files.pythonhosted.org/packages/3b/25/2c87754f3a9e692315f7b811244090e68f362979fc8886b3fbd2985a1d8c/uncalled_for-0.3.2-py3-none-any.whl", hash = "sha256:0ff60b142c7d1f8070bde9d42afaa70aedc77dcc10998c227687e9c15713418e", size = 11444, upload-time = "2026-05-06T13:38:24.025Z" }, ] [[package]] @@ -3130,24 +3125,24 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] name = "uvicorn" -version = "0.44.0" +version = "0.47.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b1/8e7077a8641086aea449e1b5752a570f1b5906c64e0a33cd6d93b63a066b/uvicorn-0.47.0.tar.gz", hash = "sha256:7c9a0ea1a9414106bbab7324609c162d8fa0cdcdcb703060987269d77c7bb533", size = 90582, upload-time = "2026-05-14T18:16:54.455Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" }, + { url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" }, ] [[package]] @@ -3161,7 +3156,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.2.4" +version = "21.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -3169,9 +3164,9 @@ dependencies = [ { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/98/3a7e644e19cb26133488caff231be390579860bbbb3da35913c49a1d0a46/virtualenv-21.2.4.tar.gz", hash = "sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada", size = 5850742, upload-time = "2026-04-14T22:15:31.438Z" } +sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/8d/edd0bd910ff803c308ee9a6b7778621af0d10252219ad9f19ef4d4982a61/virtualenv-21.2.4-py3-none-any.whl", hash = "sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac", size = 5831232, upload-time = "2026-04-14T22:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" }, ] [[package]] @@ -3194,45 +3189,58 @@ wheels = [ [[package]] name = "watchfiles" -version = "1.1.1" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, - { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, - { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, - { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, - { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, - { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, - { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, - { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, - { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, - { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, - { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, - { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, - { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, - { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, - { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, - { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, - { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, ] [[package]] name = "wcwidth" -version = "0.6.0" +version = "0.7.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/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } 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/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, ] [[package]] @@ -3305,52 +3313,45 @@ wheels = [ [[package]] name = "zensical" -version = "0.0.36" +version = "0.0.43" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "deepmerge" }, + { name = "jinja2" }, { name = "markdown" }, { name = "pygments" }, { name = "pymdown-extensions" }, { name = "pyyaml" }, { name = "tomli" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/e9/8d0e66ad113e702d7f5eed2cc5ad0f035cb212c49b0415553473f2da900b/zensical-0.0.36.tar.gz", hash = "sha256:32126c57fd241267e55c863f2bdd31bfe4422c376280e74e4a1036a89c0d513c", size = 3897092, upload-time = "2026-04-23T15:37:46.892Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/ff/2846737502a9ae783570b32aac4f20f5232512fbf245bbf1c0398728c7ed/zensical-0.0.36-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:3d42312267c4124ed67ddfd2809167bdd3ea4f71892c8a20897be98b66da8b73", size = 12515534, upload-time = "2026-04-23T15:37:07.815Z" }, - { url = "https://files.pythonhosted.org/packages/84/e9/443b561793ed6626cb46c328fd8fd916a7b18e5af5349934c5346438548c/zensical-0.0.36-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8462c133c8da5234cd301ad3c722d52d66a0092a51b7b93e2ce12f217976b29b", size = 12384874, upload-time = "2026-04-23T15:37:11.617Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f0/faecf0a5dff381ff331b7b87d385c8335ca0b7297a33d85abc3313cfa598/zensical-0.0.36-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0a6dc86dc0d8488b18c6501d62b63989a538350a33173347da8b9f1f54bed2c", size = 12764889, upload-time = "2026-04-23T15:37:14.512Z" }, - { url = "https://files.pythonhosted.org/packages/b0/56/1ddee63d323d779733e5bf00e99c878f03e50b77f294711a850c1e1ceddb/zensical-0.0.36-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d31c726d7f13601a568a2a9e80592472da24657ff5428ef15c2c95bc458cb65b", size = 12705679, upload-time = "2026-04-23T15:37:18.038Z" }, - { url = "https://files.pythonhosted.org/packages/9b/61/4b264b1466251450856ed4768fa9a793f7c24172039f47f562cd899e0744/zensical-0.0.36-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3a7e8b32e41784d19122cb16a0bd6fcb53852177ce689ceba1ba7a8bb20fe3a0", size = 13057470, upload-time = "2026-04-23T15:37:21.594Z" }, - { url = "https://files.pythonhosted.org/packages/17/9b/c44a1ebc2fe8daadecbd9ea41c498e545c494204e239314347fbcec51159/zensical-0.0.36-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abe5d24716107edb033c2326c816b891952b98b9637c5308f5320712a2e70aac", size = 12792788, upload-time = "2026-04-23T15:37:24.784Z" }, - { url = "https://files.pythonhosted.org/packages/97/94/4d0e345f75f892fce029b513a26f4491b6dd39ff73c5bee3f8fbb9305e8c/zensical-0.0.36-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9ed7a54465b497d1548aeb6b38a99ac6f45c8f191a5cf2a180902af28c0cd58a", size = 12940940, upload-time = "2026-04-23T15:37:27.975Z" }, - { url = "https://files.pythonhosted.org/packages/de/2e/4612b97d8d493a6ac591ebb28a6b3a592eb4d969bbb8a92311125fe0b874/zensical-0.0.36-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:282eb4eaf7cd3bd389a4b826c1c13a30136e5c6fcfcafce26fc27cd05acc660f", size = 12980355, upload-time = "2026-04-23T15:37:30.998Z" }, - { url = "https://files.pythonhosted.org/packages/c1/90/c1a91b503aec105cdb7ccf4d466e8612c113186f090c61d795272cecce27/zensical-0.0.36-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:36d5719df268697dbcf7aa5bbea9eea353501c80b1c6c17d6c7f2c69405be9af", size = 13124220, upload-time = "2026-04-23T15:37:34.506Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e0/b9ffadaff0b80498699aaf0f2bcc0b659db074fd94071520d22f035e5125/zensical-0.0.36-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7771aaf33f7d06f779e041930812fe65f5f97a6f4fbd1c7e51924ce1a27c0c66", size = 13070894, upload-time = "2026-04-23T15:37:38.092Z" }, - { url = "https://files.pythonhosted.org/packages/c5/c3/aea29875f7b89d7c79b84a30259356404bf778d42c27c36632ef19aa826c/zensical-0.0.36-cp310-abi3-win32.whl", hash = "sha256:61f1dff7c38a8d0acb054c11426c25f0a57b973703eb3d0bf1e8cc04ca54d047", size = 12084318, upload-time = "2026-04-23T15:37:41.093Z" }, - { url = "https://files.pythonhosted.org/packages/6d/fd/6d7b2088180624e3c6dd9471788ac277b9ae3091a4da1b23a191c8ed6419/zensical-0.0.36-cp310-abi3-win_amd64.whl", hash = "sha256:be08cdf13599cfa92d71563ec12058ab20f234ed5489293b83b0f29563cc588a", size = 12301398, upload-time = "2026-04-23T15:37:44.07Z" }, -] - -[[package]] -name = "zipp" -version = "3.23.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/85/ec45162e7824a8f879d887ef0774ee65926bf7d1064e2eebccc7eaee3378/zensical-0.0.43.tar.gz", hash = "sha256:dc2d3804ff562795c1024130e0c3ce79736467930729dda314f096d0e35b98c8", size = 3932396, upload-time = "2026-05-19T09:44:07.418Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/55e0709607ae41c266987c3b91a1a9702b37fbbef0d07eddfe5e25c2d823/zensical-0.0.43-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:17c335362b6bac3a50178181694a964f6d9f0c516fc532129ba5a0a5c4103fb6", size = 12706531, upload-time = "2026-05-19T09:43:32.729Z" }, + { url = "https://files.pythonhosted.org/packages/2c/64/ce8627bc5ea30556162b29b041fe97d6a6aef2a87b51f12def628e4fa608/zensical-0.0.43-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8fe97f185194215f6193af45a17d2b30ebd72c8113e3650f2d7d6767b9c2206", size = 12563012, upload-time = "2026-05-19T09:43:35.962Z" }, + { url = "https://files.pythonhosted.org/packages/66/d1/533bc9454f0e06b3d9d8bd2e7ac405308c3d4dee6572acab98f0ed6d1c07/zensical-0.0.43-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c4c85978c765b3e7f347e8102dfe1373d4bbe4229d7008b6bdbf352f1fbcd7f", size = 12947599, upload-time = "2026-05-19T09:43:38.754Z" }, + { url = "https://files.pythonhosted.org/packages/75/a0/94f47d6fb592997be7ab9526938c929f0199adf2637c3c2b2b9b2101b28e/zensical-0.0.43-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:90d7c06ffd07b2bdf78bef041d541baba8a3ea51fd2dd84dbdbc5b0229076524", size = 12904911, upload-time = "2026-05-19T09:43:42.434Z" }, + { url = "https://files.pythonhosted.org/packages/96/fb/1db3ad9a86ff772f74a8bc60ad5b447aa02a158e70f94adacf50bdd5c40f/zensical-0.0.43-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:60022f4a6b95e46ec0023f51052fcd491743b3ebd08c0066b22a5cf1e741fecd", size = 13269386, upload-time = "2026-05-19T09:43:45.387Z" }, + { url = "https://files.pythonhosted.org/packages/31/ee/b24fd0f94885519d851c35615b086d069a1077b0198021a56755395a4633/zensical-0.0.43-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e278eb948a0b7545d50609d713c7c27e366dade4523ff73a311a5d5f136518a", size = 12999364, upload-time = "2026-05-19T09:43:48.549Z" }, + { url = "https://files.pythonhosted.org/packages/28/78/401ccd7afd9d2690f81b5319b7f1eed05108154ce20e4207053914518c1c/zensical-0.0.43-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b85e5ab99fbda13823e67c43a4be6e5ebda6600602969c6575e143f20ac203fd", size = 13124392, upload-time = "2026-05-19T09:43:50.965Z" }, + { url = "https://files.pythonhosted.org/packages/98/b3/9af6eba5826b0ef143fc8308bd1e219e221441e307a958e39f824ba9ab53/zensical-0.0.43-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:751385accc92cccfd4560dabed7c423870686ef6ede244a67e5c96286af25e8f", size = 13177538, upload-time = "2026-05-19T09:43:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/be/6b/cd090bd6659d32692487206469988ee84d41aa6de4cdf9e380f847da90e2/zensical-0.0.43-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:dd3ff5bfa6e65cf3d2550dc639c3da2a3bfa11087b83d57e06623c4c1607d583", size = 13327086, upload-time = "2026-05-19T09:43:56.8Z" }, + { url = "https://files.pythonhosted.org/packages/79/5b/ac2555354b5a53cb9c2c942811905c47be0b9f5603d3c1328ee8564333eb/zensical-0.0.43-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85055a115b12f49c6ab194dcf04f966fc06b690ed6a8ddddd819929fc5f340e6", size = 13284645, upload-time = "2026-05-19T09:43:59.329Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c6/1688ec6e5be15e3ab367d7804753291bfbdff3109b06e20c19ce30a7129c/zensical-0.0.43-cp310-abi3-win32.whl", hash = "sha256:8a75ddd4bb3cd3c4a8e71d2ebae44c5611fd636c1d355c6124dd96e2f9c52838", size = 12256740, upload-time = "2026-05-19T09:44:02.102Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a8/d967e70eac810a7e9eb8c5150d6d02848a1f42260f42977c71debed3cb02/zensical-0.0.43-cp310-abi3-win_amd64.whl", hash = "sha256:03a9d1744a6394ad66c355d6f1de04cfd92efa525b0b94bf6dbf6971c5cd2c6b", size = 12496166, upload-time = "2026-05-19T09:44:04.915Z" }, ] [[package]] name = "zope-interface" -version = "8.3" +version = "8.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/04/0b1d92e7d31507c5fbe203d9cc1ae80fb0645688c7af751ea0ec18c2223e/zope_interface-8.3.tar.gz", hash = "sha256:e1a9de7d0b5b5c249a73b91aebf4598ce05e334303af6aa94865893283e9ff10", size = 256822, upload-time = "2026-04-10T06:12:35.036Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/65/34a6e6e4dfa260c4c55ee02bb2fc53625e126ff0181485286cf0c9d453d6/zope_interface-8.4.tar.gz", hash = "sha256:9dbee7925a23aa6349738892c911019d4095a96cff487b743482073ecbc174a8", size = 257736, upload-time = "2026-04-25T07:22:10.439Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/d9/95fe0d4d8da09042383c42f239e0106f1019ec86a27ed9f5000e754f6e7a/zope_interface-8.3-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:96f0001b49227d756770fc70ecde49f19332ae98ec98e1bbbf2fd7a87e9d4e45", size = 211979, upload-time = "2026-04-10T06:22:38.628Z" }, - { url = "https://files.pythonhosted.org/packages/f3/01/b6f694444ea1c911a4ea915f4ef066a95e9d1a58256a30c131ec88c3ae64/zope_interface-8.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3853bfb808084e1b4a3a769b00bd8b58a52b0c4a4fc5c23de26d283cd8beb627", size = 212038, upload-time = "2026-04-10T06:22:40.475Z" }, - { url = "https://files.pythonhosted.org/packages/f7/cf/237de1fba4f05686bc344eeb035236bd89890679c8211f129f05b5971ccf/zope_interface-8.3-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:33a13acba79ef693fb64ceb6193ece913d39586f184797f133c1bc549da86851", size = 266041, upload-time = "2026-04-10T06:22:42.093Z" }, - { url = "https://files.pythonhosted.org/packages/58/5f/df85b1ff5626d7f05231e69b7efd38bdc2c82ca363495e0bb112aaf655b3/zope_interface-8.3-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e9f7e4b46741a11a9e1fab8b68710f08dec700e9f1b877cdca02480fbebe4846", size = 269094, upload-time = "2026-04-10T06:22:43.832Z" }, - { url = "https://files.pythonhosted.org/packages/5f/10/7ad1ff9c514fe38b176fc1271967c453074eb386a4515bd3b957c485f3a8/zope_interface-8.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ce49d43366e12aeccd14fcaebb3ef110f50f5795e0d4a95383ea057365cedf2", size = 269413, upload-time = "2026-04-10T06:22:45.573Z" }, - { url = "https://files.pythonhosted.org/packages/38/42/3b0b5edee7801e0dd5c42c2c9bb4ec8bec430a6628462eb1315db76a7954/zope_interface-8.3-cp314-cp314-win_amd64.whl", hash = "sha256:301db4049c79a15a3b29d89795e150daf0e9ae701404b112ad6585ea863f6ef5", size = 215170, upload-time = "2026-04-10T06:22:47.115Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d5/ca60c8b404b303d9490e1417430a5198a77557dbeb17c1cb31616e432318/zope_interface-8.4-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:7cbb887fdbfaacb4c362dbb487033551646e28013ad5ffe72e96eb260003a1a1", size = 212012, upload-time = "2026-04-25T07:28:36.88Z" }, + { url = "https://files.pythonhosted.org/packages/83/64/6bb9f54250c817e24b39e986f173b6cd21ff658bec6c6cc0baad05d761e4/zope_interface-8.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a5638c6be715116d3453e6d099c299c6844d54810de7445ce116424e905ede06", size = 212071, upload-time = "2026-04-25T07:28:38.742Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cf/42851262e102723058019dc7d0b48210b85a935f79ae32ce60ddccc2e8fb/zope_interface-8.4-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:b8147b40bfcd53803870a9519e0879ff066aeecc2fcff8295663c1b17fc38dc2", size = 266075, upload-time = "2026-04-25T07:28:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/d2/a7/e48c79b836f6f0a2c219288e2ec343517f90e95c93de5435a8a23918bf20/zope_interface-8.4-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:049ba3c7b38cc400ae08e011617635706e0f442e1d075db1b015246fcbf6091e", size = 269127, upload-time = "2026-04-25T07:28:42.868Z" }, + { url = "https://files.pythonhosted.org/packages/6a/40/0e26f24d3a2f34f0de2cfeaab6458a865284d9d1fa317ab78913aa1f7322/zope_interface-8.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9c4ac009c2c8e43283842f80387c4d4b41bcbc293391c3b9ab71532ae1ccc301", size = 269446, upload-time = "2026-04-25T07:28:44.97Z" }, + { url = "https://files.pythonhosted.org/packages/91/d5/20310601450367fc35fa28b0544c98d0347b8cc25eaf106a2c4cc36841e1/zope_interface-8.4-cp314-cp314-win_amd64.whl", hash = "sha256:4713bf651ec36e7eea49d2ace4f0e89bec2b33a339674874b1121f2537edc62a", size = 215199, upload-time = "2026-04-25T07:28:47.146Z" }, + { url = "https://files.pythonhosted.org/packages/5b/00/0d22ce75126e31f81baa5889e2a40aad37c8e34d1220cf8b18d744f2b5d9/zope_interface-8.4-cp314-cp314-win_arm64.whl", hash = "sha256:d934497c4b72d5f528d2b5ebe9b8b5a7004b5877948ebd4ea00c2432fb27178f", size = 213178, upload-time = "2026-04-25T07:28:48.868Z" }, ] diff --git a/compose.dev.yml b/compose.dev.yml index 911203a..52b7885 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -31,28 +31,7 @@ services: - DJANGO_SETUP=False - UV_PROJECT_ENVIRONMENT=/home/app/.venv - PYTHONPYCACHEPREFIX=/tmp/pycache - env_file: - - .env - - .env.dev - volumes: - - ./backend:/app - - backend_venv:/home/app/.venv - depends_on: - database: - condition: service_healthy - state: - condition: service_healthy - message-broker: - condition: service_healthy - - realtime-consumer: - build: - context: ./backend - target: realtime-consumer - environment: - - DJANGO_SETUP=False - - UV_PROJECT_ENVIRONMENT=/home/app/.venv - - PYTHONPYCACHEPREFIX=/tmp/pycache + - MQTT_CONSUMER_ENABLED=true - MQTT_HOST=telemetry-broker - MQTT_PORT=1883 env_file: @@ -66,10 +45,10 @@ services: condition: service_healthy state: condition: service_healthy - telemetry-broker: - condition: service_started message-broker: condition: service_healthy + telemetry-broker: + condition: service_started schedule-engine: build: diff --git a/compose.prod.yml b/compose.prod.yml index 2bc465f..af04a79 100644 --- a/compose.prod.yml +++ b/compose.prod.yml @@ -24,7 +24,7 @@ services: dockerfile: Docker/Dockerfile target: prod entrypoint: ["/app/Docker/docker-entrypoint.sh"] - command: ["uv", "run", "celery", "-A", "realtime", "worker", "-l", "info"] + command: ["uv", "run", "celery", "-A", "databus", "worker", "-l", "info"] env_file: - ../.env - ../.env.prod @@ -40,18 +40,7 @@ services: dockerfile: Docker/Dockerfile target: prod entrypoint: ["/app/Docker/docker-entrypoint.sh"] - command: - [ - "uv", - "run", - "celery", - "-A", - "realtime", - "beat", - "--scheduler", - "django_celery_beat.schedulers:DatabaseScheduler", - "--loglevel=info", - ] + command: ["uv", "run", "celery", "-A", "databus", "beat", "--loglevel=info"] env_file: - ../.env - ../.env.prod diff --git a/docs/content/processes/run-lifecycle.md b/docs/content/processes/run-lifecycle.md index f812d60..55c9ae3 100644 --- a/docs/content/processes/run-lifecycle.md +++ b/docs/content/processes/run-lifecycle.md @@ -37,7 +37,7 @@ What happens inside a request-response cycle has to be synchronous - response: `run_lifecycle_state = INITIALIZED` - `POST api/update-run` - request: `event: RUN_CONFIRMED` - - response: `run_lifecycle_state = TRACKING` + - response: `run_lifecycle_state = CONFIRMED` - request: `event: RUN_COMPLETED` - response: `run_lifecycle_state = COMPLETED` - request: `event: RUN_INTERRUPTED` diff --git a/docs/uv.lock b/docs/uv.lock deleted file mode 100644 index 1d5339b..0000000 --- a/docs/uv.lock +++ /dev/null @@ -1,133 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.14" - -[[package]] -name = "click" -version = "8.3.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, -] - -[[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" } -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" }, -] - -[[package]] -name = "deepmerge" -version = "2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a8/3a/b0ba594708f1ad0bc735884b3ad854d3ca3bdc1d741e56e40bbda6263499/deepmerge-2.0.tar.gz", hash = "sha256:5c3d86081fbebd04dd5de03626a0607b809a98fb6ccba5770b62466fe940ff20", size = 19890, upload-time = "2024-08-30T05:31:50.308Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475, upload-time = "2024-08-30T05:31:48.659Z" }, -] - -[[package]] -name = "docs" -version = "0.1.0" -source = { virtual = "." } - -[package.dev-dependencies] -dev = [ - { name = "zensical" }, -] - -[package.metadata] - -[package.metadata.requires-dev] -dev = [{ name = "zensical", specifier = ">=0.0.33" }] - -[[package]] -name = "markdown" -version = "3.10.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, -] - -[[package]] -name = "pygments" -version = "2.20.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, -] - -[[package]] -name = "pymdown-extensions" -version = "10.21.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown" }, - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, -] - -[[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/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, -] - -[[package]] -name = "zensical" -version = "0.0.33" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "deepmerge" }, - { name = "markdown" }, - { name = "pygments" }, - { name = "pymdown-extensions" }, - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/59/c2/dea4b86dc1ca2a7b55414017f12cfb12b5cfdf3a1ed7c77a04c271eb523b/zensical-0.0.33.tar.gz", hash = "sha256:05209cb4f80185c533e0d37c25d084ddc2050e3d5a4dd1b1812961c2ee0c3380", size = 3892278, upload-time = "2026-04-14T11:08:19.895Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/74/5f/45d5200405420a9d8ac91cf9e7826622ea12f3198e8e6ac4ffb481eb53bf/zensical-0.0.33-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f658e3c241cfbb560bd8811116a9486cff7e04d7d5aed73569dd533c74187450", size = 12416748, upload-time = "2026-04-14T11:07:43.246Z" }, - { url = "https://files.pythonhosted.org/packages/33/1e/aadaf31d6e4d20419ecedaf0b1c804e359ec23dcdb44c8d2bf6d8407080c/zensical-0.0.33-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:f9813ac3256c28e2e2f1ba5c9fab1b4bca62bbe0e0f8e85ac22d33b068b1b08a", size = 12293372, upload-time = "2026-04-14T11:07:46.569Z" }, - { url = "https://files.pythonhosted.org/packages/db/e5/838be8451ea8b2aecec39fbec3971060fc705e17f5741249740d9b6a6824/zensical-0.0.33-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3bad7ac71028769c5d1f3f84f448dbb7352db28d77095d1b40a8d1b0aa34ec30", size = 12659832, upload-time = "2026-04-14T11:07:50.754Z" }, - { url = "https://files.pythonhosted.org/packages/1e/5c/dd957d7c83efc13a70a6058d4190a3afcf29942aefb391120bca5466347d/zensical-0.0.33-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:06bb039daf044547c9400a52f9493b3cd486ba9baef3324fdcffd2e26e61105f", size = 12603847, upload-time = "2026-04-14T11:07:53.698Z" }, - { url = "https://files.pythonhosted.org/packages/b7/99/dd6ccc392ece1f34fb20ea339a01717badbbeb2fba1d4f3019a5028d0bcc/zensical-0.0.33-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:260238062b3139ece0edab93f4dbe7a12923453091f5aa580dfd73e799388076", size = 12956236, upload-time = "2026-04-14T11:07:56.728Z" }, - { url = "https://files.pythonhosted.org/packages/f4/76/e0a1b884eadf6afa7e2d56c90c268eec36836ac27e96ef250c0129e55417/zensical-0.0.33-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7dff0f4afda7b8586bc4ab2a5684bce5b282232dd4e0cad3be4c73fedd264425", size = 12701944, upload-time = "2026-04-14T11:07:59.928Z" }, - { url = "https://files.pythonhosted.org/packages/38/38/e1ff13461e406864fa2b23fc828822659a7dbac5c79398f724d17f088540/zensical-0.0.33-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:207b4d81b208d75b97dc7bd318804550b886a3e852ef67429ef0e6b9442839d1", size = 12835444, upload-time = "2026-04-14T11:08:02.998Z" }, - { url = "https://files.pythonhosted.org/packages/41/04/7d24d52d6903fc5c511633afe8b5716fef19da09685327665cc127f61648/zensical-0.0.33-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:06d2f57f7bc8cc8fd904386020ea1365eebc411e8698a871e9525c885abca574", size = 12878419, upload-time = "2026-04-14T11:08:06.054Z" }, - { url = "https://files.pythonhosted.org/packages/9a/ec/87fc9e360c694ab006363c7834639eccafd0d26a487cd63dd609bd68f36a/zensical-0.0.33-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:c2851b82d83aa0b2ae4f8e99731cfeedeecebfa04e6b3fc4d375deca629fa240", size = 13022474, upload-time = "2026-04-14T11:08:09.007Z" }, - { url = "https://files.pythonhosted.org/packages/10/b3/0bf174ab6ceedb31d9af462073b5339c894b2084a27d42cb9f0906050d76/zensical-0.0.33-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:90daaf512b0429d7b9147ad5e6085b455d24803eff18b508aed738ca65444683", size = 12975233, upload-time = "2026-04-14T11:08:12.535Z" }, - { url = "https://files.pythonhosted.org/packages/a9/27/7cc3c2d284698647f60f3b823e0101e619c87edf158d47ee11bf4bfb6228/zensical-0.0.33-cp310-abi3-win32.whl", hash = "sha256:2701820597fe19361a12371129927c58c19633dcaa5f6986d610dce58cecd8c4", size = 12012664, upload-time = "2026-04-14T11:08:14.977Z" }, - { url = "https://files.pythonhosted.org/packages/25/0b/6be5c2fdaf9f1600577e7ba5e235d86b72a26f6af389efb146f978f76ac3/zensical-0.0.33-cp310-abi3-win_amd64.whl", hash = "sha256:a5a0911b4247708a55951b74c459f4d5faec5daaf287d23a2e1f0d96be1e647f", size = 12206255, upload-time = "2026-04-14T11:08:17.375Z" }, -] From fa54280d96bd8a0883489e04e18069e8f012c7cf Mon Sep 17 00:00:00 2001 From: Jae Date: Thu, 21 May 2026 03:01:01 -0600 Subject: [PATCH 04/23] fix(schedule_engine): handle dict-shaped run/progression in fake stop times --- backend/schedule_engine/fake_stop_times.py | 122 ++++++++++++++------- 1 file changed, 82 insertions(+), 40 deletions(-) diff --git a/backend/schedule_engine/fake_stop_times.py b/backend/schedule_engine/fake_stop_times.py index 269239d..fa26369 100644 --- a/backend/schedule_engine/fake_stop_times.py +++ b/backend/schedule_engine/fake_stop_times.py @@ -1,16 +1,20 @@ # For the _fake_stop_times method (temporary!) -from datetime import datetime, timedelta -import pandas as pd -import numpy as np +import logging import random +from datetime import datetime, timedelta +from pathlib import Path from typing import Any -_CSV_FILE_PATH = "./schedule_engine/aux_files/route_stops.csv" +import numpy as np +import pandas as pd + +logger = logging.getLogger(__name__) + +_CSV_FILE_PATH = Path(__file__).resolve().parent / "aux_files" / "route_stops.csv" # Time in seconds _UNCERTAINTY_S = 120 _TIME_OFFSET_MIN_S = 150 _TIME_OFFSET_MAX_S = 300 -_DEPARTURE_OFFSET_MAX_S = 120 _ARRIVAL_MAX_MIN = 5 @@ -40,69 +44,100 @@ def _generate_stop_entry( Returns: dict[str, Any]: A dictionary entry with stop time updates. """ - departure_time = arrival_time + timedelta( - seconds=random.randint(0, _DEPARTURE_OFFSET_MAX_S) - ) return { - "arrival": {"time": int(arrival_time.timestamp()), "uncertainty": uncertainty}, - "departure": { - "time": int(departure_time.timestamp()), - "uncertainty": uncertainty, - }, - "stop_id": stop_id, - "stop_sequence": stop_sequence, + "stop_sequence": int(stop_sequence), + "stop_id": str(stop_id), + "eta_posix": int(arrival_time.timestamp()), + "uncertainty": uncertainty, } +def _safe_int(value, default: int = -1) -> int: + """Coerce a Redis-string value to int, returning ``default`` on failure.""" + try: + return int(value) + except (TypeError, ValueError): + return default + + def build_stop_time_updates(run, progression) -> list[dict[str, Any]]: """Generate fake stop times for the given run. Parameters: - run: - progression: An object containing current stop sequence and status. + run: Mapping with at least ``route_id`` and ``shape_id`` keys + (typically a Redis hash dict). + progression: Mapping with ``current_stop_sequence`` and + ``current_status`` keys (typically a Redis hash dict). Returns: list[dict[str, Any]]: A list of dictionaries with stop time updates. - - Revisar en Progression por cuál parada está el viaje, y devolver los tiempos de llegada a las siguientes paradas, con la siguiente aproximación: 3 minutos de intervalo entre cada parada. - - Ejemplos: - - current_stop_sequence = 5, current_status = "IN_TRANSIT_TO": Devolver los tiempos de llegada a las paradas 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 (última). - - current_stop_sequence = 5, current_status = "INCOMING_AT": Devolver los tiempos de llegada a las paradas 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 (última). - - current_stop_sequence = 5, current_status = "STOPPED_AT": Devolver los tiempos de llegada a las paradas 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 (última). """ stop_time_update: list[dict[str, Any]] = [] - route_stops = _load_route_stops(csv_file_path=_CSV_FILE_PATH) + run = run or {} + progression = progression or {} + + route_id = str(run.get("route_id") or "").strip() + shape_id = str(run.get("shape_id") or "").strip() + if not route_id: + logger.warning("build_stop_time_updates: run missing route_id (run=%s)", run) + return stop_time_update + + try: + route_stops = _load_route_stops(csv_file_path=_CSV_FILE_PATH) + except FileNotFoundError: + logger.exception("Route stops CSV not found at %s", _CSV_FILE_PATH) + return stop_time_update + + # Primary match: route_id AND shape_id filtered_stops = route_stops[ - (route_stops["route_id"] == run.route_id) - & (route_stops["shape_id"] == run.shape_id) + (route_stops["route_id"].astype(str) == route_id) + & (route_stops["shape_id"].astype(str) == shape_id) ] + # Fallback: match on route_id only (if shape_id is unknown or unmapped) + if filtered_stops.empty: + logger.info( + "No CSV rows for route_id=%r shape_id=%r — falling back to route_id only", + route_id, + shape_id, + ) + filtered_stops = route_stops[ + route_stops["route_id"].astype(str) == route_id + ] + if filtered_stops.empty: + logger.warning( + "build_stop_time_updates: no stops for route_id=%r (CSV has routes=%s)", + route_id, + sorted(route_stops["route_id"].astype(str).unique().tolist()), + ) return stop_time_update - # Start with an invalid value to ensure the first comparison is always true - previous_stop_sequence = -1 + # Ensure ascending order so we walk stops in sequence + filtered_stops = filtered_stops.sort_values("stop_sequence") + + current_stop_sequence = _safe_int( + progression.get("current_stop_sequence"), default=-1 + ) + current_status = (progression.get("current_status") or "").upper() + arrival_time = datetime.now() + timedelta( minutes=random.randint(0, _ARRIVAL_MAX_MIN) ) for _, row in filtered_stops.iterrows(): - stop_sequence = row["stop_sequence"] + stop_sequence = int(row["stop_sequence"]) - if stop_sequence < progression.current_stop_sequence: + # Skip stops the vehicle has already passed + if stop_sequence < current_stop_sequence: continue - if stop_sequence < previous_stop_sequence: - # Modify the last entry to remove "departure" - if stop_time_update: - stop_time_update[-1].pop("departure", None) - break - + # If the bus is currently stopped at this sequence, the next ETA is + # the following stop, so skip the current one. if ( - progression.current_status == "STOPPED_AT" - and stop_sequence == progression.current_stop_sequence + current_status == "STOPPED_AT" + and stop_sequence == current_stop_sequence ): continue @@ -113,9 +148,16 @@ def build_stop_time_updates(run, progression) -> list[dict[str, Any]]: uncertainty=_UNCERTAINTY_S, ) stop_time_update.append(stop_entry) - previous_stop_sequence = stop_sequence arrival_time += timedelta( seconds=random.randint(_TIME_OFFSET_MIN_S, _TIME_OFFSET_MAX_S) ) + logger.debug( + "build_stop_time_updates: route_id=%s shape_id=%s current_seq=%s status=%s -> %d stops", + route_id, + shape_id, + current_stop_sequence, + current_status, + len(stop_time_update), + ) return stop_time_update From cacf60e4ea60122a80723812378495a0b34e93d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A1n=20Abarca=20Calder=C3=B3n?= Date: Mon, 25 May 2026 13:52:07 -0300 Subject: [PATCH 05/23] refactor(run): rename UpdateRunSerializer to RunUpdateSerializer and update related views and URLs feat(run): add RunStateViewSet for retrieving current run lifecycle state docs: add README for run lifecycle states refactor(redis): change vehicle data key from 'data' to 'metadata' across scripts and tasks --- backend/api/serializers.py | 3 +- backend/api/urls.py | 15 +++++++-- backend/api/views.py | 56 ++++++++++++++++++++++++++------ backend/realtime_engine/mqtt.py | 39 ++++++++++------------ backend/runs/README.md | 19 +++++++++++ backend/runs/domain/actions.py | 3 +- backend/runs/domain/guards.py | 54 ++++++++++++++++++++++-------- backend/schedule_engine/tasks.py | 50 +++++++++++++++------------- scripts/cleanup_redis.py | 4 +-- scripts/inspect_redis.py | 6 ++-- 10 files changed, 171 insertions(+), 78 deletions(-) create mode 100644 backend/runs/README.md diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 983bbf8..5799e8f 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -130,8 +130,7 @@ class CreateRunSerializer(serializers.Serializer): ) -class UpdateRunSerializer(serializers.Serializer): - run_id = serializers.CharField(max_length=100) +class RunUpdateSerializer(serializers.Serializer): event = serializers.ChoiceField(choices=RunLifecycleEvents) details = serializers.JSONField(required=False, default=dict) diff --git a/backend/api/urls.py b/backend/api/urls.py index e61a23f..737f641 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -39,8 +39,19 @@ path("login/", views.LoginView.as_view(), name="login"), # path("route-stops/", views.RouteStopView.as_view(), name="route_stops"), path("create-run/", views.CreateRunViewSet.as_view(), name="create_run"), - path("update-run/", views.UpdateRunViewSet.as_view(), name="update_run"), - path("runs//history/", views.RunHistoryView.as_view(), name="run_history"), + path( + "runs//state/", views.RunStateViewSet.as_view(), name="run_state" + ), + path( + "runs//update/", + views.RunUpdateViewSet.as_view(), + name="run_update", + ), + path( + "runs//history/", + views.RunHistoryView.as_view(), + name="run_history", + ), path("service-today/", views.ServiceTodayView.as_view(), name="service_today"), path("which-shapes/", views.WhichShapesView.as_view(), name="which_shapes"), path("find-trips/", views.FindTripsView.as_view(), name="find_trips"), diff --git a/backend/api/views.py b/backend/api/views.py index 1475d58..66e2847 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -56,7 +56,7 @@ EquipmentLogSerializer, OperatorSerializer, CreateRunSerializer, - UpdateRunSerializer, + RunUpdateSerializer, PositionSerializer, ProgressionSerializer, OccupancySerializer, @@ -251,16 +251,36 @@ def post(self, request): ) -class UpdateRunViewSet(APIView): +class RunStateViewSet(APIView): + """ + Endpoint to get the current lifecycle state of a run. + + It only allows the GET method with the run_id as path parameter. + """ + + def get(self, request, run_id): + run = Run.objects.filter(id=run_id).first() + if not run: + return Response( + {"status": "error", "errors": {"run_id": "Run not found"}}, + status=status.HTTP_404_NOT_FOUND, + ) + return Response( + {"status": "success", "run_lifecycle_state": run.run_lifecycle_state}, + status=status.HTTP_200_OK, + ) + + +class RunUpdateViewSet(APIView): """ Endpoint to request an update of the lifecycle state of an existing run. It only allows the POST method with the event to process. """ - def post(self, request): + def post(self, request, run_id): service = RunLifecycleService() - serializer = UpdateRunSerializer(data=request.data) + serializer = RunUpdateSerializer(data=request.data) if not serializer.is_valid(): return Response( {"status": "error", "errors": serializer.errors}, @@ -273,16 +293,34 @@ def post(self, request): details = payload.pop("details", {}) or {} if isinstance(details, dict): payload.update(details) - run_id = payload.get("run_id") + payload["run_id"] = run_id run = Run.objects.filter(id=run_id).first() if not run: return Response( {"status": "error", "errors": {"run_id": "Run not found"}}, status=status.HTTP_404_NOT_FOUND, ) + # Ensure the effective event (after payload normalization) is valid. event = payload.get("event") + event_value = ( + event.value if isinstance(event, RunLifecycleEvents) else str(event) + ) + allowed_events = {e.value for e in RunLifecycleEvents} + if event_value not in allowed_events: + return Response( + { + "status": "error", + "errors": { + "event": f"Invalid event '{event_value}'. Allowed values: {sorted(allowed_events)}" + }, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + payload["event"] = event_value try: - new_run_lifecycle_state, _guards, _actions = service.process_event(event, payload) + new_run_lifecycle_state, _guards, _actions = service.process_event( + event_value, payload + ) except RunLifecycleError as e: return Response( {"status": "error", "errors": e.errors}, @@ -309,10 +347,8 @@ def get(self, request, run_id): {"status": "error", "errors": {"detail": f"run {run_id} not found"}}, status=status.HTTP_404_NOT_FOUND, ) - transitions = ( - RunLifecycleTransition.objects - .filter(run_id=run_id) - .order_by("timestamp", "created_at") + transitions = RunLifecycleTransition.objects.filter(run_id=run_id).order_by( + "timestamp", "created_at" ) return Response( { diff --git a/backend/realtime_engine/mqtt.py b/backend/realtime_engine/mqtt.py index 70434e0..2d7e78f 100644 --- a/backend/realtime_engine/mqtt.py +++ b/backend/realtime_engine/mqtt.py @@ -9,6 +9,7 @@ Topic pattern: ``transit/vehicle//{position,progression,occupancy}``. """ + import json import logging import os @@ -19,6 +20,8 @@ from celery import bootsteps from django.utils.timezone import now +from runs.domain.states import RunLifecycleState + logger = logging.getLogger(__name__) MQTT_HOST = os.getenv("MQTT_HOST", "telemetry-broker") @@ -29,7 +32,7 @@ "yes", ) -_redis = redis.Redis( +r = redis.Redis( host=os.getenv("REDIS_HOST", "state"), port=int(os.getenv("REDIS_PORT", "6379")), db=0, @@ -57,18 +60,18 @@ def _handle_telemetry(vehicle_id: str, leaf: str, payload_bytes: bytes) -> None: logger.warning("Non-JSON payload on vehicle %s/%s — ignored", vehicle_id, leaf) return - run_id = _redis.get(f"vehicle:{vehicle_id}:current_run") + run_id = r.get(f"vehicle:{vehicle_id}:current_run") if not run_id: logger.debug("No active run for vehicle %s — dropping %s", vehicle_id, leaf) return if isinstance(data, dict): - _redis.hset( + r.hset( f"vehicle:{vehicle_id}:{leaf}", mapping={k: str(v) for k, v in data.items()}, ) - _redis.set(f"runs:last_seen:{run_id}", now().isoformat()) + r.set(f"runs:last_seen:{run_id}", now().isoformat()) _maybe_fire_lifecycle_event(run_id, vehicle_id, leaf, data) @@ -77,7 +80,7 @@ def _maybe_fire_lifecycle_event( ) -> None: from realtime_engine.tasks import run_lifecycle_event - run_state = _redis.hget(f"run:{run_id}", "run_lifecycle_state") + run_state = r.hget(f"run:{run_id}", "run_lifecycle_state") if not run_state: return @@ -88,26 +91,24 @@ def _maybe_fire_lifecycle_event( **data, } - if run_state == "Confirmed": - _redis.sadd("runs:tracking", run_id) + if run_state == RunLifecycleState.CONFIRMED: + r.sadd("runs:tracking", run_id) run_lifecycle_event.delay("run_tracking_started", payload) - elif run_state == "Tracking" and leaf == "position": + elif run_state == RunLifecycleState.TRACKING and leaf == "position": speed = float(data.get("speed", 0)) if speed > 0.5: run_lifecycle_event.delay("run_started", payload) - elif run_state == "No Signal": - _redis.sadd("runs:tracking", run_id) + elif run_state == RunLifecycleState.NO_SIGNAL: + r.sadd("runs:tracking", run_id) run_lifecycle_event.delay("run_tracking_restored", payload) - elif run_state == "In Progress" and leaf == "progression": + elif run_state == RunLifecycleState.IN_PROGRESS and leaf == "progression": current_status = data.get("current_status", "") stop_id = data.get("stop_id", "") if current_status == "STOPPED_AT" and stop_id: - run_lifecycle_event.delay( - "complete_run", {**payload, "stop_id": stop_id} - ) + run_lifecycle_event.delay("complete_run", {**payload, "stop_id": stop_id}) def _on_connect(client: mqtt.Client, userdata, flags, rc) -> None: @@ -128,9 +129,7 @@ def _on_message(client: mqtt.Client, userdata, msg: mqtt.MQTTMessage) -> None: try: _handle_telemetry(vehicle_id, leaf, msg.payload) except Exception: - logger.exception( - "Telemetry handling failed for %s/%s", vehicle_id, leaf - ) + logger.exception("Telemetry handling failed for %s/%s", vehicle_id, leaf) def build_client() -> mqtt.Client: @@ -144,7 +143,7 @@ def build_client() -> mqtt.Client: class MQTTConsumerStep(bootsteps.StartStopStep): """Celery worker bootstep that runs the MQTT subscriber in-process. - paho's ``loop_start()`` spawns its own background thread and handles + paho's `loop_start()` spawns its own background thread and handles reconnects internally, so the bootstep only orchestrates lifecycle: start on worker boot, stop on worker shutdown. """ @@ -162,9 +161,7 @@ def start(self, worker) -> None: os.getenv("MQTT_CONSUMER_ENABLED", ""), ) return - logger.info( - "Starting MQTT consumer bootstep (%s:%s)", MQTT_HOST, MQTT_PORT - ) + logger.info("Starting MQTT consumer bootstep (%s:%s)", MQTT_HOST, MQTT_PORT) client = build_client() try: client.connect_async(MQTT_HOST, MQTT_PORT, keepalive=60) diff --git a/backend/runs/README.md b/backend/runs/README.md new file mode 100644 index 0000000..9417d87 --- /dev/null +++ b/backend/runs/README.md @@ -0,0 +1,19 @@ +# Run + +## Run lifecycle states + +- `RUN_REQUESTED` = a "POST /create-run" API call request happened (an implicit run request) +- `VALIDATE_RUN` = apply the transition guards to check GTFS consistency +- `INITIALIZE_RUN` = execute actions to update the system state +- `RUN_CONFIRMED_BY_OPERATOR` = the operator (driver, dispatcher) re-confirmed the run +- `RUN_TRACKING_STARTED` = GPS pings are detected and valid +- `RUN_STARTED` = the run actually started (vehicle is moving along a valid path) +- `COMPLETE_RUN` = manual or automatic request to complete a successful run (e.g. vehicle reached the end of the route or the run was completed by the operator) + +- `RUN_REJECTED` = validation or initialization failed +- `CANCEL_RUN` = a cancellation request by the operator (driver, administrator, dispatcher) or the system before it started +- `INTERRUPT_RUN` = a manual or automatic request to interrupt the run after it started, either by the operator or the system (possible activation of an alert!) +- `SHORT_TURN_RUN` = a manual request to short-turn the run +- `RUN_TRACKING_LOST` = the run tracking was lost (automatic, async) +- `RUN_TRACKING_RESTORED` = the run tracking was restored (automatic, async) +- `RUN_TRACKING_EXPIRED` = the run tracking expired (e.g. no telemetry for a long time) (automatic, async) diff --git a/backend/runs/domain/actions.py b/backend/runs/domain/actions.py index 1cc79e1..b60ebda 100644 --- a/backend/runs/domain/actions.py +++ b/backend/runs/domain/actions.py @@ -66,6 +66,7 @@ def update_system_state( # Write vehicle metadata so the GTFS-RT builders can populate VehicleDescriptor if vehicle_id: from operations.models import Vehicle as VehicleModel + try: v = VehicleModel.objects.get(id=vehicle_id) vehicle_meta: dict[str, str] = { @@ -76,7 +77,7 @@ def update_system_state( vehicle_meta["license_plate"] = v.license_plate if v.wheelchair_accessible: vehicle_meta["wheelchair_accessible"] = v.wheelchair_accessible - pipe.hset(f"vehicle:{vehicle_id}:data", mapping=vehicle_meta) + pipe.hset(f"vehicle:{vehicle_id}:metadata", mapping=vehicle_meta) except Exception: pass diff --git a/backend/runs/domain/guards.py b/backend/runs/domain/guards.py index bb798c7..a628fc1 100644 --- a/backend/runs/domain/guards.py +++ b/backend/runs/domain/guards.py @@ -11,7 +11,7 @@ r = redis.Redis(host="state", port=6379, db=0) TELEMETRY_GRACE_S = 60 -TELEMETRY_EXPIRY_S = 300 +TELEMETRY_EXPIRY_S = 600 def _parse_last_seen(payload: dict[str, Any]) -> datetime | None: @@ -49,7 +49,9 @@ def is_gtfs_valid( if not trip_id: errors["trip_id"] = "trip_id is required" if direction_id not in [0, 1]: - errors["direction_id"] = f"direction_id must be 0 or 1, got '{direction_id}'" + errors["direction_id"] = ( + f"direction_id must be 0 or 1, got '{direction_id}'" + ) if not shape_id: errors["shape_id"] = "shape_id is required" if not schedule_relationship: @@ -104,7 +106,9 @@ def is_vehicle_available( existing = r.get(f"vehicle:{vehicle_id}:current_run") if existing and existing.decode() != str(run.id): raise RunLifecycleError( - {"vehicle_id": f"Vehicle '{vehicle_id}' is already assigned to run {existing.decode()}"} + { + "vehicle_id": f"Vehicle '{vehicle_id}' is already assigned to run {existing.decode()}" + } ) return True @@ -118,7 +122,9 @@ def is_trip_available( existing = r.get(f"trip:{trip_id}:current_run") if existing and existing.decode() != str(run.id): raise RunLifecycleError( - {"trip_id": f"Trip '{trip_id}' is already assigned to run {existing.decode()}"} + { + "trip_id": f"Trip '{trip_id}' is already assigned to run {existing.decode()}" + } ) return True @@ -134,7 +140,9 @@ def is_operator_available( existing = r.get(f"operator:{operator_id}:current_run") if existing and existing.decode() != str(run.id): raise RunLifecycleError( - {"operator_id": f"Operator '{operator_id}' is already assigned to run {existing.decode()}"} + { + "operator_id": f"Operator '{operator_id}' is already assigned to run {existing.decode()}" + } ) return True @@ -170,7 +178,9 @@ def is_cancellation_authorized( if actor_role in ("dispatcher", "operator"): return True raise RunLifecycleError( - {"actor_role": f"actor_role '{actor_role}' is not authorized to cancel runs"} + { + "actor_role": f"actor_role '{actor_role}' is not authorized to cancel runs" + } ) @staticmethod @@ -181,7 +191,9 @@ def is_interruption_authorized( if actor_role in ("system", "dispatcher", "operator"): return True raise RunLifecycleError( - {"actor_role": f"actor_role '{actor_role}' is not authorized to interrupt runs"} + { + "actor_role": f"actor_role '{actor_role}' is not authorized to interrupt runs" + } ) @staticmethod @@ -192,7 +204,9 @@ def is_short_turn_authorized( if actor_role in ("dispatcher", "system"): return True raise RunLifecycleError( - {"actor_role": f"actor_role '{actor_role}' must be 'dispatcher' or 'system' to short-turn"} + { + "actor_role": f"actor_role '{actor_role}' must be 'dispatcher' or 'system' to short-turn" + } ) @staticmethod @@ -203,7 +217,9 @@ def is_short_turn_geometrically_valid( short_turn_stop_id = payload.get("short_turn_stop_id") if not short_turn_stop_id: - raise RunLifecycleError({"short_turn_stop_id": "short_turn_stop_id is required"}) + raise RunLifecycleError( + {"short_turn_stop_id": "short_turn_stop_id is required"} + ) trip_id = run.trip_id if not trip_id: @@ -213,16 +229,22 @@ def is_short_turn_geometrically_valid( if not feed: raise RunLifecycleError({"feed": "No current GTFS feed found"}) - stop_times = StopTime.objects.filter(feed=feed, trip_id=trip_id).order_by("stop_sequence") + stop_times = StopTime.objects.filter(feed=feed, trip_id=trip_id).order_by( + "stop_sequence" + ) if not stop_times.exists(): - raise RunLifecycleError({"trip_id": f"No stop times found for trip '{trip_id}'"}) + raise RunLifecycleError( + {"trip_id": f"No stop times found for trip '{trip_id}'"} + ) terminal = stop_times.last() stop_ids = list(stop_times.values_list("stop_id", flat=True)) if short_turn_stop_id not in stop_ids: raise RunLifecycleError( - {"short_turn_stop_id": f"Stop '{short_turn_stop_id}' not on trip '{trip_id}'"} + { + "short_turn_stop_id": f"Stop '{short_turn_stop_id}' not on trip '{trip_id}'" + } ) if short_turn_stop_id == terminal.stop_id: raise RunLifecycleError( @@ -298,11 +320,15 @@ def is_at_terminal_stop( .first() ) if not terminal: - raise RunLifecycleError({"trip_id": f"No stop times found for trip '{trip_id}'"}) + raise RunLifecycleError( + {"trip_id": f"No stop times found for trip '{trip_id}'"} + ) if stop_id != terminal.stop_id: raise RunLifecycleError( - {"stop_id": f"Stop '{stop_id}' is not the terminal stop '{terminal.stop_id}'"} + { + "stop_id": f"Stop '{stop_id}' is not the terminal stop '{terminal.stop_id}'" + } ) return True diff --git a/backend/schedule_engine/tasks.py b/backend/schedule_engine/tasks.py index d24bf8c..eeecb96 100644 --- a/backend/schedule_engine/tasks.py +++ b/backend/schedule_engine/tasks.py @@ -65,7 +65,7 @@ def build_vehicle_positions(): position = r.hgetall(f"vehicle:{vehicle_id}:position") progression = r.hgetall(f"vehicle:{vehicle_id}:progression") occupancy = r.hgetall(f"vehicle:{vehicle_id}:occupancy") - vehicle_meta = r.hgetall(f"vehicle:{vehicle_id}:data") + vehicle_meta = r.hgetall(f"vehicle:{vehicle_id}:metadata") if not position and not progression and not occupancy: continue @@ -103,7 +103,9 @@ def build_vehicle_positions(): if vehicle_meta.get("license_plate"): v["vehicle"]["license_plate"] = vehicle_meta["license_plate"] if vehicle_meta.get("wheelchair_accessible"): - v["vehicle"]["wheelchair_accessible"] = vehicle_meta["wheelchair_accessible"] + v["vehicle"]["wheelchair_accessible"] = vehicle_meta[ + "wheelchair_accessible" + ] if position: try: @@ -124,7 +126,9 @@ def build_vehicle_positions(): if progression: if progression.get("current_stop_sequence"): try: - v["current_stop_sequence"] = int(progression["current_stop_sequence"]) + v["current_stop_sequence"] = int( + progression["current_stop_sequence"] + ) except (ValueError, TypeError): pass if progression.get("stop_id"): @@ -185,7 +189,7 @@ def build_trip_updates(): position = r.hgetall(f"vehicle:{vehicle_id}:position") progression = r.hgetall(f"vehicle:{vehicle_id}:progression") - vehicle_meta = r.hgetall(f"vehicle:{vehicle_id}:data") + metadata = r.hgetall(f"vehicle:{vehicle_id}:metadata") if not position and not progression: continue @@ -217,29 +221,29 @@ def build_trip_updates(): tu["trip"]["start_date"] = run["start_date"] tu["vehicle"] = { - "id": vehicle_meta.get("id", vehicle_id), - "label": vehicle_meta.get("label", vehicle_id), + "id": metadata.get("id", vehicle_id), + "label": metadata.get("label", vehicle_id), } - if vehicle_meta.get("license_plate"): - tu["vehicle"]["license_plate"] = vehicle_meta["license_plate"] + if metadata.get("license_plate"): + tu["vehicle"]["license_plate"] = metadata["license_plate"] - stop_time_updates = build_stop_time_updates( - run=run, progression=progression - ) + stop_time_updates = build_stop_time_updates(run=run, progression=progression) tu["stop_time_update"] = [] for update in stop_time_updates: - tu["stop_time_update"].append({ - "stop_sequence": update["stop_sequence"], - "stop_id": update["stop_id"], - "arrival": { - "time": update["eta_posix"], - "uncertainty": update["uncertainty"], - }, - "departure": { - "time": update["eta_posix"], - "uncertainty": update["uncertainty"], - }, - }) + tu["stop_time_update"].append( + { + "stop_sequence": update["stop_sequence"], + "stop_id": update["stop_id"], + "arrival": { + "time": update["eta_posix"], + "uncertainty": update["uncertainty"], + }, + "departure": { + "time": update["eta_posix"], + "uncertainty": update["uncertainty"], + }, + } + ) feed_message["entity"].append(entity) diff --git a/scripts/cleanup_redis.py b/scripts/cleanup_redis.py index e34800a..e54c43d 100755 --- a/scripts/cleanup_redis.py +++ b/scripts/cleanup_redis.py @@ -129,7 +129,7 @@ def delete_vehicle_data(r: redis.Redis, vehicle_id: str, dry_run: bool = False) """ # Keys to delete keys_to_delete = [ - f"vehicle:{vehicle_id}:data", + f"vehicle:{vehicle_id}:metadata", f"vehicle:{vehicle_id}:position", f"vehicle:{vehicle_id}:progression", f"vehicle:{vehicle_id}:occupancy", @@ -204,7 +204,7 @@ def force_cleanup_all(r: redis.Redis, dry_run: bool = False) -> int: # Get all vehicle-related keys patterns = [ - "vehicle:*:data", + "vehicle:*:metadata", "vehicle:*:position", "vehicle:*:progression", "vehicle:*:occupancy", diff --git a/scripts/inspect_redis.py b/scripts/inspect_redis.py index 3b2f705..1d61b52 100755 --- a/scripts/inspect_redis.py +++ b/scripts/inspect_redis.py @@ -124,7 +124,7 @@ def inspect_vehicles(r: redis.Redis, show_age: bool = False) -> None: # Get all vehicle keys vehicle_keys = set() - for key in r.keys("vehicle:*:data"): + for key in r.keys("vehicle:*:metadata"): vehicle_id = key.split(":")[1] vehicle_keys.add(vehicle_id) @@ -136,7 +136,7 @@ def inspect_vehicles(r: redis.Redis, show_age: bool = False) -> None: for vehicle_id in sorted(vehicle_keys): # Get vehicle data - vehicle_data = r.hgetall(f"vehicle:{vehicle_id}:data") + vehicle_data = r.hgetall(f"vehicle:{vehicle_id}:metadata") position = r.hgetall(f"vehicle:{vehicle_id}:position") progression = r.hgetall(f"vehicle:{vehicle_id}:progression") occupancy = r.hgetall(f"vehicle:{vehicle_id}:occupancy") @@ -260,7 +260,7 @@ def inspect_summary(r: redis.Redis, show_age: bool = False) -> None: runs_count = len(r.smembers("runs:in_progress")) # Count vehicles - vehicle_keys = len(r.keys("vehicle:*:data")) + vehicle_keys = len(r.keys("vehicle:*:metadata")) # Count vehicles with recent data recent_count = 0 From eec4e608d46e1828700bdfc28dfd2639d84757e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A1n=20Abarca=20Calder=C3=B3n?= Date: Mon, 25 May 2026 14:18:42 -0300 Subject: [PATCH 06/23] refactor(domain): consolidate imports from runs.domain for clarity and consistency --- backend/api/serializers.py | 2 +- backend/api/views.py | 4 ++-- backend/realtime_engine/mqtt.py | 10 ++++----- backend/realtime_engine/tasks.py | 8 ++++--- backend/runs/domain/__init__.py | 36 ++++++++++++++++++++++++++++++ backend/runs/domain/actions.py | 2 +- backend/runs/domain/guards.py | 2 +- backend/runs/models.py | 2 +- backend/runs/services/lifecycle.py | 6 ++--- backend/runs/services/registry.py | 2 +- 10 files changed, 56 insertions(+), 18 deletions(-) diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 5799e8f..544c2df 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -12,7 +12,7 @@ Progression, Occupancy, ) -from runs.domain.events import RunLifecycleEvents +from runs.domain import RunLifecycleEvents from feed.models import * from django.contrib.auth.models import User from rest_framework import serializers diff --git a/backend/api/views.py b/backend/api/views.py index 66e2847..1e10fab 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -14,8 +14,8 @@ from realtime_engine.tasks import run_lifecycle_event from runs.services.exceptions import RunLifecycleError from runs.services.lifecycle import RunLifecycleService -from runs.domain.events import RunLifecycleEvents -from runs.domain.states import RunLifecycleStates +from runs.domain import RunLifecycleEvents +from runs.domain import RunLifecycleStates from operations.models import ( Vehicle, Operator, diff --git a/backend/realtime_engine/mqtt.py b/backend/realtime_engine/mqtt.py index 2d7e78f..8a48d6c 100644 --- a/backend/realtime_engine/mqtt.py +++ b/backend/realtime_engine/mqtt.py @@ -20,7 +20,7 @@ from celery import bootsteps from django.utils.timezone import now -from runs.domain.states import RunLifecycleState +from runs.domain import RunLifecycleStates logger = logging.getLogger(__name__) @@ -91,20 +91,20 @@ def _maybe_fire_lifecycle_event( **data, } - if run_state == RunLifecycleState.CONFIRMED: + if run_state == RunLifecycleStates.CONFIRMED.value: r.sadd("runs:tracking", run_id) run_lifecycle_event.delay("run_tracking_started", payload) - elif run_state == RunLifecycleState.TRACKING and leaf == "position": + elif run_state == RunLifecycleStates.TRACKING.value and leaf == "position": speed = float(data.get("speed", 0)) if speed > 0.5: run_lifecycle_event.delay("run_started", payload) - elif run_state == RunLifecycleState.NO_SIGNAL: + elif run_state == RunLifecycleStates.NO_SIGNAL.value: r.sadd("runs:tracking", run_id) run_lifecycle_event.delay("run_tracking_restored", payload) - elif run_state == RunLifecycleState.IN_PROGRESS and leaf == "progression": + elif run_state == RunLifecycleStates.IN_PROGRESS.value and leaf == "progression": current_status = data.get("current_status", "") stop_id = data.get("stop_id", "") if current_status == "STOPPED_AT" and stop_id: diff --git a/backend/realtime_engine/tasks.py b/backend/realtime_engine/tasks.py index 2389c2b..a5f8042 100644 --- a/backend/realtime_engine/tasks.py +++ b/backend/realtime_engine/tasks.py @@ -8,7 +8,7 @@ from django.utils.timezone import now from runs.services.lifecycle import RunLifecycleService -from runs.domain.states import RunLifecycleStates +from runs.domain import RunLifecycleStates logger = logging.getLogger(__name__) @@ -25,7 +25,7 @@ @shared_task(queue="realtime_engine") def run_lifecycle_event(event: str, payload: dict[str, Any]) -> None: - from runs.domain.events import RunLifecycleEvents + from runs.domain import RunLifecycleEvents service = RunLifecycleService() try: @@ -36,7 +36,9 @@ def run_lifecycle_event(event: str, payload: dict[str, Any]) -> None: try: service.process_event(evt, payload) except Exception: - logger.exception("Lifecycle event %s failed for run %s", event, payload.get("run_id")) + logger.exception( + "Lifecycle event %s failed for run %s", event, payload.get("run_id") + ) @shared_task(queue="realtime_engine") diff --git a/backend/runs/domain/__init__.py b/backend/runs/domain/__init__.py index e69de29..88f3cf1 100644 --- a/backend/runs/domain/__init__.py +++ b/backend/runs/domain/__init__.py @@ -0,0 +1,36 @@ +from importlib import import_module + +from .states import RunLifecycleStates, choices +from .events import RunLifecycleEvents + +__all__ = [ + "RunLifecycleStates", + "choices", + "RunLifecycleEvents", + "RunLifecycleActions", + "RunLifecycleGuards", + "Transition", + "TRANSITIONS", +] + + +def __getattr__(name: str): + if name in { + "RunLifecycleActions", + "RunLifecycleGuards", + "Transition", + "TRANSITIONS", + }: + module_name = { + "RunLifecycleActions": "actions", + "RunLifecycleGuards": "guards", + "Transition": "transitions", + "TRANSITIONS": "transitions", + }[name] + module = import_module(f"{__name__}.{module_name}") + return getattr(module, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/backend/runs/domain/actions.py b/backend/runs/domain/actions.py index b60ebda..3a0f62d 100644 --- a/backend/runs/domain/actions.py +++ b/backend/runs/domain/actions.py @@ -3,7 +3,7 @@ import redis if TYPE_CHECKING: - from runs.domain.transitions import Transition + from runs.domain import Transition r = redis.Redis(host="state", port=6379, db=0) diff --git a/backend/runs/domain/guards.py b/backend/runs/domain/guards.py index a628fc1..7c9fdf7 100644 --- a/backend/runs/domain/guards.py +++ b/backend/runs/domain/guards.py @@ -6,7 +6,7 @@ import redis if TYPE_CHECKING: - from runs.domain.transitions import Transition + from runs.domain import Transition r = redis.Redis(host="state", port=6379, db=0) diff --git a/backend/runs/models.py b/backend/runs/models.py index 245c670..028028a 100644 --- a/backend/runs/models.py +++ b/backend/runs/models.py @@ -1,6 +1,6 @@ from django.contrib.gis.db import models from operations.models import Vehicle, Operator -from runs.domain.states import RunLifecycleStates, choices +from runs.domain import RunLifecycleStates, choices import uuid # Create your models here. diff --git a/backend/runs/services/lifecycle.py b/backend/runs/services/lifecycle.py index 11338a6..0ebef14 100644 --- a/backend/runs/services/lifecycle.py +++ b/backend/runs/services/lifecycle.py @@ -1,8 +1,8 @@ from typing import Any from django.utils.timezone import now -from runs.domain.events import RunLifecycleEvents -from runs.domain.states import RunLifecycleStates -from runs.domain.transitions import Transition +from runs.domain import RunLifecycleEvents +from runs.domain import RunLifecycleStates +from runs.domain import Transition from runs.services.registry import TransitionRegistry from runs.services.exceptions import RunLifecycleError from runs.models import Run, RunLifecycleTransition diff --git a/backend/runs/services/registry.py b/backend/runs/services/registry.py index eda451c..118a4d3 100644 --- a/backend/runs/services/registry.py +++ b/backend/runs/services/registry.py @@ -1,4 +1,4 @@ -from runs.domain.transitions import TRANSITIONS +from runs.domain import TRANSITIONS class TransitionRegistry: From 430edafb0df502a5db4a651b411e5d70ddcd8aa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A1n=20Abarca=20Calder=C3=B3n?= Date: Wed, 27 May 2026 15:52:35 -0300 Subject: [PATCH 07/23] refactor(domain): separate finite state machines in folders and add scaffolding for the detection logic --- backend/api/serializers.py | 2 +- backend/api/views.py | 4 +- backend/realtime_engine/mqtt.py | 2 +- backend/realtime_engine/tasks.py | 4 +- backend/runs/domain/__init__.py | 36 -- backend/runs/domain/detection/run_started.py | 13 + backend/runs/domain/lifecycle/__init__.py | 36 ++ .../runs/domain/{ => lifecycle}/actions.py | 2 +- backend/runs/domain/{ => lifecycle}/events.py | 0 backend/runs/domain/{ => lifecycle}/guards.py | 8 +- backend/runs/domain/{ => lifecycle}/states.py | 0 .../domain/{ => lifecycle}/transitions.py | 0 backend/runs/domain/progress/__init__.py | 36 ++ backend/runs/domain/progress/actions.py | 158 +++++++++ backend/runs/domain/progress/events.py | 24 ++ backend/runs/domain/progress/guards.py | 333 ++++++++++++++++++ backend/runs/domain/progress/states.py | 23 ++ backend/runs/domain/progress/transitions.py | 248 +++++++++++++ backend/runs/models.py | 2 +- backend/runs/services/lifecycle.py | 6 +- backend/runs/services/registry.py | 2 +- 21 files changed, 884 insertions(+), 55 deletions(-) create mode 100644 backend/runs/domain/detection/run_started.py create mode 100644 backend/runs/domain/lifecycle/__init__.py rename backend/runs/domain/{ => lifecycle}/actions.py (99%) rename backend/runs/domain/{ => lifecycle}/events.py (100%) rename backend/runs/domain/{ => lifecycle}/guards.py (98%) rename backend/runs/domain/{ => lifecycle}/states.py (100%) rename backend/runs/domain/{ => lifecycle}/transitions.py (100%) create mode 100644 backend/runs/domain/progress/__init__.py create mode 100644 backend/runs/domain/progress/actions.py create mode 100644 backend/runs/domain/progress/events.py create mode 100644 backend/runs/domain/progress/guards.py create mode 100644 backend/runs/domain/progress/states.py create mode 100644 backend/runs/domain/progress/transitions.py diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 544c2df..f08125a 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -12,7 +12,7 @@ Progression, Occupancy, ) -from runs.domain import RunLifecycleEvents +from runs.domain.lifecycle import RunLifecycleEvents from feed.models import * from django.contrib.auth.models import User from rest_framework import serializers diff --git a/backend/api/views.py b/backend/api/views.py index 1e10fab..bfa4c5a 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -14,8 +14,8 @@ from realtime_engine.tasks import run_lifecycle_event from runs.services.exceptions import RunLifecycleError from runs.services.lifecycle import RunLifecycleService -from runs.domain import RunLifecycleEvents -from runs.domain import RunLifecycleStates +from runs.domain.lifecycle import RunLifecycleEvents +from runs.domain.lifecycle import RunLifecycleStates from operations.models import ( Vehicle, Operator, diff --git a/backend/realtime_engine/mqtt.py b/backend/realtime_engine/mqtt.py index 8a48d6c..70dc5a2 100644 --- a/backend/realtime_engine/mqtt.py +++ b/backend/realtime_engine/mqtt.py @@ -20,7 +20,7 @@ from celery import bootsteps from django.utils.timezone import now -from runs.domain import RunLifecycleStates +from runs.domain.lifecycle import RunLifecycleStates logger = logging.getLogger(__name__) diff --git a/backend/realtime_engine/tasks.py b/backend/realtime_engine/tasks.py index a5f8042..b13c401 100644 --- a/backend/realtime_engine/tasks.py +++ b/backend/realtime_engine/tasks.py @@ -8,7 +8,7 @@ from django.utils.timezone import now from runs.services.lifecycle import RunLifecycleService -from runs.domain import RunLifecycleStates +from runs.domain.lifecycle import RunLifecycleStates logger = logging.getLogger(__name__) @@ -25,7 +25,7 @@ @shared_task(queue="realtime_engine") def run_lifecycle_event(event: str, payload: dict[str, Any]) -> None: - from runs.domain import RunLifecycleEvents + from runs.domain.lifecycle import RunLifecycleEvents service = RunLifecycleService() try: diff --git a/backend/runs/domain/__init__.py b/backend/runs/domain/__init__.py index 88f3cf1..e69de29 100644 --- a/backend/runs/domain/__init__.py +++ b/backend/runs/domain/__init__.py @@ -1,36 +0,0 @@ -from importlib import import_module - -from .states import RunLifecycleStates, choices -from .events import RunLifecycleEvents - -__all__ = [ - "RunLifecycleStates", - "choices", - "RunLifecycleEvents", - "RunLifecycleActions", - "RunLifecycleGuards", - "Transition", - "TRANSITIONS", -] - - -def __getattr__(name: str): - if name in { - "RunLifecycleActions", - "RunLifecycleGuards", - "Transition", - "TRANSITIONS", - }: - module_name = { - "RunLifecycleActions": "actions", - "RunLifecycleGuards": "guards", - "Transition": "transitions", - "TRANSITIONS": "transitions", - }[name] - module = import_module(f"{__name__}.{module_name}") - return getattr(module, name) - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - - -def __dir__() -> list[str]: - return sorted(set(globals()) | set(__all__)) diff --git a/backend/runs/domain/detection/run_started.py b/backend/runs/domain/detection/run_started.py new file mode 100644 index 0000000..7e8c061 --- /dev/null +++ b/backend/runs/domain/detection/run_started.py @@ -0,0 +1,13 @@ +from runs.domain.lifecycle import RunLifecycleStates + + +class RunStartedDetector: + fsm = "progression" + + @staticmethod + def detect(run_state: str, leaf: str, data: dict, payload: dict): + if run_state == RunLifecycleStates.TRACKING.value and leaf == "position": + speed = float(data.get("speed", 0)) + if speed > 0.5: + return True + return False diff --git a/backend/runs/domain/lifecycle/__init__.py b/backend/runs/domain/lifecycle/__init__.py new file mode 100644 index 0000000..88f3cf1 --- /dev/null +++ b/backend/runs/domain/lifecycle/__init__.py @@ -0,0 +1,36 @@ +from importlib import import_module + +from .states import RunLifecycleStates, choices +from .events import RunLifecycleEvents + +__all__ = [ + "RunLifecycleStates", + "choices", + "RunLifecycleEvents", + "RunLifecycleActions", + "RunLifecycleGuards", + "Transition", + "TRANSITIONS", +] + + +def __getattr__(name: str): + if name in { + "RunLifecycleActions", + "RunLifecycleGuards", + "Transition", + "TRANSITIONS", + }: + module_name = { + "RunLifecycleActions": "actions", + "RunLifecycleGuards": "guards", + "Transition": "transitions", + "TRANSITIONS": "transitions", + }[name] + module = import_module(f"{__name__}.{module_name}") + return getattr(module, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/backend/runs/domain/actions.py b/backend/runs/domain/lifecycle/actions.py similarity index 99% rename from backend/runs/domain/actions.py rename to backend/runs/domain/lifecycle/actions.py index 3a0f62d..eeef9f6 100644 --- a/backend/runs/domain/actions.py +++ b/backend/runs/domain/lifecycle/actions.py @@ -3,7 +3,7 @@ import redis if TYPE_CHECKING: - from runs.domain import Transition + from runs.domain.lifecycle import Transition r = redis.Redis(host="state", port=6379, db=0) diff --git a/backend/runs/domain/events.py b/backend/runs/domain/lifecycle/events.py similarity index 100% rename from backend/runs/domain/events.py rename to backend/runs/domain/lifecycle/events.py diff --git a/backend/runs/domain/guards.py b/backend/runs/domain/lifecycle/guards.py similarity index 98% rename from backend/runs/domain/guards.py rename to backend/runs/domain/lifecycle/guards.py index 7c9fdf7..9854cf5 100644 --- a/backend/runs/domain/guards.py +++ b/backend/runs/domain/lifecycle/guards.py @@ -6,7 +6,7 @@ import redis if TYPE_CHECKING: - from runs.domain import Transition + from runs.domain.lifecycle import Transition r = redis.Redis(host="state", port=6379, db=0) @@ -331,9 +331,3 @@ def is_at_terminal_stop( } ) return True - - -class RunProgressGuards: - @staticmethod - def telemetry_lost(run: Run, payload: dict[str, Any], now: datetime) -> bool: - return True diff --git a/backend/runs/domain/states.py b/backend/runs/domain/lifecycle/states.py similarity index 100% rename from backend/runs/domain/states.py rename to backend/runs/domain/lifecycle/states.py diff --git a/backend/runs/domain/transitions.py b/backend/runs/domain/lifecycle/transitions.py similarity index 100% rename from backend/runs/domain/transitions.py rename to backend/runs/domain/lifecycle/transitions.py diff --git a/backend/runs/domain/progress/__init__.py b/backend/runs/domain/progress/__init__.py new file mode 100644 index 0000000..408ff62 --- /dev/null +++ b/backend/runs/domain/progress/__init__.py @@ -0,0 +1,36 @@ +from importlib import import_module + +from .states import RunProgressStates, choices +from .events import RunProgressEvents + +__all__ = [ + "RunProgressStates", + "choices", + "RunProgressEvents", + "RunProgressActions", + "RunProgressGuards", + "Transition", + "TRANSITIONS", +] + + +def __getattr__(name: str): + if name in { + "RunProgressActions", + "RunProgressGuards", + "Transition", + "TRANSITIONS", + }: + module_name = { + "RunProgressActions": "actions", + "RunProgressGuards": "guards", + "Transition": "transitions", + "TRANSITIONS": "transitions", + }[name] + module = import_module(f"{__name__}.{module_name}") + return getattr(module, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/backend/runs/domain/progress/actions.py b/backend/runs/domain/progress/actions.py new file mode 100644 index 0000000..3c50031 --- /dev/null +++ b/backend/runs/domain/progress/actions.py @@ -0,0 +1,158 @@ +from typing import Any, TYPE_CHECKING +from runs.models import Run +import redis + +if TYPE_CHECKING: + from runs.domain.lifecycle import Transition + +r = redis.Redis(host="state", port=6379, db=0) + + +class RunProgressActions: + """ + Ordering convention: every transition should list persist_lifecycle_event + first so the audit record is written before any external side-effects. State + is saved last via update_run_lifecycle_state so the Run row always reflects + the final outcome of a fully-executed transition. + """ + + @staticmethod + def update_system_state( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + """Write run metadata to run:{run.id} hash and claim vehicle/operator/trip keys.""" + vehicle_id = ( + payload.get("vehicle_id") + or run.vehicle.values_list("id", flat=True).first() + ) + operator_id = ( + payload.get("operator_id") + or run.operator.values_list("id", flat=True).first() + ) + run_key = f"run:{run.id}" + mapping: dict[str, str] = { + "run_id": str(run.id), + "route_id": run.route_id or "", + "trip_id": run.trip_id or "", + "direction_id": "" if run.direction_id is None else str(run.direction_id), + "shape_id": run.shape_id or "", + "schedule_relationship": run.schedule_relationship or "", + # Use transition.to_state because the action fires before _update_run_lifecycle_state + "run_lifecycle_state": transition.to_state.value, + } + if vehicle_id: + mapping["vehicle"] = str(vehicle_id) + if operator_id: + mapping["operator"] = str(operator_id) + if run.start_date: + mapping["start_date"] = run.start_date.strftime("%Y%m%d") + if run.start_time: + total_seconds = int(run.start_time.total_seconds()) + h, rem = divmod(total_seconds, 3600) + m, s = divmod(rem, 60) + mapping["start_time"] = f"{h:02d}:{m:02d}:{s:02d}" + + pipe = r.pipeline() + pipe.hset(run_key, mapping=mapping) + + # Claim assignment keys so availability guards have a signal to read + if vehicle_id: + pipe.set(f"vehicle:{vehicle_id}:current_run", str(run.id)) + if operator_id: + pipe.set(f"operator:{operator_id}:current_run", str(run.id)) + if run.trip_id: + pipe.set(f"trip:{run.trip_id}:current_run", str(run.id)) + + # Write vehicle metadata so the GTFS-RT builders can populate VehicleDescriptor + if vehicle_id: + from operations.models import Vehicle as VehicleModel + + try: + v = VehicleModel.objects.get(id=vehicle_id) + vehicle_meta: dict[str, str] = { + "id": str(v.id), + "label": v.label or str(v.id), + } + if v.license_plate: + vehicle_meta["license_plate"] = v.license_plate + if v.wheelchair_accessible: + vehicle_meta["wheelchair_accessible"] = v.wheelchair_accessible + pipe.hset(f"vehicle:{vehicle_id}:metadata", mapping=vehicle_meta) + except Exception: + pass + + pipe.execute() + return True + + # ------------------------------------------------------------------ + # Redis set mutations + # ------------------------------------------------------------------ + + @staticmethod + def sync_lifecycle_state( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + """Keep the Redis run hash's run_lifecycle_state in sync with the DB transition.""" + r.hset(f"run:{run.id}", "run_lifecycle_state", transition.to_state.value) + return True + + @staticmethod + def add_to_tracking_set( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + r.sadd("runs:tracking", str(run.id)) + return True + + @staticmethod + def remove_from_tracking_set( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + r.srem("runs:tracking", str(run.id)) + return True + + @staticmethod + def add_to_in_progress_set( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + r.sadd("runs:in_progress", str(run.id)) + return True + + @staticmethod + def remove_from_in_progress_set( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + r.srem("runs:in_progress", str(run.id)) + return True + + @staticmethod + def remove_from_system_state( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + pipe = r.pipeline() + pipe.delete(f"run:{run.id}") + pipe.srem("runs:tracking", str(run.id)) + pipe.srem("runs:in_progress", str(run.id)) + pipe.execute() + return True + + # ------------------------------------------------------------------ + # Resource release + # ------------------------------------------------------------------ + + @staticmethod + def release_resources( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + """Free vehicle, operator, and trip assignment keys.""" + vehicle_id = run.vehicle.values_list("id", flat=True).first() + operator_id = run.operator.values_list("id", flat=True).first() + keys_to_delete = [] + if vehicle_id: + keys_to_delete.append(f"vehicle:{vehicle_id}:current_run") + if operator_id: + keys_to_delete.append(f"operator:{operator_id}:current_run") + if run.trip_id: + keys_to_delete.append(f"trip:{run.trip_id}:current_run") + if keys_to_delete: + r.delete(*keys_to_delete) + return True diff --git a/backend/runs/domain/progress/events.py b/backend/runs/domain/progress/events.py new file mode 100644 index 0000000..d5eafcf --- /dev/null +++ b/backend/runs/domain/progress/events.py @@ -0,0 +1,24 @@ +from enum import Enum + + +class RunProgressEvents(str, Enum): + """ + Defines all possible events that can trigger state transitions in the run lifecycle. + """ + + # Lifecycle progression events + RUN_REQUESTED = "run_requested" # initial: record creation puts run in REQUESTED + VALIDATE_RUN = "validate_run" + INITIALIZE_RUN = "initialize_run" + RUN_CONFIRMED_BY_OPERATOR = "run_confirmed_by_operator" + RUN_TRACKING_STARTED = "run_tracking_started" + RUN_STARTED = "run_started" + COMPLETE_RUN = "complete_run" + # Operational deviation events + RUN_REJECTED = "run_rejected" + CANCEL_RUN = "cancel_run" + INTERRUPT_RUN = "interrupt_run" + SHORT_TURN_RUN = "short_turn_run" + RUN_TRACKING_LOST = "run_tracking_lost" + RUN_TRACKING_RESTORED = "run_tracking_restored" + RUN_TRACKING_EXPIRED = "run_tracking_expired" diff --git a/backend/runs/domain/progress/guards.py b/backend/runs/domain/progress/guards.py new file mode 100644 index 0000000..5d57643 --- /dev/null +++ b/backend/runs/domain/progress/guards.py @@ -0,0 +1,333 @@ +from typing import Any, TYPE_CHECKING +from datetime import datetime, timezone +from django.utils.timezone import now +from runs.models import Run +from runs.services.exceptions import RunLifecycleError +import redis + +if TYPE_CHECKING: + from runs.domain.lifecycle import Transition + +r = redis.Redis(host="state", port=6379, db=0) + +TELEMETRY_GRACE_S = 60 +TELEMETRY_EXPIRY_S = 600 + + +def _parse_last_seen(payload: dict[str, Any]) -> datetime | None: + raw = payload.get("last_seen_at") + if raw is None: + return None + if isinstance(raw, datetime): + return raw + try: + dt = datetime.fromisoformat(str(raw)) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + except (ValueError, TypeError): + return None + + +class RunProgressGuards: + @staticmethod + def is_gtfs_valid( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + from feed.models import Feed, Route, Trip, Shape + + route_id = payload.get("route_id") + trip_id = payload.get("trip_id") + direction_id = payload.get("direction_id") + shape_id = payload.get("shape_id") + schedule_relationship = payload.get("schedule_relationship") + + errors: dict[str, str] = {} + + if not route_id: + errors["route_id"] = "route_id is required" + if not trip_id: + errors["trip_id"] = "trip_id is required" + if direction_id not in [0, 1]: + errors["direction_id"] = ( + f"direction_id must be 0 or 1, got '{direction_id}'" + ) + if not shape_id: + errors["shape_id"] = "shape_id is required" + if not schedule_relationship: + errors["schedule_relationship"] = "schedule_relationship is required" + elif schedule_relationship != "SCHEDULED": + errors["schedule_relationship"] = ( + f"schedule_relationship '{schedule_relationship}' is not valid" + ) + + if errors: + raise RunLifecycleError(errors) + + feed = Feed.objects.filter(is_current=True).first() + if not feed: + raise RunLifecycleError({"feed": "No current GTFS feed found"}) + + lookup_errors: dict[str, str] = {} + + if not Route.objects.filter(feed=feed, route_id=route_id).exists(): + lookup_errors["route_id"] = ( + f"route_id '{route_id}' not found in current GTFS feed" + ) + + trip = Trip.objects.filter(feed=feed, trip_id=trip_id).first() + if not trip: + lookup_errors["trip_id"] = ( + f"trip_id '{trip_id}' not found in current GTFS feed" + ) + elif trip.direction_id != direction_id: + lookup_errors["direction_id"] = ( + f"direction_id '{direction_id}' does not match trip '{trip_id}'" + ) + elif shape_id and trip.shape_id != shape_id: + lookup_errors["shape_id"] = ( + f"shape_id '{shape_id}' does not match trip '{trip_id}'" + ) + + if lookup_errors: + raise RunLifecycleError(lookup_errors) + + return True + + @staticmethod + def is_vehicle_available( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + vehicle_id = payload.get("vehicle_id") or ( + run.vehicle.values_list("id", flat=True).first() + ) + if not vehicle_id: + return True + existing = r.get(f"vehicle:{vehicle_id}:current_run") + if existing and existing.decode() != str(run.id): + raise RunLifecycleError( + { + "vehicle_id": f"Vehicle '{vehicle_id}' is already assigned to run {existing.decode()}" + } + ) + return True + + @staticmethod + def is_trip_available( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + trip_id = payload.get("trip_id") or run.trip_id + if not trip_id: + return True + existing = r.get(f"trip:{trip_id}:current_run") + if existing and existing.decode() != str(run.id): + raise RunLifecycleError( + { + "trip_id": f"Trip '{trip_id}' is already assigned to run {existing.decode()}" + } + ) + return True + + @staticmethod + def is_operator_available( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + operator_id = payload.get("operator_id") or ( + run.operator.values_list("id", flat=True).first() + ) + if not operator_id: + return True + existing = r.get(f"operator:{operator_id}:current_run") + if existing and existing.decode() != str(run.id): + raise RunLifecycleError( + { + "operator_id": f"Operator '{operator_id}' is already assigned to run {existing.decode()}" + } + ) + return True + + @staticmethod + def is_vehicle_tracked( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + return bool(r.sismember("runs:tracking", str(run.id))) + + @staticmethod + def is_run_validated( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + return True + + @staticmethod + def is_vehicle_moving( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + return float(payload.get("speed", 0)) > 0.5 + + # ------------------------------------------------------------------ + # Cancellation / interruption / short-turn authority guards + # ------------------------------------------------------------------ + + @staticmethod + def is_cancellation_authorized( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + actor_role = payload.get("actor_role", "") + if actor_role == "system": + return True + if actor_role in ("dispatcher", "operator"): + return True + raise RunLifecycleError( + { + "actor_role": f"actor_role '{actor_role}' is not authorized to cancel runs" + } + ) + + @staticmethod + def is_interruption_authorized( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + actor_role = payload.get("actor_role", "") + if actor_role in ("system", "dispatcher", "operator"): + return True + raise RunLifecycleError( + { + "actor_role": f"actor_role '{actor_role}' is not authorized to interrupt runs" + } + ) + + @staticmethod + def is_short_turn_authorized( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + actor_role = payload.get("actor_role", "") + if actor_role in ("dispatcher", "system"): + return True + raise RunLifecycleError( + { + "actor_role": f"actor_role '{actor_role}' must be 'dispatcher' or 'system' to short-turn" + } + ) + + @staticmethod + def is_short_turn_geometrically_valid( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + from feed.models import Feed, StopTime + + short_turn_stop_id = payload.get("short_turn_stop_id") + if not short_turn_stop_id: + raise RunLifecycleError( + {"short_turn_stop_id": "short_turn_stop_id is required"} + ) + + trip_id = run.trip_id + if not trip_id: + raise RunLifecycleError({"trip_id": "Run has no trip_id"}) + + feed = Feed.objects.filter(is_current=True).first() + if not feed: + raise RunLifecycleError({"feed": "No current GTFS feed found"}) + + stop_times = StopTime.objects.filter(feed=feed, trip_id=trip_id).order_by( + "stop_sequence" + ) + if not stop_times.exists(): + raise RunLifecycleError( + {"trip_id": f"No stop times found for trip '{trip_id}'"} + ) + + terminal = stop_times.last() + stop_ids = list(stop_times.values_list("stop_id", flat=True)) + + if short_turn_stop_id not in stop_ids: + raise RunLifecycleError( + { + "short_turn_stop_id": f"Stop '{short_turn_stop_id}' not on trip '{trip_id}'" + } + ) + if short_turn_stop_id == terminal.stop_id: + raise RunLifecycleError( + {"short_turn_stop_id": "Short-turn stop cannot be the terminal stop"} + ) + return True + + # ------------------------------------------------------------------ + # Telemetry freshness guards + # ------------------------------------------------------------------ + + @staticmethod + def is_telemetry_stale( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + last_seen = _parse_last_seen(payload) + if last_seen is None: + last_seen = run.last_event_at + if last_seen is None: + return True + staleness = (now() - last_seen).total_seconds() + return staleness > TELEMETRY_GRACE_S + + @staticmethod + def is_telemetry_fresh( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + last_seen = _parse_last_seen(payload) + if last_seen is None: + last_seen = run.last_event_at + if last_seen is None: + return False + staleness = (now() - last_seen).total_seconds() + return staleness <= TELEMETRY_GRACE_S + + @staticmethod + def is_telemetry_grace_period_exceeded( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + last_seen = _parse_last_seen(payload) + if last_seen is None: + last_seen = run.last_event_at + if last_seen is None: + return True + staleness = (now() - last_seen).total_seconds() + return staleness > TELEMETRY_EXPIRY_S + + # ------------------------------------------------------------------ + # Completion guard + # ------------------------------------------------------------------ + + @staticmethod + def is_at_terminal_stop( + run: Run, transition: "Transition", payload: dict[str, Any] + ) -> bool: + from feed.models import Feed, StopTime + + stop_id = payload.get("stop_id") + if not stop_id: + raise RunLifecycleError({"stop_id": "stop_id is required"}) + + trip_id = run.trip_id + if not trip_id: + raise RunLifecycleError({"trip_id": "Run has no trip_id"}) + + feed = Feed.objects.filter(is_current=True).first() + if not feed: + raise RunLifecycleError({"feed": "No current GTFS feed found"}) + + terminal = ( + StopTime.objects.filter(feed=feed, trip_id=trip_id) + .order_by("-stop_sequence") + .first() + ) + if not terminal: + raise RunLifecycleError( + {"trip_id": f"No stop times found for trip '{trip_id}'"} + ) + + if stop_id != terminal.stop_id: + raise RunLifecycleError( + { + "stop_id": f"Stop '{stop_id}' is not the terminal stop '{terminal.stop_id}'" + } + ) + return True diff --git a/backend/runs/domain/progress/states.py b/backend/runs/domain/progress/states.py new file mode 100644 index 0000000..ea8dad6 --- /dev/null +++ b/backend/runs/domain/progress/states.py @@ -0,0 +1,23 @@ +from enum import Enum + + +class RunProgressStates(str, Enum): + """ + Defines the possible lifecycle states of a run. + """ + + REQUESTED = "Requested" + VALIDATED = "Validated" + INITIALIZED = "Initialized" + CONFIRMED = "Confirmed" + TRACKING = "Tracking" + CANCELLED = "Cancelled" + IN_PROGRESS = "In Progress" + NO_SIGNAL = "No Signal" + COMPLETED = "Completed" + INTERRUPTED = "Interrupted" + SHORT_TURNED = "Short Turned" + + +def choices(): + return [(status.value, status.name) for status in RunProgressStates] diff --git a/backend/runs/domain/progress/transitions.py b/backend/runs/domain/progress/transitions.py new file mode 100644 index 0000000..971ee22 --- /dev/null +++ b/backend/runs/domain/progress/transitions.py @@ -0,0 +1,248 @@ +from dataclasses import dataclass +from typing import Callable, List + +from .actions import RunLifecycleActions +from .guards import RunLifecycleGuards +from .states import RunLifecycleStates +from .events import RunLifecycleEvents + + +@dataclass +class Transition: + """Represents a state transition in the run lifecycle.""" + + from_state: RunLifecycleStates + event: RunLifecycleEvents + to_state: RunLifecycleStates + guards: List[Callable] + actions: List[Callable] + + +TRANSITIONS = [ + # ------------------------------------------------------------------ + # Registration: REQUESTED → VALIDATED + # ------------------------------------------------------------------ + Transition( + from_state=RunLifecycleStates.REQUESTED, + event=RunLifecycleEvents.VALIDATE_RUN, + to_state=RunLifecycleStates.VALIDATED, + guards=[ + RunLifecycleGuards.is_gtfs_valid, + RunLifecycleGuards.is_trip_available, + RunLifecycleGuards.is_vehicle_available, + RunLifecycleGuards.is_operator_available, + ], + actions=[ + ], + ), + # Registration rejected at validation stage + Transition( + from_state=RunLifecycleStates.REQUESTED, + event=RunLifecycleEvents.RUN_REJECTED, + to_state=RunLifecycleStates.CANCELLED, + guards=[ + + ], + actions=[ + ], + ), + # ------------------------------------------------------------------ + # Initialization: VALIDATED → INITIALIZED + # ------------------------------------------------------------------ + Transition( + from_state=RunLifecycleStates.VALIDATED, + event=RunLifecycleEvents.INITIALIZE_RUN, + to_state=RunLifecycleStates.INITIALIZED, + guards=[ + RunLifecycleGuards.is_run_validated, + ], + actions=[ + RunLifecycleActions.update_system_state, + ], + ), + # Initialization failed after validation passed + Transition( + from_state=RunLifecycleStates.VALIDATED, + event=RunLifecycleEvents.RUN_REJECTED, + to_state=RunLifecycleStates.CANCELLED, + guards=[ + ], + actions=[ + RunLifecycleActions.release_resources, + ], + ), + # ------------------------------------------------------------------ + # Operator confirmation: INITIALIZED → CONFIRMED + # ------------------------------------------------------------------ + Transition( + from_state=RunLifecycleStates.INITIALIZED, + event=RunLifecycleEvents.RUN_CONFIRMED_BY_OPERATOR, + to_state=RunLifecycleStates.CONFIRMED, + guards=[ + ], + actions=[ + RunLifecycleActions.sync_lifecycle_state, + ], + ), + # Cancelled before operator confirmation + Transition( + from_state=RunLifecycleStates.INITIALIZED, + event=RunLifecycleEvents.RUN_REJECTED, + to_state=RunLifecycleStates.CANCELLED, + guards=[ + RunLifecycleGuards.is_cancellation_authorized, + ], + actions=[ + RunLifecycleActions.remove_from_system_state, + RunLifecycleActions.release_resources, + ], + ), + # ------------------------------------------------------------------ + # Tracking started: CONFIRMED → TRACKING + # ------------------------------------------------------------------ + Transition( + from_state=RunLifecycleStates.CONFIRMED, + event=RunLifecycleEvents.RUN_TRACKING_STARTED, + to_state=RunLifecycleStates.TRACKING, + guards=[ + RunLifecycleGuards.is_vehicle_tracked, + ], + actions=[ + RunLifecycleActions.sync_lifecycle_state, + RunLifecycleActions.add_to_tracking_set, + ], + ), + # Cancelled after confirmation but before tracking + Transition( + from_state=RunLifecycleStates.CONFIRMED, + event=RunLifecycleEvents.CANCEL_RUN, + to_state=RunLifecycleStates.CANCELLED, + guards=[ + RunLifecycleGuards.is_cancellation_authorized, + ], + actions=[ + RunLifecycleActions.remove_from_system_state, + RunLifecycleActions.release_resources, + ], + ), + # ------------------------------------------------------------------ + # Run started: TRACKING → IN_PROGRESS + # ------------------------------------------------------------------ + Transition( + from_state=RunLifecycleStates.TRACKING, + event=RunLifecycleEvents.RUN_STARTED, + to_state=RunLifecycleStates.IN_PROGRESS, + guards=[ + RunLifecycleGuards.is_vehicle_moving, + ], + actions=[ + RunLifecycleActions.sync_lifecycle_state, + RunLifecycleActions.add_to_in_progress_set, + ], + ), + # Cancelled while tracking (before run started) + Transition( + from_state=RunLifecycleStates.TRACKING, + event=RunLifecycleEvents.CANCEL_RUN, + to_state=RunLifecycleStates.CANCELLED, + guards=[ + RunLifecycleGuards.is_cancellation_authorized, + ], + actions=[ + RunLifecycleActions.remove_from_tracking_set, + RunLifecycleActions.remove_from_system_state, + RunLifecycleActions.release_resources, + ], + ), + # ------------------------------------------------------------------ + # In progress: deviation events + # ------------------------------------------------------------------ + Transition( + from_state=RunLifecycleStates.IN_PROGRESS, + event=RunLifecycleEvents.RUN_TRACKING_LOST, + to_state=RunLifecycleStates.NO_SIGNAL, + guards=[ + RunLifecycleGuards.is_telemetry_stale, + ], + actions=[ + # Keep the run in `runs:tracking` so scan_stale_runs can fire + # RUN_TRACKING_EXPIRED later. The set is the work queue, not a + # status flag — only fully-terminal transitions should remove from it. + RunLifecycleActions.sync_lifecycle_state, + ], + ), + Transition( + from_state=RunLifecycleStates.IN_PROGRESS, + event=RunLifecycleEvents.INTERRUPT_RUN, + to_state=RunLifecycleStates.INTERRUPTED, + guards=[ + RunLifecycleGuards.is_interruption_authorized, + ], + actions=[ + RunLifecycleActions.sync_lifecycle_state, + RunLifecycleActions.remove_from_tracking_set, + RunLifecycleActions.remove_from_in_progress_set, + RunLifecycleActions.release_resources, + ], + ), + Transition( + from_state=RunLifecycleStates.IN_PROGRESS, + event=RunLifecycleEvents.SHORT_TURN_RUN, + to_state=RunLifecycleStates.SHORT_TURNED, + guards=[ + RunLifecycleGuards.is_short_turn_authorized, + RunLifecycleGuards.is_short_turn_geometrically_valid, + ], + actions=[ + RunLifecycleActions.sync_lifecycle_state, + RunLifecycleActions.remove_from_tracking_set, + RunLifecycleActions.remove_from_in_progress_set, + RunLifecycleActions.release_resources, + ], + ), + Transition( + from_state=RunLifecycleStates.IN_PROGRESS, + event=RunLifecycleEvents.COMPLETE_RUN, + to_state=RunLifecycleStates.COMPLETED, + guards=[ + RunLifecycleGuards.is_at_terminal_stop, + ], + actions=[ + RunLifecycleActions.sync_lifecycle_state, + RunLifecycleActions.remove_from_tracking_set, + RunLifecycleActions.remove_from_in_progress_set, + RunLifecycleActions.release_resources, + ], + ), + # ------------------------------------------------------------------ + # No signal: recovery or expiry + # ------------------------------------------------------------------ + Transition( + from_state=RunLifecycleStates.NO_SIGNAL, + event=RunLifecycleEvents.RUN_TRACKING_RESTORED, + to_state=RunLifecycleStates.IN_PROGRESS, + guards=[ + RunLifecycleGuards.is_telemetry_fresh, + RunLifecycleGuards.is_vehicle_tracked, + ], + actions=[ + RunLifecycleActions.sync_lifecycle_state, + RunLifecycleActions.add_to_tracking_set, + RunLifecycleActions.add_to_in_progress_set, + ], + ), + Transition( + from_state=RunLifecycleStates.NO_SIGNAL, + event=RunLifecycleEvents.RUN_TRACKING_EXPIRED, + to_state=RunLifecycleStates.CANCELLED, + guards=[ + RunLifecycleGuards.is_telemetry_grace_period_exceeded, + ], + actions=[ + RunLifecycleActions.sync_lifecycle_state, + RunLifecycleActions.remove_from_tracking_set, + RunLifecycleActions.remove_from_in_progress_set, + RunLifecycleActions.release_resources, + ], + ), +] diff --git a/backend/runs/models.py b/backend/runs/models.py index 028028a..4a18e50 100644 --- a/backend/runs/models.py +++ b/backend/runs/models.py @@ -1,6 +1,6 @@ from django.contrib.gis.db import models from operations.models import Vehicle, Operator -from runs.domain import RunLifecycleStates, choices +from runs.domain.lifecycle import RunLifecycleStates, choices import uuid # Create your models here. diff --git a/backend/runs/services/lifecycle.py b/backend/runs/services/lifecycle.py index 0ebef14..97b9653 100644 --- a/backend/runs/services/lifecycle.py +++ b/backend/runs/services/lifecycle.py @@ -1,8 +1,8 @@ from typing import Any from django.utils.timezone import now -from runs.domain import RunLifecycleEvents -from runs.domain import RunLifecycleStates -from runs.domain import Transition +from runs.domain.lifecycle import RunLifecycleEvents +from runs.domain.lifecycle import RunLifecycleStates +from runs.domain.lifecycle import Transition from runs.services.registry import TransitionRegistry from runs.services.exceptions import RunLifecycleError from runs.models import Run, RunLifecycleTransition diff --git a/backend/runs/services/registry.py b/backend/runs/services/registry.py index 118a4d3..8d71b2a 100644 --- a/backend/runs/services/registry.py +++ b/backend/runs/services/registry.py @@ -1,4 +1,4 @@ -from runs.domain import TRANSITIONS +from runs.domain.lifecycle import TRANSITIONS class TransitionRegistry: From 76e71720f723f3f4804c5e69dad8f848dcbec33c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A1n=20Abarca=20Calder=C3=B3n?= Date: Fri, 29 May 2026 15:03:52 -0300 Subject: [PATCH 08/23] refactor(transitions): update state and event references to align with run progress model --- backend/runs/domain/progress/transitions.py | 230 ++++++++++---------- 1 file changed, 112 insertions(+), 118 deletions(-) diff --git a/backend/runs/domain/progress/transitions.py b/backend/runs/domain/progress/transitions.py index 971ee22..c1af1eb 100644 --- a/backend/runs/domain/progress/transitions.py +++ b/backend/runs/domain/progress/transitions.py @@ -1,19 +1,19 @@ from dataclasses import dataclass from typing import Callable, List -from .actions import RunLifecycleActions -from .guards import RunLifecycleGuards -from .states import RunLifecycleStates -from .events import RunLifecycleEvents +from .actions import RunProgressActions +from .guards import RunProgressGuards +from .states import RunProgressStates +from .events import RunProgressEvents @dataclass class Transition: """Represents a state transition in the run lifecycle.""" - from_state: RunLifecycleStates - event: RunLifecycleEvents - to_state: RunLifecycleStates + from_state: RunProgressStates + event: RunProgressEvents + to_state: RunProgressStates guards: List[Callable] actions: List[Callable] @@ -23,226 +23,220 @@ class Transition: # Registration: REQUESTED → VALIDATED # ------------------------------------------------------------------ Transition( - from_state=RunLifecycleStates.REQUESTED, - event=RunLifecycleEvents.VALIDATE_RUN, - to_state=RunLifecycleStates.VALIDATED, + from_state=RunProgressStates.REQUESTED, + event=RunProgressEvents.VALIDATE_RUN, + to_state=RunProgressStates.VALIDATED, guards=[ - RunLifecycleGuards.is_gtfs_valid, - RunLifecycleGuards.is_trip_available, - RunLifecycleGuards.is_vehicle_available, - RunLifecycleGuards.is_operator_available, - ], - actions=[ + RunProgressGuards.is_gtfs_valid, + RunProgressGuards.is_trip_available, + RunProgressGuards.is_vehicle_available, + RunProgressGuards.is_operator_available, ], + actions=[], ), # Registration rejected at validation stage Transition( - from_state=RunLifecycleStates.REQUESTED, - event=RunLifecycleEvents.RUN_REJECTED, - to_state=RunLifecycleStates.CANCELLED, - guards=[ - - ], - actions=[ - ], + from_state=RunProgressStates.REQUESTED, + event=RunProgressEvents.RUN_REJECTED, + to_state=RunProgressStates.CANCELLED, + guards=[], + actions=[], ), # ------------------------------------------------------------------ # Initialization: VALIDATED → INITIALIZED # ------------------------------------------------------------------ Transition( - from_state=RunLifecycleStates.VALIDATED, - event=RunLifecycleEvents.INITIALIZE_RUN, - to_state=RunLifecycleStates.INITIALIZED, + from_state=RunProgressStates.VALIDATED, + event=RunProgressEvents.INITIALIZE_RUN, + to_state=RunProgressStates.INITIALIZED, guards=[ - RunLifecycleGuards.is_run_validated, + RunProgressGuards.is_run_validated, ], actions=[ - RunLifecycleActions.update_system_state, + RunProgressActions.update_system_state, ], ), # Initialization failed after validation passed Transition( - from_state=RunLifecycleStates.VALIDATED, - event=RunLifecycleEvents.RUN_REJECTED, - to_state=RunLifecycleStates.CANCELLED, - guards=[ - ], + from_state=RunProgressStates.VALIDATED, + event=RunProgressEvents.RUN_REJECTED, + to_state=RunProgressStates.CANCELLED, + guards=[], actions=[ - RunLifecycleActions.release_resources, + RunProgressActions.release_resources, ], ), # ------------------------------------------------------------------ # Operator confirmation: INITIALIZED → CONFIRMED # ------------------------------------------------------------------ Transition( - from_state=RunLifecycleStates.INITIALIZED, - event=RunLifecycleEvents.RUN_CONFIRMED_BY_OPERATOR, - to_state=RunLifecycleStates.CONFIRMED, - guards=[ - ], + from_state=RunProgressStates.INITIALIZED, + event=RunProgressEvents.RUN_CONFIRMED_BY_OPERATOR, + to_state=RunProgressStates.CONFIRMED, + guards=[], actions=[ - RunLifecycleActions.sync_lifecycle_state, + RunProgressActions.sync_lifecycle_state, ], ), # Cancelled before operator confirmation Transition( - from_state=RunLifecycleStates.INITIALIZED, - event=RunLifecycleEvents.RUN_REJECTED, - to_state=RunLifecycleStates.CANCELLED, + from_state=RunProgressStates.INITIALIZED, + event=RunProgressEvents.RUN_REJECTED, + to_state=RunProgressStates.CANCELLED, guards=[ - RunLifecycleGuards.is_cancellation_authorized, + RunProgressGuards.is_cancellation_authorized, ], actions=[ - RunLifecycleActions.remove_from_system_state, - RunLifecycleActions.release_resources, + RunProgressActions.remove_from_system_state, + RunProgressActions.release_resources, ], ), # ------------------------------------------------------------------ # Tracking started: CONFIRMED → TRACKING # ------------------------------------------------------------------ Transition( - from_state=RunLifecycleStates.CONFIRMED, - event=RunLifecycleEvents.RUN_TRACKING_STARTED, - to_state=RunLifecycleStates.TRACKING, + from_state=RunProgressStates.CONFIRMED, + event=RunProgressEvents.RUN_TRACKING_STARTED, + to_state=RunProgressStates.TRACKING, guards=[ - RunLifecycleGuards.is_vehicle_tracked, + RunProgressGuards.is_vehicle_tracked, ], actions=[ - RunLifecycleActions.sync_lifecycle_state, - RunLifecycleActions.add_to_tracking_set, + RunProgressActions.sync_lifecycle_state, + RunProgressActions.add_to_tracking_set, ], ), # Cancelled after confirmation but before tracking Transition( - from_state=RunLifecycleStates.CONFIRMED, - event=RunLifecycleEvents.CANCEL_RUN, - to_state=RunLifecycleStates.CANCELLED, + from_state=RunProgressStates.CONFIRMED, + event=RunProgressEvents.CANCEL_RUN, + to_state=RunProgressStates.CANCELLED, guards=[ - RunLifecycleGuards.is_cancellation_authorized, + RunProgressGuards.is_cancellation_authorized, ], actions=[ - RunLifecycleActions.remove_from_system_state, - RunLifecycleActions.release_resources, + RunProgressActions.remove_from_system_state, + RunProgressActions.release_resources, ], ), # ------------------------------------------------------------------ # Run started: TRACKING → IN_PROGRESS # ------------------------------------------------------------------ Transition( - from_state=RunLifecycleStates.TRACKING, - event=RunLifecycleEvents.RUN_STARTED, - to_state=RunLifecycleStates.IN_PROGRESS, + from_state=RunProgressStates.TRACKING, + event=RunProgressEvents.RUN_STARTED, + to_state=RunProgressStates.IN_PROGRESS, guards=[ - RunLifecycleGuards.is_vehicle_moving, + RunProgressGuards.is_vehicle_moving, ], actions=[ - RunLifecycleActions.sync_lifecycle_state, - RunLifecycleActions.add_to_in_progress_set, + RunProgressActions.sync_lifecycle_state, + RunProgressActions.add_to_in_progress_set, ], ), # Cancelled while tracking (before run started) Transition( - from_state=RunLifecycleStates.TRACKING, - event=RunLifecycleEvents.CANCEL_RUN, - to_state=RunLifecycleStates.CANCELLED, + from_state=RunProgressStates.TRACKING, + event=RunProgressEvents.CANCEL_RUN, + to_state=RunProgressStates.CANCELLED, guards=[ - RunLifecycleGuards.is_cancellation_authorized, + RunProgressGuards.is_cancellation_authorized, ], actions=[ - RunLifecycleActions.remove_from_tracking_set, - RunLifecycleActions.remove_from_system_state, - RunLifecycleActions.release_resources, + RunProgressActions.remove_from_tracking_set, + RunProgressActions.remove_from_system_state, + RunProgressActions.release_resources, ], ), # ------------------------------------------------------------------ # In progress: deviation events # ------------------------------------------------------------------ Transition( - from_state=RunLifecycleStates.IN_PROGRESS, - event=RunLifecycleEvents.RUN_TRACKING_LOST, - to_state=RunLifecycleStates.NO_SIGNAL, + from_state=RunProgressStates.IN_PROGRESS, + event=RunProgressEvents.RUN_TRACKING_LOST, + to_state=RunProgressStates.NO_SIGNAL, guards=[ - RunLifecycleGuards.is_telemetry_stale, + RunProgressGuards.is_telemetry_stale, ], actions=[ # Keep the run in `runs:tracking` so scan_stale_runs can fire # RUN_TRACKING_EXPIRED later. The set is the work queue, not a # status flag — only fully-terminal transitions should remove from it. - RunLifecycleActions.sync_lifecycle_state, + RunProgressActions.sync_lifecycle_state, ], ), Transition( - from_state=RunLifecycleStates.IN_PROGRESS, - event=RunLifecycleEvents.INTERRUPT_RUN, - to_state=RunLifecycleStates.INTERRUPTED, + from_state=RunProgressStates.IN_PROGRESS, + event=RunProgressEvents.INTERRUPT_RUN, + to_state=RunProgressStates.INTERRUPTED, guards=[ - RunLifecycleGuards.is_interruption_authorized, + RunProgressGuards.is_interruption_authorized, ], actions=[ - RunLifecycleActions.sync_lifecycle_state, - RunLifecycleActions.remove_from_tracking_set, - RunLifecycleActions.remove_from_in_progress_set, - RunLifecycleActions.release_resources, + RunProgressActions.sync_lifecycle_state, + RunProgressActions.remove_from_tracking_set, + RunProgressActions.remove_from_in_progress_set, + RunProgressActions.release_resources, ], ), Transition( - from_state=RunLifecycleStates.IN_PROGRESS, - event=RunLifecycleEvents.SHORT_TURN_RUN, - to_state=RunLifecycleStates.SHORT_TURNED, + from_state=RunProgressStates.IN_PROGRESS, + event=RunProgressEvents.SHORT_TURN_RUN, + to_state=RunProgressStates.SHORT_TURNED, guards=[ - RunLifecycleGuards.is_short_turn_authorized, - RunLifecycleGuards.is_short_turn_geometrically_valid, + RunProgressGuards.is_short_turn_authorized, + RunProgressGuards.is_short_turn_geometrically_valid, ], actions=[ - RunLifecycleActions.sync_lifecycle_state, - RunLifecycleActions.remove_from_tracking_set, - RunLifecycleActions.remove_from_in_progress_set, - RunLifecycleActions.release_resources, + RunProgressActions.sync_lifecycle_state, + RunProgressActions.remove_from_tracking_set, + RunProgressActions.remove_from_in_progress_set, + RunProgressActions.release_resources, ], ), Transition( - from_state=RunLifecycleStates.IN_PROGRESS, - event=RunLifecycleEvents.COMPLETE_RUN, - to_state=RunLifecycleStates.COMPLETED, + from_state=RunProgressStates.IN_PROGRESS, + event=RunProgressEvents.COMPLETE_RUN, + to_state=RunProgressStates.COMPLETED, guards=[ - RunLifecycleGuards.is_at_terminal_stop, + RunProgressGuards.is_at_terminal_stop, ], actions=[ - RunLifecycleActions.sync_lifecycle_state, - RunLifecycleActions.remove_from_tracking_set, - RunLifecycleActions.remove_from_in_progress_set, - RunLifecycleActions.release_resources, + RunProgressActions.sync_lifecycle_state, + RunProgressActions.remove_from_tracking_set, + RunProgressActions.remove_from_in_progress_set, + RunProgressActions.release_resources, ], ), # ------------------------------------------------------------------ # No signal: recovery or expiry # ------------------------------------------------------------------ Transition( - from_state=RunLifecycleStates.NO_SIGNAL, - event=RunLifecycleEvents.RUN_TRACKING_RESTORED, - to_state=RunLifecycleStates.IN_PROGRESS, + from_state=RunProgressStates.NO_SIGNAL, + event=RunProgressEvents.RUN_TRACKING_RESTORED, + to_state=RunProgressStates.IN_PROGRESS, guards=[ - RunLifecycleGuards.is_telemetry_fresh, - RunLifecycleGuards.is_vehicle_tracked, + RunProgressGuards.is_telemetry_fresh, + RunProgressGuards.is_vehicle_tracked, ], actions=[ - RunLifecycleActions.sync_lifecycle_state, - RunLifecycleActions.add_to_tracking_set, - RunLifecycleActions.add_to_in_progress_set, + RunProgressActions.sync_lifecycle_state, + RunProgressActions.add_to_tracking_set, + RunProgressActions.add_to_in_progress_set, ], ), Transition( - from_state=RunLifecycleStates.NO_SIGNAL, - event=RunLifecycleEvents.RUN_TRACKING_EXPIRED, - to_state=RunLifecycleStates.CANCELLED, + from_state=RunProgressStates.NO_SIGNAL, + event=RunProgressEvents.RUN_TRACKING_EXPIRED, + to_state=RunProgressStates.CANCELLED, guards=[ - RunLifecycleGuards.is_telemetry_grace_period_exceeded, + RunProgressGuards.is_telemetry_grace_period_exceeded, ], actions=[ - RunLifecycleActions.sync_lifecycle_state, - RunLifecycleActions.remove_from_tracking_set, - RunLifecycleActions.remove_from_in_progress_set, - RunLifecycleActions.release_resources, + RunProgressActions.sync_lifecycle_state, + RunProgressActions.remove_from_tracking_set, + RunProgressActions.remove_from_in_progress_set, + RunProgressActions.release_resources, ], ), ] From c4a44b88b68572d72d0f78654ca2ba84c86a633c Mon Sep 17 00:00:00 2001 From: Jae Date: Tue, 2 Jun 2026 06:42:34 -0600 Subject: [PATCH 09/23] refactor(runs): extract telemetry/stale detection into runs/domain/detection Consolidate the lifecycle detection heuristics that were inlined in realtime_engine/mqtt.py (_maybe_fire_lifecycle_event) and scan_stale_runs into a dedicated, pure, unit-testable detection layer: - detection/result.py: DetectionResult value object - detection/thresholds.py: single source for telemetry grace/expiry (fixes the prior 300-vs-600 mismatch between tasks.py and lifecycle/guards.py) - detection/lifecycle_detectors.py: tracking-started, run-started (speed>0.5), tracking-restored, completion detectors - detection/periodic_detectors.py: tracking-lost / tracking-expired (staleness) - detection/registry.py + dispatch.py: ordered detector lists and a pure planner plus thin Redis/Celery wrappers mqtt.py and scan_stale_runs now delegate to the dispatcher and hold no detection logic. Lifecycle FSM behaviour is unchanged. 30 pure tests added. Detectors stay I/O-free; the runs:tracking seed that a guard depends on stays in the dispatcher. --- backend/realtime_engine/mqtt.py | 42 +----- backend/realtime_engine/tasks.py | 36 ++--- backend/runs/domain/detection/__init__.py | 14 ++ backend/runs/domain/detection/dispatch.py | 126 ++++++++++++++++++ .../domain/detection/lifecycle_detectors.py | 81 +++++++++++ .../domain/detection/periodic_detectors.py | 51 +++++++ backend/runs/domain/detection/registry.py | 31 +++++ backend/runs/domain/detection/result.py | 21 +++ backend/runs/domain/detection/run_started.py | 13 -- .../runs/domain/detection/tests/__init__.py | 0 .../domain/detection/tests/test_detectors.py | 122 +++++++++++++++++ .../detection/tests/test_dispatch_planner.py | 68 ++++++++++ .../domain/detection/tests/test_result.py | 34 +++++ backend/runs/domain/detection/thresholds.py | 17 +++ backend/runs/domain/lifecycle/guards.py | 4 +- 15 files changed, 578 insertions(+), 82 deletions(-) create mode 100644 backend/runs/domain/detection/__init__.py create mode 100644 backend/runs/domain/detection/dispatch.py create mode 100644 backend/runs/domain/detection/lifecycle_detectors.py create mode 100644 backend/runs/domain/detection/periodic_detectors.py create mode 100644 backend/runs/domain/detection/registry.py create mode 100644 backend/runs/domain/detection/result.py delete mode 100644 backend/runs/domain/detection/run_started.py create mode 100644 backend/runs/domain/detection/tests/__init__.py create mode 100644 backend/runs/domain/detection/tests/test_detectors.py create mode 100644 backend/runs/domain/detection/tests/test_dispatch_planner.py create mode 100644 backend/runs/domain/detection/tests/test_result.py create mode 100644 backend/runs/domain/detection/thresholds.py diff --git a/backend/realtime_engine/mqtt.py b/backend/realtime_engine/mqtt.py index 70dc5a2..500914d 100644 --- a/backend/realtime_engine/mqtt.py +++ b/backend/realtime_engine/mqtt.py @@ -13,15 +13,12 @@ import json import logging import os -from typing import Any import paho.mqtt.client as mqtt import redis from celery import bootsteps from django.utils.timezone import now -from runs.domain.lifecycle import RunLifecycleStates - logger = logging.getLogger(__name__) MQTT_HOST = os.getenv("MQTT_HOST", "telemetry-broker") @@ -72,43 +69,12 @@ def _handle_telemetry(vehicle_id: str, leaf: str, payload_bytes: bytes) -> None: ) r.set(f"runs:last_seen:{run_id}", now().isoformat()) - _maybe_fire_lifecycle_event(run_id, vehicle_id, leaf, data) - -def _maybe_fire_lifecycle_event( - run_id: str, vehicle_id: str, leaf: str, data: dict[str, Any] -) -> None: - from realtime_engine.tasks import run_lifecycle_event - - run_state = r.hget(f"run:{run_id}", "run_lifecycle_state") - if not run_state: - return + # All detection heuristics live in runs.domain.detection; this consumer only + # ingests telemetry and delegates. + from runs.domain.detection.dispatch import detect_from_telemetry - payload = { - "run_id": run_id, - "vehicle_id": vehicle_id, - "last_seen_at": now().isoformat(), - **data, - } - - if run_state == RunLifecycleStates.CONFIRMED.value: - r.sadd("runs:tracking", run_id) - run_lifecycle_event.delay("run_tracking_started", payload) - - elif run_state == RunLifecycleStates.TRACKING.value and leaf == "position": - speed = float(data.get("speed", 0)) - if speed > 0.5: - run_lifecycle_event.delay("run_started", payload) - - elif run_state == RunLifecycleStates.NO_SIGNAL.value: - r.sadd("runs:tracking", run_id) - run_lifecycle_event.delay("run_tracking_restored", payload) - - elif run_state == RunLifecycleStates.IN_PROGRESS.value and leaf == "progression": - current_status = data.get("current_status", "") - stop_id = data.get("stop_id", "") - if current_status == "STOPPED_AT" and stop_id: - run_lifecycle_event.delay("complete_run", {**payload, "stop_id": stop_id}) + detect_from_telemetry(run_id, vehicle_id, leaf, data) def _on_connect(client: mqtt.Client, userdata, flags, rc) -> None: diff --git a/backend/realtime_engine/tasks.py b/backend/realtime_engine/tasks.py index b13c401..fa9a0ce 100644 --- a/backend/realtime_engine/tasks.py +++ b/backend/realtime_engine/tasks.py @@ -8,7 +8,6 @@ from django.utils.timezone import now from runs.services.lifecycle import RunLifecycleService -from runs.domain.lifecycle import RunLifecycleStates logger = logging.getLogger(__name__) @@ -19,9 +18,6 @@ decode_responses=True, ) -TELEMETRY_GRACE_S = 60 -TELEMETRY_EXPIRY_S = 300 - @shared_task(queue="realtime_engine") def run_lifecycle_event(event: str, payload: dict[str, Any]) -> None: @@ -43,11 +39,14 @@ def run_lifecycle_event(event: str, payload: dict[str, Any]) -> None: @shared_task(queue="realtime_engine") def scan_stale_runs() -> str: + """Scan ``runs:tracking`` every 30 s and let the detection layer decide. + + The staleness windows and the IN_PROGRESS/NO_SIGNAL conditions live in the + periodic detectors (``runs.domain.detection``); this task only computes how + long each run has been quiet and hands it to the dispatcher. """ - Scans runs:tracking every 30 s. - - Grace exceeded (>60s, ≤300s) while IN_PROGRESS → RUN_TRACKING_LOST - - Expiry exceeded (>300s) while NO_SIGNAL → RUN_TRACKING_EXPIRED - """ + from runs.domain.detection.dispatch import detect_from_scan + run_ids = redis_client.smembers("runs:tracking") fired = 0 for run_id in run_ids: @@ -62,25 +61,6 @@ def scan_stale_runs() -> str: continue staleness = (now() - last_seen).total_seconds() - run_state = redis_client.hget(f"run:{run_id}", "run_lifecycle_state") - - payload = { - "run_id": run_id, - "last_seen_at": raw_last_seen, - "actor_role": "system", - } - - if ( - TELEMETRY_GRACE_S < staleness <= TELEMETRY_EXPIRY_S - and run_state == RunLifecycleStates.IN_PROGRESS.value - ): - run_lifecycle_event.delay("run_tracking_lost", payload) - fired += 1 - elif ( - staleness > TELEMETRY_EXPIRY_S - and run_state == RunLifecycleStates.NO_SIGNAL.value - ): - run_lifecycle_event.delay("run_tracking_expired", payload) - fired += 1 + fired += detect_from_scan(run_id, staleness, raw_last_seen) return f"scan_stale_runs: checked {len(run_ids)} runs, fired {fired} events" diff --git a/backend/runs/domain/detection/__init__.py b/backend/runs/domain/detection/__init__.py new file mode 100644 index 0000000..598d01e --- /dev/null +++ b/backend/runs/domain/detection/__init__.py @@ -0,0 +1,14 @@ +"""Detection logic for the run state machines. + +This package is the single home for the heuristics that turn raw vehicle +telemetry (and the periodic staleness scan) into state-machine events. Detectors +are pure functions of their inputs — they never touch Redis or the database — so +they are trivial to unit-test and cheap to change. All side effects (Redis set +mutations, DB writes) live in the FSM *actions*, executed by the services once an +event is fired. + +Import submodules directly to keep this package's import side-effect-free: + + from runs.domain.detection.dispatch import detect_from_telemetry + from runs.domain.detection.result import DetectionResult +""" diff --git a/backend/runs/domain/detection/dispatch.py b/backend/runs/domain/detection/dispatch.py new file mode 100644 index 0000000..46ed2fd --- /dev/null +++ b/backend/runs/domain/detection/dispatch.py @@ -0,0 +1,126 @@ +"""Dispatch: turn telemetry / staleness into fired lifecycle events. + +Two layers: + +* **Pure planners** (``plan_telemetry_events`` / ``plan_scan_events``) decide which + events to fire given the current state and a message. No I/O — unit-testable. +* **Impure wrappers** (``detect_from_telemetry`` / ``detect_from_scan``) read run + state from Redis, seed the few preconditions the lifecycle guards depend on, and + queue the resulting events onto the lifecycle Celery task. + +``mqtt.py`` and ``scan_stale_runs`` call only the wrappers; they hold no detection +logic of their own. +""" + +from __future__ import annotations + +import os +from typing import Any + +import redis +from django.utils.timezone import now + +from runs.domain.detection.result import DetectionResult +from runs.domain.detection import registry + +r = redis.Redis( + host=os.getenv("REDIS_HOST", "state"), + port=int(os.getenv("REDIS_PORT", "6379")), + db=0, + decode_responses=True, +) + +# Lifecycle events whose guard (``is_vehicle_tracked``) requires the run to +# already be in ``runs:tracking``. The arrival of telemetry is precisely the +# signal that justifies membership, so the dispatcher seeds it before firing — +# mirroring the original inline behaviour while keeping detectors pure. +_TRACKING_SEED_EVENTS = {"run_tracking_started", "run_tracking_restored"} + + +# --------------------------------------------------------------------------- +# Pure planners +# --------------------------------------------------------------------------- + + +def plan_telemetry_events( + lifecycle_state: str | None, + leaf: str, + data: dict[str, Any], + base_payload: dict[str, Any], + detectors=registry.TELEMETRY_DETECTORS, +) -> list[DetectionResult]: + """Plan at most one event per FSM for a telemetry message.""" + results: list[DetectionResult] = [] + fired_fsms: set[str] = set() + for detector in detectors: + if detector.fsm in fired_fsms: + continue + if lifecycle_state is None: + continue + result = detector.detect(lifecycle_state, leaf, data, base_payload) + if result is not None: + results.append(result) + fired_fsms.add(result.fsm) + return results + + +def plan_scan_events( + lifecycle_state: str | None, + staleness_s: float, + payload: dict[str, Any], + detectors=registry.PERIODIC_DETECTORS, +) -> list[DetectionResult]: + """Plan at most one lifecycle event for a staleness scan tick.""" + if lifecycle_state is None: + return [] + for detector in detectors: + result = detector.detect(lifecycle_state, staleness_s, payload) + if result is not None: + return [result] + return [] + + +# --------------------------------------------------------------------------- +# Impure wrappers +# --------------------------------------------------------------------------- + + +def _fire(result: DetectionResult, base_payload: dict[str, Any]) -> None: + from realtime_engine.tasks import run_lifecycle_event + + payload = {**base_payload, **result.extra_payload} + if result.event in _TRACKING_SEED_EVENTS: + r.sadd("runs:tracking", base_payload["run_id"]) + run_lifecycle_event.delay(result.event, payload) + + +def detect_from_telemetry( + run_id: str, vehicle_id: str, leaf: str, data: dict[str, Any] +) -> None: + lifecycle_state = r.hget(f"run:{run_id}", "run_lifecycle_state") + if not lifecycle_state: + return + + base_payload = { + "run_id": run_id, + "vehicle_id": vehicle_id, + "last_seen_at": now().isoformat(), + **data, + } + for result in plan_telemetry_events(lifecycle_state, leaf, data, base_payload): + _fire(result, base_payload) + + +def detect_from_scan(run_id: str, staleness_s: float, raw_last_seen: str) -> int: + """Evaluate periodic detectors for one run; returns number of events fired.""" + lifecycle_state = r.hget(f"run:{run_id}", "run_lifecycle_state") + base_payload = { + "run_id": run_id, + "last_seen_at": raw_last_seen, + "actor_role": "system", + } + fired = 0 + for result in plan_scan_events(lifecycle_state, staleness_s, base_payload): + _fire(result, base_payload) + fired += 1 + return fired diff --git a/backend/runs/domain/detection/lifecycle_detectors.py b/backend/runs/domain/detection/lifecycle_detectors.py new file mode 100644 index 0000000..3815531 --- /dev/null +++ b/backend/runs/domain/detection/lifecycle_detectors.py @@ -0,0 +1,81 @@ +"""Telemetry-triggered detectors that feed the lifecycle FSM. + +Each ``detect`` is a pure function of the run's current lifecycle state and a +single incoming telemetry message. It returns a :class:`DetectionResult` to fire, +or ``None``. Heuristics here are intentionally simple and expected to change. +""" + +from __future__ import annotations + +from typing import Any + +from runs.domain.lifecycle.states import RunLifecycleStates +from runs.domain.detection.result import DetectionResult + +FSM = "lifecycle" + +# Speed (m/s) above which a tracked vehicle is considered to have started moving. +MIN_MOVING_SPEED = 0.5 + + +class RunTrackingStartedDetector: + """Confirmed run + first telemetry of any kind ⇒ tracking has begun.""" + + fsm = FSM + + @staticmethod + def detect( + run_state: str, leaf: str, data: dict[str, Any], payload: dict[str, Any] + ) -> DetectionResult | None: + if run_state == RunLifecycleStates.CONFIRMED.value: + return DetectionResult(FSM, "run_tracking_started") + return None + + +class RunStartedDetector: + """Tracking run + a position ping showing movement ⇒ the run has started.""" + + fsm = FSM + + @staticmethod + def detect( + run_state: str, leaf: str, data: dict[str, Any], payload: dict[str, Any] + ) -> DetectionResult | None: + if run_state == RunLifecycleStates.TRACKING.value and leaf == "position": + if float(data.get("speed", 0) or 0) > MIN_MOVING_SPEED: + return DetectionResult(FSM, "run_started") + return None + + +class RunTrackingRestoredDetector: + """No-signal run + any fresh telemetry ⇒ tracking restored.""" + + fsm = FSM + + @staticmethod + def detect( + run_state: str, leaf: str, data: dict[str, Any], payload: dict[str, Any] + ) -> DetectionResult | None: + if run_state == RunLifecycleStates.NO_SIGNAL.value: + return DetectionResult(FSM, "run_tracking_restored") + return None + + +class RunCompletedDetector: + """In-progress run reporting STOPPED_AT a stop ⇒ candidate completion. + + The terminal-stop check is the FSM guard's job (``is_at_terminal_stop``); the + detector only surfaces the candidate event and forwards the ``stop_id``. + """ + + fsm = FSM + + @staticmethod + def detect( + run_state: str, leaf: str, data: dict[str, Any], payload: dict[str, Any] + ) -> DetectionResult | None: + if run_state == RunLifecycleStates.IN_PROGRESS.value and leaf == "progression": + stop_id = data.get("stop_id") + if data.get("current_status") == "STOPPED_AT" and stop_id: + return DetectionResult(FSM, "complete_run", {"stop_id": stop_id}) + return None diff --git a/backend/runs/domain/detection/periodic_detectors.py b/backend/runs/domain/detection/periodic_detectors.py new file mode 100644 index 0000000..4075fdb --- /dev/null +++ b/backend/runs/domain/detection/periodic_detectors.py @@ -0,0 +1,51 @@ +"""Staleness-triggered detectors that feed the lifecycle FSM. + +These are evaluated by the periodic ``scan_stale_runs`` task rather than on an +incoming message, so their signature takes ``staleness_s`` (seconds since the run +was last seen) instead of telemetry. Thresholds come from the single source in +:mod:`runs.domain.detection.thresholds`. +""" + +from __future__ import annotations + +from typing import Any + +from runs.domain.lifecycle.states import RunLifecycleStates +from runs.domain.detection.result import DetectionResult +from runs.domain.detection.thresholds import TELEMETRY_GRACE_S, TELEMETRY_EXPIRY_S + +FSM = "lifecycle" + + +class RunTrackingLostDetector: + """In-progress run gone quiet past the grace window (but not yet expired).""" + + fsm = FSM + + @staticmethod + def detect( + run_state: str, staleness_s: float, payload: dict[str, Any] + ) -> DetectionResult | None: + if ( + run_state == RunLifecycleStates.IN_PROGRESS.value + and TELEMETRY_GRACE_S < staleness_s <= TELEMETRY_EXPIRY_S + ): + return DetectionResult(FSM, "run_tracking_lost", {"actor_role": "system"}) + return None + + +class RunTrackingExpiredDetector: + """No-signal run quiet beyond the expiry window ⇒ cancel.""" + + fsm = FSM + + @staticmethod + def detect( + run_state: str, staleness_s: float, payload: dict[str, Any] + ) -> DetectionResult | None: + if ( + run_state == RunLifecycleStates.NO_SIGNAL.value + and staleness_s > TELEMETRY_EXPIRY_S + ): + return DetectionResult(FSM, "run_tracking_expired", {"actor_role": "system"}) + return None diff --git a/backend/runs/domain/detection/registry.py b/backend/runs/domain/detection/registry.py new file mode 100644 index 0000000..201c60a --- /dev/null +++ b/backend/runs/domain/detection/registry.py @@ -0,0 +1,31 @@ +"""Ordered detector registries. + +Adding a new detection is a one-line change here. Order only matters within a +single FSM (the planner fires at most one event per FSM per evaluation, taking +the first match); across FSMs detectors are independent. +""" + +from runs.domain.detection.lifecycle_detectors import ( + RunTrackingStartedDetector, + RunStartedDetector, + RunTrackingRestoredDetector, + RunCompletedDetector, +) +from runs.domain.detection.periodic_detectors import ( + RunTrackingLostDetector, + RunTrackingExpiredDetector, +) + +# Evaluated on every incoming telemetry message. +TELEMETRY_DETECTORS = [ + RunTrackingStartedDetector, + RunStartedDetector, + RunTrackingRestoredDetector, + RunCompletedDetector, +] + +# Evaluated by the periodic staleness scan. +PERIODIC_DETECTORS = [ + RunTrackingLostDetector, + RunTrackingExpiredDetector, +] diff --git a/backend/runs/domain/detection/result.py b/backend/runs/domain/detection/result.py new file mode 100644 index 0000000..6bead82 --- /dev/null +++ b/backend/runs/domain/detection/result.py @@ -0,0 +1,21 @@ +"""The value a detector returns when it recognises an event.""" + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class DetectionResult: + """A detected state-machine event, ready to be routed to an FSM service. + + Attributes: + fsm: Routing key for the target state machine (``"lifecycle"`` or + ``"progress"``). The dispatcher uses this to pick the service/task. + event: The event name to fire (the FSM event enum *value*). + extra_payload: Fields the detector adds on top of the base payload + (e.g. ``{"stop_id": ...}`` for completion). Merged by the dispatcher. + """ + + fsm: str + event: str + extra_payload: dict[str, Any] = field(default_factory=dict) diff --git a/backend/runs/domain/detection/run_started.py b/backend/runs/domain/detection/run_started.py deleted file mode 100644 index 7e8c061..0000000 --- a/backend/runs/domain/detection/run_started.py +++ /dev/null @@ -1,13 +0,0 @@ -from runs.domain.lifecycle import RunLifecycleStates - - -class RunStartedDetector: - fsm = "progression" - - @staticmethod - def detect(run_state: str, leaf: str, data: dict, payload: dict): - if run_state == RunLifecycleStates.TRACKING.value and leaf == "position": - speed = float(data.get("speed", 0)) - if speed > 0.5: - return True - return False diff --git a/backend/runs/domain/detection/tests/__init__.py b/backend/runs/domain/detection/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/runs/domain/detection/tests/test_detectors.py b/backend/runs/domain/detection/tests/test_detectors.py new file mode 100644 index 0000000..d379283 --- /dev/null +++ b/backend/runs/domain/detection/tests/test_detectors.py @@ -0,0 +1,122 @@ +"""Pure unit tests for lifecycle detectors — no Django/Redis required. + +Detectors are pure functions of (state, telemetry) → DetectionResult | None. +""" + +import pytest + +from runs.domain.lifecycle.states import RunLifecycleStates +from runs.domain.detection.lifecycle_detectors import ( + RunTrackingStartedDetector, + RunStartedDetector, + RunTrackingRestoredDetector, + RunCompletedDetector, +) +from runs.domain.detection.periodic_detectors import ( + RunTrackingLostDetector, + RunTrackingExpiredDetector, +) +from runs.domain.detection import thresholds + +TRACKING = RunLifecycleStates.TRACKING.value +CONFIRMED = RunLifecycleStates.CONFIRMED.value +NO_SIGNAL = RunLifecycleStates.NO_SIGNAL.value +IN_PROGRESS = RunLifecycleStates.IN_PROGRESS.value + + +# -------------------------------------------------------------------------- +# Lifecycle telemetry detectors +# -------------------------------------------------------------------------- + + +def test_tracking_started_fires_on_any_telemetry_when_confirmed(): + res = RunTrackingStartedDetector.detect(CONFIRMED, "position", {}, {}) + assert res is not None + assert res.fsm == "lifecycle" + assert res.event == "run_tracking_started" + + +def test_tracking_started_silent_when_not_confirmed(): + assert RunTrackingStartedDetector.detect(TRACKING, "position", {}, {}) is None + + +@pytest.mark.parametrize( + "speed,expected", + [(0.0, None), (0.5, None), (0.51, "run_started"), (12.0, "run_started")], +) +def test_run_started_speed_boundary(speed, expected): + res = RunStartedDetector.detect(TRACKING, "position", {"speed": speed}, {}) + assert (res.event if res else None) == expected + + +def test_run_started_ignores_non_position_leaf(): + assert RunStartedDetector.detect(TRACKING, "progression", {"speed": 9}, {}) is None + + +def test_run_started_silent_when_not_tracking(): + assert RunStartedDetector.detect(IN_PROGRESS, "position", {"speed": 9}, {}) is None + + +def test_tracking_restored_fires_when_no_signal(): + res = RunTrackingRestoredDetector.detect(NO_SIGNAL, "occupancy", {}, {}) + assert res is not None and res.event == "run_tracking_restored" + + +def test_completed_fires_on_stopped_at_with_stop_id(): + data = {"current_status": "STOPPED_AT", "stop_id": "TERM-1"} + res = RunCompletedDetector.detect(IN_PROGRESS, "progression", data, {}) + assert res is not None + assert res.event == "complete_run" + assert res.extra_payload == {"stop_id": "TERM-1"} + + +def test_completed_silent_without_stop_id(): + data = {"current_status": "STOPPED_AT"} + assert RunCompletedDetector.detect(IN_PROGRESS, "progression", data, {}) is None + + +def test_completed_silent_when_not_stopped(): + data = {"current_status": "IN_TRANSIT_TO", "stop_id": "X"} + assert RunCompletedDetector.detect(IN_PROGRESS, "progression", data, {}) is None + + +# -------------------------------------------------------------------------- +# Periodic (staleness) detectors +# -------------------------------------------------------------------------- + +GRACE = thresholds.TELEMETRY_GRACE_S +EXPIRY = thresholds.TELEMETRY_EXPIRY_S + + +@pytest.mark.parametrize( + "staleness,state,expected", + [ + (GRACE - 1, IN_PROGRESS, None), + (GRACE + 1, IN_PROGRESS, "run_tracking_lost"), + (EXPIRY, IN_PROGRESS, "run_tracking_lost"), + (EXPIRY + 1, IN_PROGRESS, None), # beyond expiry handled by expired detector + (GRACE + 1, NO_SIGNAL, None), # wrong state + ], +) +def test_tracking_lost_window(staleness, state, expected): + res = RunTrackingLostDetector.detect(state, staleness, {}) + assert (res.event if res else None) == expected + + +@pytest.mark.parametrize( + "staleness,state,expected", + [ + (EXPIRY, NO_SIGNAL, None), + (EXPIRY + 1, NO_SIGNAL, "run_tracking_expired"), + (EXPIRY + 1, IN_PROGRESS, None), # wrong state + ], +) +def test_tracking_expired_window(staleness, state, expected): + res = RunTrackingExpiredDetector.detect(state, staleness, {}) + assert (res.event if res else None) == expected + + +def test_periodic_detectors_tag_lifecycle_and_system_actor(): + res = RunTrackingLostDetector.detect(IN_PROGRESS, GRACE + 5, {}) + assert res.fsm == "lifecycle" + assert res.extra_payload.get("actor_role") == "system" diff --git a/backend/runs/domain/detection/tests/test_dispatch_planner.py b/backend/runs/domain/detection/tests/test_dispatch_planner.py new file mode 100644 index 0000000..d1f0a17 --- /dev/null +++ b/backend/runs/domain/detection/tests/test_dispatch_planner.py @@ -0,0 +1,68 @@ +"""Pure unit tests for the dispatch *planner* (no Redis/Celery/Django). + +The planner decides which events to fire given the current state and a message; +the impure wrapper that reads Redis and queues Celery tasks is covered by +integration tests in the compose stack. +""" + +from runs.domain.detection import registry +from runs.domain.detection.dispatch import plan_telemetry_events, plan_scan_events +from runs.domain.detection import thresholds + + +def _events(results): + return [(r.fsm, r.event) for r in results] + + +def test_registry_lists_are_populated_and_tagged(): + assert registry.TELEMETRY_DETECTORS + assert registry.PERIODIC_DETECTORS + for d in registry.TELEMETRY_DETECTORS + registry.PERIODIC_DETECTORS: + assert d.fsm == "lifecycle" + assert hasattr(d, "detect") + + +def test_plan_fires_at_most_one_lifecycle_event(): + data = {"current_status": "STOPPED_AT", "stop_id": "S9"} + results = plan_telemetry_events( + lifecycle_state="In Progress", + leaf="progression", + data=data, + base_payload={}, + detectors=registry.TELEMETRY_DETECTORS, + ) + events = _events(results) + assert ("lifecycle", "complete_run") in events + fsms = [fsm for fsm, _ in events] + assert fsms.count("lifecycle") == 1 + + +def test_plan_tracking_started_only(): + results = plan_telemetry_events( + lifecycle_state="Confirmed", + leaf="position", + data={"speed": 0}, + base_payload={}, + detectors=registry.TELEMETRY_DETECTORS, + ) + assert _events(results) == [("lifecycle", "run_tracking_started")] + + +def test_plan_scan_picks_single_lifecycle_event(): + results = plan_scan_events( + lifecycle_state="In Progress", + staleness_s=thresholds.TELEMETRY_GRACE_S + 5, + payload={}, + detectors=registry.PERIODIC_DETECTORS, + ) + assert _events(results) == [("lifecycle", "run_tracking_lost")] + + +def test_plan_scan_no_event_when_fresh(): + results = plan_scan_events( + lifecycle_state="In Progress", + staleness_s=1, + payload={}, + detectors=registry.PERIODIC_DETECTORS, + ) + assert results == [] diff --git a/backend/runs/domain/detection/tests/test_result.py b/backend/runs/domain/detection/tests/test_result.py new file mode 100644 index 0000000..d87da5b --- /dev/null +++ b/backend/runs/domain/detection/tests/test_result.py @@ -0,0 +1,34 @@ +"""Pure unit tests for the detection result primitive and thresholds. + +These tests intentionally avoid Django/Redis so they run under plain pytest. +""" + +from runs.domain.detection.result import DetectionResult +from runs.domain.detection import thresholds + + +def test_detection_result_holds_fsm_event_and_payload(): + result = DetectionResult(fsm="lifecycle", event="run_started", extra_payload={"stop_id": "s1"}) + assert result.fsm == "lifecycle" + assert result.event == "run_started" + assert result.extra_payload == {"stop_id": "s1"} + + +def test_detection_result_defaults_to_empty_payload(): + result = DetectionResult(fsm="progress", event="vehicle_stopped") + assert result.extra_payload == {} + + +def test_detection_result_is_immutable(): + result = DetectionResult(fsm="lifecycle", event="run_started") + try: + result.event = "other" # type: ignore[misc] + except Exception: + return + raise AssertionError("DetectionResult should be frozen/immutable") + + +def test_thresholds_are_consistent_single_source(): + # Grace must be strictly less than expiry, and both positive. + assert thresholds.TELEMETRY_GRACE_S > 0 + assert thresholds.TELEMETRY_EXPIRY_S > thresholds.TELEMETRY_GRACE_S diff --git a/backend/runs/domain/detection/thresholds.py b/backend/runs/domain/detection/thresholds.py new file mode 100644 index 0000000..e638fff --- /dev/null +++ b/backend/runs/domain/detection/thresholds.py @@ -0,0 +1,17 @@ +"""Single source of truth for telemetry-staleness thresholds. + +Previously these lived in two places that disagreed: ``realtime_engine/tasks.py`` +used a 300 s expiry while ``runs/domain/lifecycle/guards.py`` used 600 s, so the +periodic scan could fire ``run_tracking_expired`` in a window where the guard then +rejected it. Both now import from here. + +This module imports nothing so it is safe to import from any layer (detectors, +guards, tasks) without pulling in Django or Redis. +""" + +# Telemetry older than this (seconds) is "stale": an IN_PROGRESS run drops to +# NO_SIGNAL. +TELEMETRY_GRACE_S = 60 + +# Telemetry older than this (seconds) is "expired": a NO_SIGNAL run is cancelled. +TELEMETRY_EXPIRY_S = 600 diff --git a/backend/runs/domain/lifecycle/guards.py b/backend/runs/domain/lifecycle/guards.py index 9854cf5..87b2cff 100644 --- a/backend/runs/domain/lifecycle/guards.py +++ b/backend/runs/domain/lifecycle/guards.py @@ -3,6 +3,7 @@ from django.utils.timezone import now from runs.models import Run from runs.services.exceptions import RunLifecycleError +from runs.domain.detection.thresholds import TELEMETRY_GRACE_S, TELEMETRY_EXPIRY_S import redis if TYPE_CHECKING: @@ -10,9 +11,6 @@ r = redis.Redis(host="state", port=6379, db=0) -TELEMETRY_GRACE_S = 60 -TELEMETRY_EXPIRY_S = 600 - def _parse_last_seen(payload: dict[str, Any]) -> datetime | None: raw = payload.get("last_seen_at") From 9fe15738e2080e693fde5dd9c4937ffe16507098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A1n=20Abarca=20Calder=C3=B3n?= Date: Thu, 4 Jun 2026 13:08:40 -0300 Subject: [PATCH 10/23] refactor(runs): map models to GTFS Realtime VehiclePosition objects --- backend/api/serializers.py | 23 +++++++++++++------ backend/api/urls.py | 3 ++- backend/api/views.py | 26 +++++++++++++-------- backend/feed/files/README.md | 1 - backend/feed/views.py | 12 ++++------ backend/runs/admin.py | 12 ++++------ backend/runs/models.py | 39 ++++++++++++++++++++++++++++---- backend/schedule_engine/tasks.py | 12 ++++++---- 8 files changed, 87 insertions(+), 41 deletions(-) delete mode 100644 backend/feed/files/README.md diff --git a/backend/api/serializers.py b/backend/api/serializers.py index f08125a..a9e2f9f 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -9,8 +9,9 @@ from runs.models import ( Run, Position, - Progression, - Occupancy, + VehicleStopStatus, + CongestionLevel, + OccupancyStatus, ) from runs.domain.lifecycle import RunLifecycleEvents from feed.models import * @@ -174,23 +175,31 @@ def get_longitude(self, obj): # return Position.objects.create(point=point, **validated_data) -class ProgressionSerializer(serializers.HyperlinkedModelSerializer): - vehicle = serializers.PrimaryKeyRelatedField(queryset=Vehicle.objects.all()) +class VehicleStopStatusSerializer(serializers.HyperlinkedModelSerializer): vehicle = serializers.PrimaryKeyRelatedField(queryset=Vehicle.objects.all()) class Meta: - model = Progression + model = VehicleStopStatus fields = "__all__" fields = "__all__" ordering = ["id"] -class OccupancySerializer(serializers.HyperlinkedModelSerializer): +class CongestionLevelSerializer(serializers.HyperlinkedModelSerializer): vehicle = serializers.PrimaryKeyRelatedField(queryset=Vehicle.objects.all()) + + class Meta: + model = CongestionLevel + fields = "__all__" + fields = "__all__" + ordering = ["id"] + + +class OccupancyStatusSerializer(serializers.HyperlinkedModelSerializer): vehicle = serializers.PrimaryKeyRelatedField(queryset=Vehicle.objects.all()) class Meta: - model = Occupancy + model = OccupancyStatus fields = "__all__" fields = "__all__" ordering = ["id"] diff --git a/backend/api/urls.py b/backend/api/urls.py index 737f641..845eae4 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -14,8 +14,9 @@ router.register(r"equipment-log", views.EquipmentLogViewSet) # router.register(r"run", views.RunViewSet) router.register(r"position", views.PositionViewSet) -router.register(r"progression", views.ProgressionViewSet) +router.register(r"stop-status", views.VehicleStopStatusViewSet) router.register(r"occupancy", views.OccupancyViewSet) +router.register(r"congestion", views.CongestionLevelViewSet) # GTFS Schedule router.register(r"agency", views.AgencyViewSet) router.register(r"stops", views.StopViewSet) diff --git a/backend/api/views.py b/backend/api/views.py index bfa4c5a..d75ad01 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -28,8 +28,9 @@ Run, RunLifecycleTransition, Position, - Progression, - Occupancy, + VehicleStopStatus, + CongestionLevel, + OccupancyStatus, ) from feed.models import ( Feed, @@ -58,8 +59,9 @@ CreateRunSerializer, RunUpdateSerializer, PositionSerializer, - ProgressionSerializer, - OccupancySerializer, + VehicleStopStatusSerializer, + CongestionLevelSerializer, + OccupancyStatusSerializer, AgencySerializer, StopSerializer, GeoStopSerializer, @@ -375,15 +377,21 @@ class PositionViewSet(viewsets.ModelViewSet): authentication_classes = [TokenAuthentication] -class ProgressionViewSet(viewsets.ModelViewSet): - queryset = Progression.objects.all() - serializer_class = ProgressionSerializer +class VehicleStopStatusViewSet(viewsets.ModelViewSet): + queryset = VehicleStopStatus.objects.all() + serializer_class = VehicleStopStatusSerializer + authentication_classes = [TokenAuthentication] + + +class CongestionLevelViewSet(viewsets.ModelViewSet): + queryset = CongestionLevel.objects.all() + serializer_class = CongestionLevelSerializer authentication_classes = [TokenAuthentication] class OccupancyViewSet(viewsets.ModelViewSet): - queryset = Occupancy.objects.all() - serializer_class = OccupancySerializer + queryset = OccupancyStatus.objects.all() + serializer_class = OccupancyStatusSerializer authentication_classes = [TokenAuthentication] diff --git a/backend/feed/files/README.md b/backend/feed/files/README.md deleted file mode 100644 index 77c85bc..0000000 --- a/backend/feed/files/README.md +++ /dev/null @@ -1 +0,0 @@ -# GTFS files \ No newline at end of file diff --git a/backend/feed/views.py b/backend/feed/views.py index 3143652..eec06cb 100644 --- a/backend/feed/views.py +++ b/backend/feed/views.py @@ -11,21 +11,19 @@ def status(request): def schedule(request): - file_path = settings.BASE_DIR / "schedule_engine" / "files" / "bUCR_GTFS.zip" + file_path = settings.BASE_DIR / "feed" / "files" / "bUCR_GTFS.zip" return FileResponse(open(file_path, "rb"), filename="bUCR_GTFS.zip") @xframe_options_exempt def vehicle_json(request): - file_path = ( - settings.BASE_DIR / "schedule_engine" / "files" / "vehicle_positions.json" - ) + file_path = settings.BASE_DIR / "feed" / "files" / "vehicle_positions.json" return FileResponse(open(file_path, "rb"), filename="vehicle_positions.json") @xframe_options_exempt def vehicle_pb(request): - file_path = settings.BASE_DIR / "schedule_engine" / "files" / "vehicle_positions.pb" + file_path = settings.BASE_DIR / "feed" / "files" / "vehicle_positions.pb" return FileResponse( open(file_path, "rb"), as_attachment=True, filename="vehicle_positions.pb" ) @@ -33,13 +31,13 @@ def vehicle_pb(request): @xframe_options_exempt def trip_updates_json(request): - file_path = settings.BASE_DIR / "schedule_engine" / "files" / "trip_updates.json" + file_path = settings.BASE_DIR / "feed" / "files" / "trip_updates.json" return FileResponse(open(file_path, "rb"), filename="trip_updates.json") @xframe_options_exempt def trip_updates_pb(request): - file_path = settings.BASE_DIR / "schedule_engine" / "files" / "trip_updates.pb" + file_path = settings.BASE_DIR / "feed" / "files" / "trip_updates.pb" return FileResponse( open(file_path, "rb"), as_attachment=True, filename="trip_updates.pb" ) diff --git a/backend/runs/admin.py b/backend/runs/admin.py index 1e9f84b..be9c64a 100644 --- a/backend/runs/admin.py +++ b/backend/runs/admin.py @@ -1,14 +1,10 @@ from django.contrib.gis import admin -from .models import ( - Run, - Position, - Progression, - Occupancy, -) +from .models import Run, Position, VehicleStopStatus, CongestionLevel, OccupancyStatus # Register your models here. admin.site.register(Run) admin.site.register(Position, admin.GISModelAdmin) -admin.site.register(Progression) -admin.site.register(Occupancy) +admin.site.register(VehicleStopStatus) +admin.site.register(CongestionLevel) +admin.site.register(OccupancyStatus) diff --git a/backend/runs/models.py b/backend/runs/models.py index 4a18e50..54d321e 100644 --- a/backend/runs/models.py +++ b/backend/runs/models.py @@ -96,7 +96,7 @@ class Position(models.Model): is_new = models.BooleanField(default=True) -class Progression(models.Model): +class VehicleStopStatus(models.Model): vehicle = models.ForeignKey(Vehicle, on_delete=models.PROTECT) timestamp = models.DateTimeField(auto_now_add=True) current_stop_sequence = models.PositiveIntegerField(blank=True, null=True) @@ -111,6 +111,11 @@ class Progression(models.Model): ("IN_TRANSIT_TO", "En tránsito a la parada"), ], ) + + +class CongestionLevel(models.Model): + vehicle = models.ForeignKey(Vehicle, on_delete=models.PROTECT) + timestamp = models.DateTimeField(auto_now_add=True) congestion_level = models.CharField( max_length=100, blank=True, @@ -124,10 +129,8 @@ class Progression(models.Model): ], ) - is_new = models.BooleanField(default=True) - -class Occupancy(models.Model): +class OccupancyStatus(models.Model): vehicle = models.ForeignKey(Vehicle, on_delete=models.PROTECT) timestamp = models.DateTimeField(auto_now_add=True) occupancy_status = models.CharField( @@ -161,3 +164,31 @@ class Occupancy(models.Model): ) is_new = models.BooleanField(default=True) + + +""" +Mapping for GTFS Realtime VehiclePosition to our data model: + +Run: +- trip (Redis (hash): run::trip) +- vehicle (Redis (hash): run::vehicle) + +Position: +- position (Redis (hash): run::position) + +VehicleStopStatus: +- current_stop_sequence (Redis (string): run::current_stop_sequence) +- stop_id (Redis (string): run::stop_id) +- current_status (Redis (string): run::current_status) + +CongestionLevel: +- congestion_level (Redis (string): run::congestion_level) + +OccupancyStatus: +- occupancy_status (Redis (string): run::occupancy_status) +- occupancy_percentage (Redis (string): run::occupancy_percentage) + +Not mapped: +- multi_carriage_details (omitted) +- timestamp +""" diff --git a/backend/schedule_engine/tasks.py b/backend/schedule_engine/tasks.py index eeecb96..f221588 100644 --- a/backend/schedule_engine/tasks.py +++ b/backend/schedule_engine/tasks.py @@ -55,17 +55,21 @@ def build_vehicle_positions(): runs_in_progress = r.smembers("runs:in_progress") for run_id in runs_in_progress: - run = r.hgetall(f"run:{run_id}") + run = r.hgetall(f"run:{run_id}") # run::trip if not run: continue - vehicle_id = run.get("vehicle", "") + vehicle_id = run.get("vehicle", "") # run::vehicle if not vehicle_id: continue - position = r.hgetall(f"vehicle:{vehicle_id}:position") + position = r.hgetall( + f"vehicle:{vehicle_id}:position" + ) # run::position (hash) progression = r.hgetall(f"vehicle:{vehicle_id}:progression") occupancy = r.hgetall(f"vehicle:{vehicle_id}:occupancy") - vehicle_meta = r.hgetall(f"vehicle:{vehicle_id}:metadata") + vehicle_meta = r.hgetall( + f"vehicle:{vehicle_id}:metadata" + ) # run::vehicle if not position and not progression and not occupancy: continue From 49d095be56cd40b4ea7d43a08ee8097815813173 Mon Sep 17 00:00:00 2001 From: Jae Date: Mon, 8 Jun 2026 17:33:04 -0600 Subject: [PATCH 11/23] feat(telemetry): add GTFS-RT entity contract module + keys Introduce backend/runs/domain/telemetry/ as the single shared contract for the edge/server Redis entity division. One file per GTFS-RT entity (position, occupancy, vehicle_stop_status, congestion_level, trip) plus centralized Redis key templates (keys.py). Each entity exposes a tolerant from_redis (str->typed, drops bad optional values) and a strict validate_for_write (typed->str, raises on missing required / invalid enum) so producers and builders agree on field names and types. occupancy.classify_status centralizes the GTFS enum bucketing policy; trip.project_from_run_hash projects the flat run hash to the trip subset. Modules are import-light (no Django/Redis at top level) and unit-tested under plain pytest (89 tests). No producers/builders wired yet. --- backend/runs/domain/telemetry/__init__.py | 31 +++ .../runs/domain/telemetry/congestion_level.py | 95 ++++++++ backend/runs/domain/telemetry/keys.py | 102 ++++++++ backend/runs/domain/telemetry/occupancy.py | 173 +++++++++++++ backend/runs/domain/telemetry/position.py | 143 +++++++++++ .../runs/domain/telemetry/tests/__init__.py | 0 .../telemetry/tests/test_congestion_level.py | 88 +++++++ .../runs/domain/telemetry/tests/test_keys.py | 49 ++++ .../domain/telemetry/tests/test_occupancy.py | 197 +++++++++++++++ .../domain/telemetry/tests/test_position.py | 144 +++++++++++ .../runs/domain/telemetry/tests/test_trip.py | 227 ++++++++++++++++++ .../tests/test_vehicle_stop_status.py | 140 +++++++++++ backend/runs/domain/telemetry/trip.py | 169 +++++++++++++ .../domain/telemetry/vehicle_stop_status.py | 121 ++++++++++ 14 files changed, 1679 insertions(+) create mode 100644 backend/runs/domain/telemetry/__init__.py create mode 100644 backend/runs/domain/telemetry/congestion_level.py create mode 100644 backend/runs/domain/telemetry/keys.py create mode 100644 backend/runs/domain/telemetry/occupancy.py create mode 100644 backend/runs/domain/telemetry/position.py create mode 100644 backend/runs/domain/telemetry/tests/__init__.py create mode 100644 backend/runs/domain/telemetry/tests/test_congestion_level.py create mode 100644 backend/runs/domain/telemetry/tests/test_keys.py create mode 100644 backend/runs/domain/telemetry/tests/test_occupancy.py create mode 100644 backend/runs/domain/telemetry/tests/test_position.py create mode 100644 backend/runs/domain/telemetry/tests/test_trip.py create mode 100644 backend/runs/domain/telemetry/tests/test_vehicle_stop_status.py create mode 100644 backend/runs/domain/telemetry/trip.py create mode 100644 backend/runs/domain/telemetry/vehicle_stop_status.py diff --git a/backend/runs/domain/telemetry/__init__.py b/backend/runs/domain/telemetry/__init__.py new file mode 100644 index 0000000..b96a74a --- /dev/null +++ b/backend/runs/domain/telemetry/__init__.py @@ -0,0 +1,31 @@ +"""GTFS-RT entity contract package. + +This package is the single shared contract that both producers (write) and +builders (read) use for the Redis entity division. Each sub-module owns one +GTFS-RT entity: + +- :mod:`.keys` — Central Redis key templates (no hardcoded strings elsewhere). +- :mod:`.position` — ``vehicle::position`` hash (edge / MQTT). +- :mod:`.occupancy` — ``vehicle::occupancy`` hash (edge / MQTT). +- :mod:`.vehicle_stop_status` — ``run::vehicle_stop_status`` hash (server). +- :mod:`.congestion_level` — ``run::congestion_level`` hash (server, deferred). +- :mod:`.trip` — ``run::trip`` hash + projection helper (server). + +All modules are import-light (no Django, no Redis at module top level) so they +are safe to use in unit tests under plain pytest. + +Import sub-modules directly for clarity:: + + from runs.domain.telemetry import keys, position, occupancy + from runs.domain.telemetry.occupancy import classify_status + from runs.domain.telemetry.trip import project_from_run_hash +""" + +from runs.domain.telemetry import ( # noqa: F401 + keys, + position, + occupancy, + vehicle_stop_status, + congestion_level, + trip, +) diff --git a/backend/runs/domain/telemetry/congestion_level.py b/backend/runs/domain/telemetry/congestion_level.py new file mode 100644 index 0000000..f398a68 --- /dev/null +++ b/backend/runs/domain/telemetry/congestion_level.py @@ -0,0 +1,95 @@ +"""GTFS-RT CongestionLevel entity contract — STUB. + +Covers the ``run::congestion_level`` Redis hash. + +Producer: server / external (deferred — no producer yet; key reserved). +Consumer: ``schedule_engine/tasks.py::build_vehicle_positions``. + +A single bus's speed is a weak signal for congestion; an honest estimate needs +fleet aggregation or a traffic feed. The key is reserved and the contract is +defined here for consistency so builders can safely attempt a read without +knowing whether the hash exists. + +Field types +----------- +- ``congestion_level`` : str (enum) (required for write, absent in read is tolerated) + +Enum values match ``runs/models.py::CongestionLevel.congestion_level`` choices: + UNKNOWN_CONGESTION_LEVEL, RUNNING_SMOOTHLY, STOP_AND_GO, + CONGESTION, SEVERE_CONGESTION + +This module imports nothing so it is safe to import from any layer without +pulling in Django or Redis. +""" + +# --------------------------------------------------------------------------- +# Field-name constants +# --------------------------------------------------------------------------- + +CONGESTION_LEVEL = "congestion_level" + +# --------------------------------------------------------------------------- +# Enum value set (must match runs/models.py CongestionLevel.congestion_level) +# --------------------------------------------------------------------------- + +VALID_LEVELS: frozenset[str] = frozenset( + { + "UNKNOWN_CONGESTION_LEVEL", + "RUNNING_SMOOTHLY", + "STOP_AND_GO", + "CONGESTION", + "SEVERE_CONGESTION", + } +) + + +# --------------------------------------------------------------------------- +# Reader: str → typed (tolerant) +# --------------------------------------------------------------------------- + + +def from_redis(hash: dict) -> dict: + """Convert a raw Redis congestion_level hash (all strings) to typed Python. + + Tolerant: if the hash is empty or the field is absent, returns an empty + dict (the hash may not be set yet — the producer is deferred). + Invalid enum values are passed through as-is (tolerant read). + """ + result: dict = {} + + raw = hash.get(CONGESTION_LEVEL) + if raw is not None and raw != "": + result[CONGESTION_LEVEL] = raw + + return result + + +# --------------------------------------------------------------------------- +# Writer: typed → str (strict) +# --------------------------------------------------------------------------- + + +def validate_for_write(payload: dict) -> dict[str, str]: + """Validate a congestion_level payload and return a Redis-ready mapping. + + Strict: + - ``congestion_level`` must be a valid enum value; raises ``ValueError`` + on mismatch. + - ``congestion_level`` is required; raises ``ValueError`` if missing. + """ + result: dict[str, str] = {} + + level = payload.get(CONGESTION_LEVEL) + if level is None: + raise ValueError( + f"congestion_level.validate_for_write: required field " + f"'{CONGESTION_LEVEL}' is missing" + ) + if level not in VALID_LEVELS: + raise ValueError( + f"congestion_level.validate_for_write: '{level}' is not a valid " + f"congestion_level; expected one of {sorted(VALID_LEVELS)}" + ) + result[CONGESTION_LEVEL] = level + + return result diff --git a/backend/runs/domain/telemetry/keys.py b/backend/runs/domain/telemetry/keys.py new file mode 100644 index 0000000..09b8f57 --- /dev/null +++ b/backend/runs/domain/telemetry/keys.py @@ -0,0 +1,102 @@ +"""Central Redis key templates for the GTFS-RT entity division. + +No other module should hardcode these key strings. Import from here. + +Ownership is visible in the namespace: +- Edge-sensed data → ``vehicle::*`` +- Server-computed → ``run::*`` or ``runs:*`` + +This module imports nothing, keeping it safe to import from any layer +without pulling in Django or Redis. +""" + + +# --------------------------------------------------------------------------- +# Vehicle-keyed keys (edge / MQTT producers) +# --------------------------------------------------------------------------- + + +def position_key(vehicle_id: str) -> str: + """Redis hash: GPS and motion data from the edge. + + Fields: latitude, longitude, bearing?, speed?, odometer?, timestamp? + """ + return f"vehicle:{vehicle_id}:position" + + +def occupancy_key(vehicle_id: str) -> str: + """Redis hash: Passenger load data from the edge. + + Fields: occupancy_percentage?, occupancy_count?, occupancy_status + """ + return f"vehicle:{vehicle_id}:occupancy" + + +def metadata_key(vehicle_id: str) -> str: + """Redis hash: Vehicle descriptor data (server-enriched on run start). + + Fields: id, label, license_plate?, wheelchair_accessible? + """ + return f"vehicle:{vehicle_id}:metadata" + + +def current_run_key(vehicle_id: str) -> str: + """Redis string: The run_id currently assigned to this vehicle. + + Written by lifecycle action; used by ingestion to route telemetry. + """ + return f"vehicle:{vehicle_id}:current_run" + + +# --------------------------------------------------------------------------- +# Run-keyed keys (server producers) +# --------------------------------------------------------------------------- + + +def trip_key(run_id: str) -> str: + """Redis hash: GTFS-RT TripDescriptor for the assigned trip. + + Fields: trip_id, route_id, direction_id?, schedule_relationship?, + start_time?, start_date? + + This is a GTFS-RT-shaped projection of the trip subset from run:. + Written by the lifecycle action. + """ + return f"run:{run_id}:trip" + + +def stop_status_key(run_id: str) -> str: + """Redis hash: Current vehicle-stop relationship for the run. + + Fields: current_stop_sequence?, stop_id?, current_status + Written by the server progression step. + """ + return f"run:{run_id}:vehicle_stop_status" + + +def congestion_key(run_id: str) -> str: + """Redis hash: Congestion level for the run (deferred; producer TBD). + + Fields: congestion_level + """ + return f"run:{run_id}:congestion_level" + + +def run_key(run_id: str) -> str: + """Redis hash: Full run assignment / lifecycle record. + + Contains: trip_id, route_id, direction_id, shape_id, + schedule_relationship, start_time, start_date, + vehicle, operator, run_lifecycle_state, … + + ``run::trip`` is the GTFS-RT-shaped projection of its trip subset. + """ + return f"run:{run_id}" + + +def last_seen_key(run_id: str) -> str: + """Redis string: ISO-8601 timestamp of the last telemetry received for this run. + + Updated by the MQTT consumer on every message to enable stale detection. + """ + return f"runs:last_seen:{run_id}" diff --git a/backend/runs/domain/telemetry/occupancy.py b/backend/runs/domain/telemetry/occupancy.py new file mode 100644 index 0000000..0ec90f5 --- /dev/null +++ b/backend/runs/domain/telemetry/occupancy.py @@ -0,0 +1,173 @@ +"""GTFS-RT OccupancyStatus entity contract. + +Covers the ``vehicle::occupancy`` Redis hash. + +Producer: edge / MQTT (``realtime_engine/mqtt.py``), with the enum value +bucketed at ingestion time using :func:`classify_status` (server policy). +Consumer: ``schedule_engine/tasks.py::build_vehicle_positions``. + +Field types +----------- +- ``occupancy_percentage`` : int? (optional) +- ``occupancy_count`` : int? (optional) +- ``occupancy_status`` : str (enum) (required) + +Enum values match ``runs/models.py::OccupancyStatus.occupancy_status`` choices: + EMPTY, MANY_SEATS_AVAILABLE, FEW_SEATS_AVAILABLE, STANDING_ROOM_ONLY, + CRUSHED_STANDING_ROOM_ONLY, FULL, NOT_ACCEPTING_PASSENGERS, + NO_DATA_AVAILABLE, NOT_BOARDABLE + +Occupancy bucketing policy (:func:`classify_status`) +----------------------------------------------------- +The ``occupancy_status`` enum value is a *server-side policy* decision, not a +raw sensor reading. The thresholds below are adopted from the simulator's +``kinematics.occupancy_status`` as the agreed starting point: + + percentage < 20 → MANY_SEATS_AVAILABLE + percentage < 50 → FEW_SEATS_AVAILABLE + percentage < 80 → STANDING_ROOM_ONLY + percentage >= 80 → FULL + percentage is None → NO_DATA_AVAILABLE + +These bands cover only 4 of the 9 GTFS enum values. The remaining 5 +(EMPTY, CRUSHED_STANDING_ROOM_ONLY, NOT_ACCEPTING_PASSENGERS, NOT_BOARDABLE, +and the boundary between FULL and NOT_ACCEPTING_PASSENGERS) require product +input before they can be assigned threshold ranges. + +This module imports nothing so it is safe to import from any layer without +pulling in Django or Redis. +""" + +# --------------------------------------------------------------------------- +# Field-name constants +# --------------------------------------------------------------------------- + +OCCUPANCY_PERCENTAGE = "occupancy_percentage" +OCCUPANCY_COUNT = "occupancy_count" +OCCUPANCY_STATUS = "occupancy_status" + +# --------------------------------------------------------------------------- +# Enum value set (must match runs/models.py OccupancyStatus.occupancy_status) +# --------------------------------------------------------------------------- + +VALID_STATUSES: frozenset[str] = frozenset( + { + "EMPTY", + "MANY_SEATS_AVAILABLE", + "FEW_SEATS_AVAILABLE", + "STANDING_ROOM_ONLY", + "CRUSHED_STANDING_ROOM_ONLY", + "FULL", + "NOT_ACCEPTING_PASSENGERS", + "NO_DATA_AVAILABLE", + "NOT_BOARDABLE", + } +) + +_OPTIONAL_INT_FIELDS = (OCCUPANCY_PERCENTAGE, OCCUPANCY_COUNT) + + +# --------------------------------------------------------------------------- +# Bucketing policy +# --------------------------------------------------------------------------- + + +def classify_status(percentage: int | None) -> str: + """Map a raw occupancy percentage to the GTFS OccupancyStatus enum value. + + This is the centralised bucketing policy. Thresholds are adopted from + the simulator's ``kinematics.occupancy_status``: + + - ``None`` → ``NO_DATA_AVAILABLE`` + - ``< 20`` → ``MANY_SEATS_AVAILABLE`` + - ``< 50`` → ``FEW_SEATS_AVAILABLE`` + - ``< 80`` → ``STANDING_ROOM_ONLY`` + - ``>= 80`` → ``FULL`` + + Note: Only 4 of the 9 GTFS enum values are covered. The remaining values + (EMPTY, CRUSHED_STANDING_ROOM_ONLY, NOT_ACCEPTING_PASSENGERS, NOT_BOARDABLE) + require product input before threshold ranges can be assigned. + """ + if percentage is None: + return "NO_DATA_AVAILABLE" + if percentage < 20: + return "MANY_SEATS_AVAILABLE" + if percentage < 50: + return "FEW_SEATS_AVAILABLE" + if percentage < 80: + return "STANDING_ROOM_ONLY" + return "FULL" + + +# --------------------------------------------------------------------------- +# Reader: str → typed (tolerant) +# --------------------------------------------------------------------------- + + +def from_redis(hash: dict) -> dict: + """Convert a raw Redis occupancy hash (all values are strings) to typed Python. + + Tolerant: absent keys are omitted; values that fail coercion for optional + fields are silently dropped. The ``occupancy_status`` field is passed + through as-is if present; invalid values are dropped (tolerant read). + """ + result: dict = {} + + for field in _OPTIONAL_INT_FIELDS: + raw = hash.get(field) + if raw is not None and raw != "": + try: + result[field] = int(raw) + except (ValueError, TypeError): + pass # tolerant: drop bad optional values + + raw_status = hash.get(OCCUPANCY_STATUS) + if raw_status is not None and raw_status != "": + # Tolerant on read: accept any value present in Redis + result[OCCUPANCY_STATUS] = raw_status + + return result + + +# --------------------------------------------------------------------------- +# Writer: typed → str (strict) +# --------------------------------------------------------------------------- + + +def validate_for_write(payload: dict) -> dict[str, str]: + """Validate an occupancy payload and return a Redis-ready ``{field: str}`` mapping. + + Strict: + - ``occupancy_status`` must be a valid enum value; raises ``ValueError`` + on mismatch. + - Optional int fields raise ``ValueError`` if present but non-coercible. + - ``occupancy_status`` is required; raises ``ValueError`` if missing. + """ + result: dict[str, str] = {} + + # Optional int fields + for field in _OPTIONAL_INT_FIELDS: + value = payload.get(field) + if value is not None: + try: + result[field] = str(int(value)) + except (ValueError, TypeError) as exc: + raise ValueError( + f"occupancy.validate_for_write: optional field '{field}' cannot " + f"be coerced to int: {value!r}" + ) from exc + + # Required enum field + status = payload.get(OCCUPANCY_STATUS) + if status is None: + raise ValueError( + f"occupancy.validate_for_write: required field '{OCCUPANCY_STATUS}' is missing" + ) + if status not in VALID_STATUSES: + raise ValueError( + f"occupancy.validate_for_write: '{status}' is not a valid " + f"occupancy_status; expected one of {sorted(VALID_STATUSES)}" + ) + result[OCCUPANCY_STATUS] = status + + return result diff --git a/backend/runs/domain/telemetry/position.py b/backend/runs/domain/telemetry/position.py new file mode 100644 index 0000000..d53aead --- /dev/null +++ b/backend/runs/domain/telemetry/position.py @@ -0,0 +1,143 @@ +"""GTFS-RT Position entity contract. + +Covers the ``vehicle::position`` Redis hash. + +Producer: edge / MQTT (``realtime_engine/mqtt.py``). +Consumer: ``schedule_engine/tasks.py::build_vehicle_positions``. + +Field types +----------- +- ``latitude`` : float (required) +- ``longitude`` : float (required) +- ``bearing`` : float? (optional) +- ``speed`` : float? (optional) +- ``odometer`` : float? (optional) +- ``timestamp`` : int? (optional) — Unix epoch seconds. + +Note on ``timestamp``: it lives in this hash because that is where the edge +writes it, but in GTFS-RT the value is the top-level ``VehiclePosition.timestamp`` +(the ``Position`` sub-message has no timestamp field). The builder is responsible +for lifting it out of the position dict; this contract does not lift it. + +This module imports nothing so it is safe to import from any layer without +pulling in Django or Redis. +""" + +# --------------------------------------------------------------------------- +# Field-name constants +# --------------------------------------------------------------------------- + +LATITUDE = "latitude" +LONGITUDE = "longitude" +BEARING = "bearing" +SPEED = "speed" +ODOMETER = "odometer" +TIMESTAMP = "timestamp" + +_REQUIRED_FIELDS = (LATITUDE, LONGITUDE) +_OPTIONAL_FLOAT_FIELDS = (BEARING, SPEED, ODOMETER) +_OPTIONAL_INT_FIELDS = (TIMESTAMP,) + + +# --------------------------------------------------------------------------- +# Reader: str → typed (tolerant) +# --------------------------------------------------------------------------- + + +def from_redis(hash: dict) -> dict: + """Convert a raw Redis position hash (all values are strings) to typed Python. + + Tolerant: absent keys are omitted from the result; values that fail + coercion for optional fields are silently dropped (callers should handle + missing keys with ``.get()``). + + Required fields (``latitude``, ``longitude``) that are present but + non-coercible raise ``ValueError`` — the hash is corrupt. + """ + result: dict = {} + + for field in _REQUIRED_FIELDS: + raw = hash.get(field) + if raw is not None: + try: + result[field] = float(raw) + except (ValueError, TypeError) as exc: + raise ValueError( + f"position.from_redis: required field '{field}' is not a " + f"valid float: {raw!r}" + ) from exc + + for field in _OPTIONAL_FLOAT_FIELDS: + raw = hash.get(field) + if raw is not None and raw != "": + try: + result[field] = float(raw) + except (ValueError, TypeError): + pass # tolerant: drop bad optional values + + for field in _OPTIONAL_INT_FIELDS: + raw = hash.get(field) + if raw is not None and raw != "": + try: + result[field] = int(raw) + except (ValueError, TypeError): + pass # tolerant: drop bad optional values + + return result + + +# --------------------------------------------------------------------------- +# Writer: typed → str (strict) +# --------------------------------------------------------------------------- + + +def validate_for_write(payload: dict) -> dict[str, str]: + """Validate a position payload and return a Redis-ready ``{field: str}`` mapping. + + Strict: raises ``ValueError`` if a required field is missing or + non-coercible. Optional fields that are absent are simply omitted from + the output mapping; those that fail coercion also raise ``ValueError`` + (the caller is responsible for sending clean data). + """ + result: dict[str, str] = {} + + # Required fields + for field in _REQUIRED_FIELDS: + value = payload.get(field) + if value is None: + raise ValueError( + f"position.validate_for_write: required field '{field}' is missing" + ) + try: + result[field] = str(float(value)) + except (ValueError, TypeError) as exc: + raise ValueError( + f"position.validate_for_write: required field '{field}' cannot be " + f"coerced to float: {value!r}" + ) from exc + + # Optional float fields + for field in _OPTIONAL_FLOAT_FIELDS: + value = payload.get(field) + if value is not None: + try: + result[field] = str(float(value)) + except (ValueError, TypeError) as exc: + raise ValueError( + f"position.validate_for_write: optional field '{field}' cannot " + f"be coerced to float: {value!r}" + ) from exc + + # Optional int fields + for field in _OPTIONAL_INT_FIELDS: + value = payload.get(field) + if value is not None: + try: + result[field] = str(int(value)) + except (ValueError, TypeError) as exc: + raise ValueError( + f"position.validate_for_write: optional field '{field}' cannot " + f"be coerced to int: {value!r}" + ) from exc + + return result diff --git a/backend/runs/domain/telemetry/tests/__init__.py b/backend/runs/domain/telemetry/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/runs/domain/telemetry/tests/test_congestion_level.py b/backend/runs/domain/telemetry/tests/test_congestion_level.py new file mode 100644 index 0000000..7f9e4b8 --- /dev/null +++ b/backend/runs/domain/telemetry/tests/test_congestion_level.py @@ -0,0 +1,88 @@ +"""Pure unit tests for the congestion_level entity contract (stub). + +No Django, no Redis — runnable under plain pytest. +""" + +import pytest + +from runs.domain.telemetry import congestion_level +from runs.domain.telemetry.congestion_level import VALID_LEVELS + + +# --------------------------------------------------------------------------- +# valid_levels matches models.py exactly +# --------------------------------------------------------------------------- + + +def test_valid_levels_contains_all_five_gtfs_values(): + expected = { + "UNKNOWN_CONGESTION_LEVEL", + "RUNNING_SMOOTHLY", + "STOP_AND_GO", + "CONGESTION", + "SEVERE_CONGESTION", + } + assert VALID_LEVELS == expected + + +# --------------------------------------------------------------------------- +# Round-trip tests +# --------------------------------------------------------------------------- + + +def test_round_trip_congestion(): + payload = {"congestion_level": "CONGESTION"} + redis_map = congestion_level.validate_for_write(payload) + result = congestion_level.from_redis(redis_map) + assert result["congestion_level"] == "CONGESTION" + + +def test_round_trip_all_valid_levels(): + for level in VALID_LEVELS: + redis_map = congestion_level.validate_for_write({"congestion_level": level}) + result = congestion_level.from_redis(redis_map) + assert result["congestion_level"] == level + + +# --------------------------------------------------------------------------- +# from_redis — tolerant behaviour +# --------------------------------------------------------------------------- + + +def test_from_redis_empty_hash_returns_empty_dict(): + result = congestion_level.from_redis({}) + assert result == {} + + +def test_from_redis_omits_empty_string_value(): + hash_ = {"congestion_level": ""} + result = congestion_level.from_redis(hash_) + assert "congestion_level" not in result + + +def test_from_redis_tolerant_on_invalid_value(): + # Reads are tolerant — pass through unknown values + hash_ = {"congestion_level": "INVENTED_LEVEL"} + result = congestion_level.from_redis(hash_) + assert result["congestion_level"] == "INVENTED_LEVEL" + + +# --------------------------------------------------------------------------- +# validate_for_write — strict validation +# --------------------------------------------------------------------------- + + +def test_validate_for_write_raises_on_missing_level(): + with pytest.raises(ValueError, match="congestion_level"): + congestion_level.validate_for_write({}) + + +def test_validate_for_write_raises_on_invalid_enum(): + with pytest.raises(ValueError, match="INVENTED_LEVEL"): + congestion_level.validate_for_write({"congestion_level": "INVENTED_LEVEL"}) + + +def test_validate_for_write_output_all_strings(): + result = congestion_level.validate_for_write({"congestion_level": "STOP_AND_GO"}) + for k, v in result.items(): + assert isinstance(v, str), f"Field '{k}' is not a str: {v!r}" diff --git a/backend/runs/domain/telemetry/tests/test_keys.py b/backend/runs/domain/telemetry/tests/test_keys.py new file mode 100644 index 0000000..732e1e4 --- /dev/null +++ b/backend/runs/domain/telemetry/tests/test_keys.py @@ -0,0 +1,49 @@ +"""Pure unit tests for the telemetry key templates. + +No Django, no Redis — runnable under plain pytest. +""" + +from runs.domain.telemetry import keys + + +def test_position_key(): + assert keys.position_key("v42") == "vehicle:v42:position" + + +def test_occupancy_key(): + assert keys.occupancy_key("v42") == "vehicle:v42:occupancy" + + +def test_metadata_key(): + assert keys.metadata_key("v42") == "vehicle:v42:metadata" + + +def test_current_run_key(): + assert keys.current_run_key("v42") == "vehicle:v42:current_run" + + +def test_trip_key(): + assert keys.trip_key("r99") == "run:r99:trip" + + +def test_stop_status_key(): + assert keys.stop_status_key("r99") == "run:r99:vehicle_stop_status" + + +def test_congestion_key(): + assert keys.congestion_key("r99") == "run:r99:congestion_level" + + +def test_run_key(): + assert keys.run_key("r99") == "run:r99" + + +def test_last_seen_key(): + assert keys.last_seen_key("r99") == "runs:last_seen:r99" + + +def test_keys_use_string_representation_of_id(): + """Keys should work with any string representation of an id.""" + uid = "550e8400-e29b-41d4-a716-446655440000" + assert keys.run_key(uid) == f"run:{uid}" + assert keys.position_key(uid) == f"vehicle:{uid}:position" diff --git a/backend/runs/domain/telemetry/tests/test_occupancy.py b/backend/runs/domain/telemetry/tests/test_occupancy.py new file mode 100644 index 0000000..cb45097 --- /dev/null +++ b/backend/runs/domain/telemetry/tests/test_occupancy.py @@ -0,0 +1,197 @@ +"""Pure unit tests for the occupancy entity contract and classify_status. + +No Django, no Redis — runnable under plain pytest. +""" + +import pytest + +from runs.domain.telemetry import occupancy +from runs.domain.telemetry.occupancy import classify_status, VALID_STATUSES + + +# --------------------------------------------------------------------------- +# classify_status — threshold boundary tests +# --------------------------------------------------------------------------- + + +def test_classify_status_none_returns_no_data(): + assert classify_status(None) == "NO_DATA_AVAILABLE" + + +def test_classify_status_zero(): + assert classify_status(0) == "MANY_SEATS_AVAILABLE" + + +def test_classify_status_19_boundary(): + assert classify_status(19) == "MANY_SEATS_AVAILABLE" + + +def test_classify_status_20_boundary(): + assert classify_status(20) == "FEW_SEATS_AVAILABLE" + + +def test_classify_status_49_boundary(): + assert classify_status(49) == "FEW_SEATS_AVAILABLE" + + +def test_classify_status_50_boundary(): + assert classify_status(50) == "STANDING_ROOM_ONLY" + + +def test_classify_status_79_boundary(): + assert classify_status(79) == "STANDING_ROOM_ONLY" + + +def test_classify_status_80_boundary(): + assert classify_status(80) == "FULL" + + +def test_classify_status_100(): + assert classify_status(100) == "FULL" + + +def test_classify_status_above_100(): + # Edge: values above 100 are still mapped to FULL (no upper clamp) + assert classify_status(150) == "FULL" + + +def test_classify_status_mid_range(): + assert classify_status(35) == "FEW_SEATS_AVAILABLE" + assert classify_status(65) == "STANDING_ROOM_ONLY" + + +# --------------------------------------------------------------------------- +# valid_statuses matches models.py exactly +# --------------------------------------------------------------------------- + + +def test_valid_statuses_contains_all_nine_gtfs_values(): + expected = { + "EMPTY", + "MANY_SEATS_AVAILABLE", + "FEW_SEATS_AVAILABLE", + "STANDING_ROOM_ONLY", + "CRUSHED_STANDING_ROOM_ONLY", + "FULL", + "NOT_ACCEPTING_PASSENGERS", + "NO_DATA_AVAILABLE", + "NOT_BOARDABLE", + } + assert VALID_STATUSES == expected + + +# --------------------------------------------------------------------------- +# Round-trip tests +# --------------------------------------------------------------------------- + + +def test_round_trip_with_status_only(): + payload = {"occupancy_status": "FULL"} + redis_map = occupancy.validate_for_write(payload) + result = occupancy.from_redis(redis_map) + assert result["occupancy_status"] == "FULL" + assert "occupancy_percentage" not in result + assert "occupancy_count" not in result + + +def test_round_trip_all_fields(): + payload = { + "occupancy_status": "STANDING_ROOM_ONLY", + "occupancy_percentage": 65, + "occupancy_count": 42, + } + redis_map = occupancy.validate_for_write(payload) + result = occupancy.from_redis(redis_map) + assert result["occupancy_status"] == "STANDING_ROOM_ONLY" + assert result["occupancy_percentage"] == 65 + assert result["occupancy_count"] == 42 + + +# --------------------------------------------------------------------------- +# Coercion edge cases (from_redis) +# --------------------------------------------------------------------------- + + +def test_from_redis_coerces_string_ints(): + hash_ = { + "occupancy_status": "FEW_SEATS_AVAILABLE", + "occupancy_percentage": "30", + "occupancy_count": "20", + } + result = occupancy.from_redis(hash_) + assert result["occupancy_percentage"] == 30 + assert result["occupancy_count"] == 20 + assert isinstance(result["occupancy_percentage"], int) + + +def test_from_redis_omits_missing_optional_fields(): + hash_ = {"occupancy_status": "EMPTY"} + result = occupancy.from_redis(hash_) + assert "occupancy_percentage" not in result + assert "occupancy_count" not in result + + +def test_from_redis_drops_bad_optional_int_silently(): + hash_ = { + "occupancy_status": "FULL", + "occupancy_percentage": "not-a-number", + } + result = occupancy.from_redis(hash_) + assert "occupancy_percentage" not in result + + +def test_from_redis_drops_empty_string_optional_fields(): + hash_ = { + "occupancy_status": "FULL", + "occupancy_percentage": "", + "occupancy_count": "", + } + result = occupancy.from_redis(hash_) + assert "occupancy_percentage" not in result + assert "occupancy_count" not in result + + +def test_from_redis_tolerant_on_invalid_status(): + # Reads are tolerant — pass through unknown values rather than raising + hash_ = {"occupancy_status": "INVENTED_VALUE"} + result = occupancy.from_redis(hash_) + assert result["occupancy_status"] == "INVENTED_VALUE" + + +# --------------------------------------------------------------------------- +# validate_for_write — strict validation +# --------------------------------------------------------------------------- + + +def test_validate_for_write_raises_on_missing_status(): + with pytest.raises(ValueError, match="occupancy_status"): + occupancy.validate_for_write({"occupancy_percentage": 50}) + + +def test_validate_for_write_raises_on_invalid_enum(): + with pytest.raises(ValueError, match="INVENTED_STATUS"): + occupancy.validate_for_write({"occupancy_status": "INVENTED_STATUS"}) + + +def test_validate_for_write_raises_on_bad_optional_int(): + with pytest.raises(ValueError, match="occupancy_percentage"): + occupancy.validate_for_write( + {"occupancy_status": "FULL", "occupancy_percentage": "bad"} + ) + + +def test_validate_for_write_output_all_strings(): + payload = { + "occupancy_status": "MANY_SEATS_AVAILABLE", + "occupancy_percentage": 10, + "occupancy_count": 5, + } + result = occupancy.validate_for_write(payload) + for k, v in result.items(): + assert isinstance(v, str), f"Field '{k}' is not a str: {v!r}" + + +def test_validate_for_write_accepts_all_valid_enum_values(): + for status in VALID_STATUSES: + result = occupancy.validate_for_write({"occupancy_status": status}) + assert result["occupancy_status"] == status diff --git a/backend/runs/domain/telemetry/tests/test_position.py b/backend/runs/domain/telemetry/tests/test_position.py new file mode 100644 index 0000000..2f0a510 --- /dev/null +++ b/backend/runs/domain/telemetry/tests/test_position.py @@ -0,0 +1,144 @@ +"""Pure unit tests for the position entity contract. + +No Django, no Redis — runnable under plain pytest. +""" + +import pytest + +from runs.domain.telemetry import position + + +# --------------------------------------------------------------------------- +# Round-trip tests +# --------------------------------------------------------------------------- + + +def test_round_trip_required_fields_only(): + payload = {"latitude": 51.5074, "longitude": -0.1278} + redis_map = position.validate_for_write(payload) + result = position.from_redis(redis_map) + assert result["latitude"] == pytest.approx(51.5074) + assert result["longitude"] == pytest.approx(-0.1278) + # Optional fields absent + assert "bearing" not in result + assert "speed" not in result + assert "timestamp" not in result + + +def test_round_trip_all_fields(): + payload = { + "latitude": 51.5074, + "longitude": -0.1278, + "bearing": 270.5, + "speed": 12.3, + "odometer": 1234.56, + "timestamp": 1700000000, + } + redis_map = position.validate_for_write(payload) + result = position.from_redis(redis_map) + assert result["latitude"] == pytest.approx(51.5074) + assert result["longitude"] == pytest.approx(-0.1278) + assert result["bearing"] == pytest.approx(270.5) + assert result["speed"] == pytest.approx(12.3) + assert result["odometer"] == pytest.approx(1234.56) + assert result["timestamp"] == 1700000000 + + +# --------------------------------------------------------------------------- +# Coercion edge cases (from_redis) +# --------------------------------------------------------------------------- + + +def test_from_redis_coerces_string_floats(): + hash_ = {"latitude": "51.5074", "longitude": "-0.1278", "speed": "12.3"} + result = position.from_redis(hash_) + assert result["latitude"] == pytest.approx(51.5074) + assert result["speed"] == pytest.approx(12.3) + + +def test_from_redis_coerces_string_int_timestamp(): + hash_ = {"latitude": "0.0", "longitude": "0.0", "timestamp": "1700000000"} + result = position.from_redis(hash_) + assert result["timestamp"] == 1700000000 + assert isinstance(result["timestamp"], int) + + +def test_from_redis_omits_missing_optional_fields(): + hash_ = {"latitude": "1.0", "longitude": "2.0"} + result = position.from_redis(hash_) + assert "speed" not in result + assert "bearing" not in result + assert "odometer" not in result + assert "timestamp" not in result + + +def test_from_redis_drops_bad_optional_float_silently(): + hash_ = {"latitude": "1.0", "longitude": "2.0", "speed": "not-a-float"} + result = position.from_redis(hash_) + assert "speed" not in result + + +def test_from_redis_drops_bad_optional_int_silently(): + hash_ = {"latitude": "1.0", "longitude": "2.0", "timestamp": "not-an-int"} + result = position.from_redis(hash_) + assert "timestamp" not in result + + +def test_from_redis_drops_empty_string_optional_fields(): + hash_ = {"latitude": "1.0", "longitude": "2.0", "bearing": ""} + result = position.from_redis(hash_) + assert "bearing" not in result + + +def test_from_redis_raises_on_bad_required_field(): + hash_ = {"latitude": "not-a-float", "longitude": "0.0"} + with pytest.raises(ValueError, match="latitude"): + position.from_redis(hash_) + + +# --------------------------------------------------------------------------- +# validate_for_write — strict validation +# --------------------------------------------------------------------------- + + +def test_validate_for_write_raises_on_missing_required_field(): + with pytest.raises(ValueError, match="latitude"): + position.validate_for_write({"longitude": 0.0}) + + +def test_validate_for_write_raises_on_bad_required_float(): + with pytest.raises(ValueError, match="longitude"): + position.validate_for_write({"latitude": 1.0, "longitude": "bad"}) + + +def test_validate_for_write_raises_on_bad_optional_float(): + with pytest.raises(ValueError, match="speed"): + position.validate_for_write({"latitude": 1.0, "longitude": 2.0, "speed": "bad"}) + + +def test_validate_for_write_raises_on_bad_optional_int(): + with pytest.raises(ValueError, match="timestamp"): + position.validate_for_write( + {"latitude": 1.0, "longitude": 2.0, "timestamp": "bad"} + ) + + +def test_validate_for_write_output_all_strings(): + payload = { + "latitude": 51.5074, + "longitude": -0.1278, + "bearing": 270.5, + "speed": 12.3, + "odometer": 1234.56, + "timestamp": 1700000000, + } + result = position.validate_for_write(payload) + for k, v in result.items(): + assert isinstance(v, str), f"Field '{k}' is not a str: {v!r}" + + +def test_validate_for_write_accepts_integer_coords(): + # Integer lat/lon should be accepted (coerced to float) + result = position.validate_for_write({"latitude": 51, "longitude": -1}) + assert result["latitude"] == "51.0" + assert result["longitude"] == "-1.0" diff --git a/backend/runs/domain/telemetry/tests/test_trip.py b/backend/runs/domain/telemetry/tests/test_trip.py new file mode 100644 index 0000000..22fbfcf --- /dev/null +++ b/backend/runs/domain/telemetry/tests/test_trip.py @@ -0,0 +1,227 @@ +"""Pure unit tests for the trip entity contract. + +No Django, no Redis — runnable under plain pytest. +""" + +import pytest + +from runs.domain.telemetry import trip +from runs.domain.telemetry.trip import TRIP_FIELDS, project_from_run_hash + + +# --------------------------------------------------------------------------- +# TRIP_FIELDS constant +# --------------------------------------------------------------------------- + + +def test_trip_fields_contains_all_descriptor_fields(): + expected = { + "trip_id", + "route_id", + "direction_id", + "schedule_relationship", + "start_time", + "start_date", + } + assert TRIP_FIELDS == expected + + +# --------------------------------------------------------------------------- +# Round-trip tests +# --------------------------------------------------------------------------- + + +def test_round_trip_required_fields_only(): + payload = {"trip_id": "trip_001", "route_id": "route_A"} + redis_map = trip.validate_for_write(payload) + result = trip.from_redis(redis_map) + assert result["trip_id"] == "trip_001" + assert result["route_id"] == "route_A" + assert "direction_id" not in result + assert "schedule_relationship" not in result + + +def test_round_trip_all_fields(): + payload = { + "trip_id": "trip_001", + "route_id": "route_A", + "direction_id": 1, + "schedule_relationship": "SCHEDULED", + "start_time": "08:00:00", + "start_date": "20260608", + } + redis_map = trip.validate_for_write(payload) + result = trip.from_redis(redis_map) + assert result["trip_id"] == "trip_001" + assert result["route_id"] == "route_A" + assert result["direction_id"] == 1 + assert isinstance(result["direction_id"], int) + assert result["schedule_relationship"] == "SCHEDULED" + assert result["start_time"] == "08:00:00" + assert result["start_date"] == "20260608" + + +def test_round_trip_direction_id_zero(): + payload = {"trip_id": "t1", "route_id": "r1", "direction_id": 0} + redis_map = trip.validate_for_write(payload) + result = trip.from_redis(redis_map) + assert result["direction_id"] == 0 + + +# --------------------------------------------------------------------------- +# Coercion edge cases (from_redis) +# --------------------------------------------------------------------------- + + +def test_from_redis_coerces_string_direction_id(): + hash_ = {"trip_id": "t1", "route_id": "r1", "direction_id": "1"} + result = trip.from_redis(hash_) + assert result["direction_id"] == 1 + assert isinstance(result["direction_id"], int) + + +def test_from_redis_omits_missing_optional_fields(): + hash_ = {"trip_id": "t1", "route_id": "r1"} + result = trip.from_redis(hash_) + assert "direction_id" not in result + assert "schedule_relationship" not in result + assert "start_time" not in result + assert "start_date" not in result + + +def test_from_redis_drops_bad_optional_direction_id_silently(): + hash_ = {"trip_id": "t1", "route_id": "r1", "direction_id": "bad"} + result = trip.from_redis(hash_) + assert "direction_id" not in result + + +def test_from_redis_drops_empty_string_optional_fields(): + hash_ = { + "trip_id": "t1", + "route_id": "r1", + "direction_id": "", + "schedule_relationship": "", + "start_time": "", + "start_date": "", + } + result = trip.from_redis(hash_) + assert "direction_id" not in result + assert "schedule_relationship" not in result + + +# --------------------------------------------------------------------------- +# validate_for_write — strict validation +# --------------------------------------------------------------------------- + + +def test_validate_for_write_raises_on_missing_trip_id(): + with pytest.raises(ValueError, match="trip_id"): + trip.validate_for_write({"route_id": "r1"}) + + +def test_validate_for_write_raises_on_missing_route_id(): + with pytest.raises(ValueError, match="route_id"): + trip.validate_for_write({"trip_id": "t1"}) + + +def test_validate_for_write_raises_on_empty_trip_id(): + with pytest.raises(ValueError, match="trip_id"): + trip.validate_for_write({"trip_id": "", "route_id": "r1"}) + + +def test_validate_for_write_raises_on_bad_direction_id(): + with pytest.raises(ValueError, match="direction_id"): + trip.validate_for_write( + {"trip_id": "t1", "route_id": "r1", "direction_id": "bad"} + ) + + +def test_validate_for_write_output_all_strings(): + payload = { + "trip_id": "t1", + "route_id": "r1", + "direction_id": 0, + "schedule_relationship": "SCHEDULED", + "start_time": "09:00:00", + "start_date": "20260608", + } + result = trip.validate_for_write(payload) + for k, v in result.items(): + assert isinstance(v, str), f"Field '{k}' is not a str: {v!r}" + + +# --------------------------------------------------------------------------- +# project_from_run_hash +# --------------------------------------------------------------------------- + + +def test_project_from_run_hash_extracts_trip_subset(): + run_hash = { + # TripDescriptor fields + "trip_id": "trip_001", + "route_id": "route_A", + "direction_id": "1", + "schedule_relationship": "SCHEDULED", + "start_time": "08:00:00", + "start_date": "20260608", + # Run-only fields that must be excluded + "run_id": "some-uuid", + "shape_id": "shape_x", + "vehicle": "vehicle-uuid", + "operator": "operator-uuid", + "run_lifecycle_state": "IN_PROGRESS", + } + result = project_from_run_hash(run_hash) + + # Trip fields included + assert result["trip_id"] == "trip_001" + assert result["route_id"] == "route_A" + assert result["direction_id"] == "1" + assert result["schedule_relationship"] == "SCHEDULED" + assert result["start_time"] == "08:00:00" + assert result["start_date"] == "20260608" + + # Run-only fields excluded + assert "run_id" not in result + assert "shape_id" not in result + assert "vehicle" not in result + assert "operator" not in result + assert "run_lifecycle_state" not in result + + +def test_project_from_run_hash_drops_empty_values(): + run_hash = { + "trip_id": "trip_001", + "route_id": "route_A", + "direction_id": "", # empty — should be dropped + "schedule_relationship": "", # empty — should be dropped + "run_lifecycle_state": "IN_PROGRESS", + } + result = project_from_run_hash(run_hash) + assert "direction_id" not in result + assert "schedule_relationship" not in result + assert result["trip_id"] == "trip_001" + + +def test_project_from_run_hash_partial_trip_fields(): + """When the run hash has only required fields, optional trip fields are absent.""" + run_hash = { + "trip_id": "t2", + "route_id": "r2", + "vehicle": "v1", + "run_lifecycle_state": "REQUESTED", + } + result = project_from_run_hash(run_hash) + assert set(result.keys()) == {"trip_id", "route_id"} + + +def test_project_from_run_hash_output_all_strings(): + run_hash = { + "trip_id": "t1", + "route_id": "r1", + "direction_id": "0", + "vehicle": "v1", + } + result = project_from_run_hash(run_hash) + for k, v in result.items(): + assert isinstance(v, str), f"Field '{k}' is not a str: {v!r}" diff --git a/backend/runs/domain/telemetry/tests/test_vehicle_stop_status.py b/backend/runs/domain/telemetry/tests/test_vehicle_stop_status.py new file mode 100644 index 0000000..3901574 --- /dev/null +++ b/backend/runs/domain/telemetry/tests/test_vehicle_stop_status.py @@ -0,0 +1,140 @@ +"""Pure unit tests for the vehicle_stop_status entity contract. + +No Django, no Redis — runnable under plain pytest. +""" + +import pytest + +from runs.domain.telemetry import vehicle_stop_status +from runs.domain.telemetry.vehicle_stop_status import VALID_STATUSES + + +# --------------------------------------------------------------------------- +# valid_statuses matches models.py exactly +# --------------------------------------------------------------------------- + + +def test_valid_statuses_contains_all_three_gtfs_values(): + expected = {"INCOMING_AT", "STOPPED_AT", "IN_TRANSIT_TO"} + assert VALID_STATUSES == expected + + +# --------------------------------------------------------------------------- +# Round-trip tests +# --------------------------------------------------------------------------- + + +def test_round_trip_status_only(): + payload = {"current_status": "IN_TRANSIT_TO"} + redis_map = vehicle_stop_status.validate_for_write(payload) + result = vehicle_stop_status.from_redis(redis_map) + assert result["current_status"] == "IN_TRANSIT_TO" + assert "current_stop_sequence" not in result + assert "stop_id" not in result + + +def test_round_trip_all_fields(): + payload = { + "current_status": "STOPPED_AT", + "current_stop_sequence": 5, + "stop_id": "stop_A", + } + redis_map = vehicle_stop_status.validate_for_write(payload) + result = vehicle_stop_status.from_redis(redis_map) + assert result["current_status"] == "STOPPED_AT" + assert result["current_stop_sequence"] == 5 + assert result["stop_id"] == "stop_A" + + +def test_round_trip_incoming_at(): + payload = { + "current_status": "INCOMING_AT", + "current_stop_sequence": 3, + "stop_id": "stop_B", + } + redis_map = vehicle_stop_status.validate_for_write(payload) + result = vehicle_stop_status.from_redis(redis_map) + assert result["current_status"] == "INCOMING_AT" + assert result["current_stop_sequence"] == 3 + + +# --------------------------------------------------------------------------- +# Coercion edge cases (from_redis) +# --------------------------------------------------------------------------- + + +def test_from_redis_coerces_string_int_sequence(): + hash_ = {"current_status": "STOPPED_AT", "current_stop_sequence": "7"} + result = vehicle_stop_status.from_redis(hash_) + assert result["current_stop_sequence"] == 7 + assert isinstance(result["current_stop_sequence"], int) + + +def test_from_redis_omits_missing_optional_fields(): + hash_ = {"current_status": "IN_TRANSIT_TO"} + result = vehicle_stop_status.from_redis(hash_) + assert "current_stop_sequence" not in result + assert "stop_id" not in result + + +def test_from_redis_drops_bad_optional_int_silently(): + hash_ = {"current_status": "IN_TRANSIT_TO", "current_stop_sequence": "not-a-number"} + result = vehicle_stop_status.from_redis(hash_) + assert "current_stop_sequence" not in result + + +def test_from_redis_drops_empty_string_optional_fields(): + hash_ = { + "current_status": "STOPPED_AT", + "current_stop_sequence": "", + "stop_id": "", + } + result = vehicle_stop_status.from_redis(hash_) + assert "current_stop_sequence" not in result + assert "stop_id" not in result + + +def test_from_redis_tolerant_on_invalid_status(): + # Reads are tolerant — pass through unknown values rather than raising + hash_ = {"current_status": "INVENTED_STATUS"} + result = vehicle_stop_status.from_redis(hash_) + assert result["current_status"] == "INVENTED_STATUS" + + +# --------------------------------------------------------------------------- +# validate_for_write — strict validation +# --------------------------------------------------------------------------- + + +def test_validate_for_write_raises_on_missing_status(): + with pytest.raises(ValueError, match="current_status"): + vehicle_stop_status.validate_for_write({"current_stop_sequence": 5}) + + +def test_validate_for_write_raises_on_invalid_enum(): + with pytest.raises(ValueError, match="INVALID_STATUS"): + vehicle_stop_status.validate_for_write({"current_status": "INVALID_STATUS"}) + + +def test_validate_for_write_raises_on_bad_optional_int(): + with pytest.raises(ValueError, match="current_stop_sequence"): + vehicle_stop_status.validate_for_write( + {"current_status": "IN_TRANSIT_TO", "current_stop_sequence": "bad"} + ) + + +def test_validate_for_write_output_all_strings(): + payload = { + "current_status": "IN_TRANSIT_TO", + "current_stop_sequence": 3, + "stop_id": "stop_C", + } + result = vehicle_stop_status.validate_for_write(payload) + for k, v in result.items(): + assert isinstance(v, str), f"Field '{k}' is not a str: {v!r}" + + +def test_validate_for_write_accepts_all_valid_enum_values(): + for status in VALID_STATUSES: + result = vehicle_stop_status.validate_for_write({"current_status": status}) + assert result["current_status"] == status diff --git a/backend/runs/domain/telemetry/trip.py b/backend/runs/domain/telemetry/trip.py new file mode 100644 index 0000000..ec39484 --- /dev/null +++ b/backend/runs/domain/telemetry/trip.py @@ -0,0 +1,169 @@ +"""GTFS-RT TripDescriptor entity contract. + +Covers the ``run::trip`` Redis hash. + +Producer: server lifecycle action (``runs/domain/lifecycle/actions.py``). +Consumer: ``schedule_engine/tasks.py::build_vehicle_positions`` and +``build_trip_updates``. + +This hash is the GTFS-RT-shaped *projection* of the TripDescriptor subset from +the flat ``run:`` hash. It contains only what the feed builder needs +for the ``trip`` field; the full run record (assignment state, lifecycle, …) +remains in ``run:``. + +Field types +----------- +- ``trip_id`` : str (required) +- ``route_id`` : str (required) +- ``direction_id`` : int? (optional) +- ``schedule_relationship`` : str? (optional) +- ``start_time`` : str? (optional — HH:MM:SS) +- ``start_date`` : str? (optional — YYYYMMDD) + +Note: ``vehicle_id`` and all lifecycle / non-trip fields present in +``run:`` are *not* part of this contract. + +This module imports nothing so it is safe to import from any layer without +pulling in Django or Redis. +""" + +# --------------------------------------------------------------------------- +# Field-name constants +# --------------------------------------------------------------------------- + +TRIP_ID = "trip_id" +ROUTE_ID = "route_id" +DIRECTION_ID = "direction_id" +SCHEDULE_RELATIONSHIP = "schedule_relationship" +START_TIME = "start_time" +START_DATE = "start_date" + +_REQUIRED_FIELDS = (TRIP_ID, ROUTE_ID) +_OPTIONAL_STR_FIELDS = (SCHEDULE_RELATIONSHIP, START_TIME, START_DATE) + +# The complete set of fields that belong to the TripDescriptor contract. +TRIP_FIELDS: frozenset[str] = frozenset( + { + TRIP_ID, + ROUTE_ID, + DIRECTION_ID, + SCHEDULE_RELATIONSHIP, + START_TIME, + START_DATE, + } +) + + +# --------------------------------------------------------------------------- +# Reader: str → typed (tolerant) +# --------------------------------------------------------------------------- + + +def from_redis(hash: dict) -> dict: + """Convert a raw Redis trip hash (all values are strings) to typed Python. + + Tolerant: absent keys are omitted; values that fail coercion for optional + fields are silently dropped. Required fields that are present but + non-coercible raise ``ValueError``. + + Most fields are already strings; only ``direction_id`` is coerced to int. + """ + result: dict = {} + + for field in _REQUIRED_FIELDS: + raw = hash.get(field) + if raw is not None and raw != "": + result[field] = raw # already a str + + # direction_id: optional int + raw_dir = hash.get(DIRECTION_ID) + if raw_dir is not None and raw_dir != "": + try: + result[DIRECTION_ID] = int(raw_dir) + except (ValueError, TypeError): + pass # tolerant: drop bad optional values + + for field in _OPTIONAL_STR_FIELDS: + raw = hash.get(field) + if raw is not None and raw != "": + result[field] = raw + + return result + + +# --------------------------------------------------------------------------- +# Writer: typed → str (strict) +# --------------------------------------------------------------------------- + + +def validate_for_write(payload: dict) -> dict[str, str]: + """Validate a trip payload and return a Redis-ready ``{field: str}`` mapping. + + Strict: + - ``trip_id`` and ``route_id`` are required; raises ``ValueError`` if missing + or empty. + - ``direction_id``, if present, must be coercible to int. + - Other optional fields are written as-is if present. + """ + result: dict[str, str] = {} + + # Required fields + for field in _REQUIRED_FIELDS: + value = payload.get(field) + if not value: + raise ValueError( + f"trip.validate_for_write: required field '{field}' is missing or empty" + ) + result[field] = str(value) + + # direction_id: optional int + dir_id = payload.get(DIRECTION_ID) + if dir_id is not None: + try: + result[DIRECTION_ID] = str(int(dir_id)) + except (ValueError, TypeError) as exc: + raise ValueError( + f"trip.validate_for_write: optional field '{DIRECTION_ID}' cannot " + f"be coerced to int: {dir_id!r}" + ) from exc + + # Optional string fields + for field in _OPTIONAL_STR_FIELDS: + value = payload.get(field) + if value is not None: + result[field] = str(value) + + return result + + +# --------------------------------------------------------------------------- +# Projection helper +# --------------------------------------------------------------------------- + + +def project_from_run_hash(run_hash: dict) -> dict: + """Extract and return the TripDescriptor subset from a flat run hash. + + The flat ``run:`` hash contains all run assignment and lifecycle + fields. This helper picks only the fields that belong to the + TripDescriptor contract and returns a write-ready typed dict (values are + still the raw strings from Redis, as ``from_redis`` will further type-coerce + them on read). + + Used by the lifecycle action to produce the ``run::trip`` hash in + one call: + + mapping = project_from_run_hash(run_hash) + redis.hset(trip_key(run_id), mapping=mapping) + + Run-only fields (``run_id``, ``shape_id``, ``vehicle``, ``operator``, + ``run_lifecycle_state``, …) are silently dropped. + """ + subset: dict[str, str] = {} + + for field in TRIP_FIELDS: + value = run_hash.get(field) + if value is not None and value != "": + subset[field] = str(value) + + return subset diff --git a/backend/runs/domain/telemetry/vehicle_stop_status.py b/backend/runs/domain/telemetry/vehicle_stop_status.py new file mode 100644 index 0000000..0081351 --- /dev/null +++ b/backend/runs/domain/telemetry/vehicle_stop_status.py @@ -0,0 +1,121 @@ +"""GTFS-RT VehicleStopStatus entity contract. + +Covers the ``run::vehicle_stop_status`` Redis hash. + +Producer: server progression step (``runs/domain/progression/compute.py``). +Consumer: ``schedule_engine/tasks.py::build_vehicle_positions``. + +Field types +----------- +- ``current_stop_sequence`` : int? (optional) +- ``stop_id`` : str? (optional) +- ``current_status`` : str (enum) (required) + +Enum values match ``runs/models.py::VehicleStopStatus.current_status`` choices: + INCOMING_AT, STOPPED_AT, IN_TRANSIT_TO + +This module imports nothing so it is safe to import from any layer without +pulling in Django or Redis. +""" + +# --------------------------------------------------------------------------- +# Field-name constants +# --------------------------------------------------------------------------- + +CURRENT_STOP_SEQUENCE = "current_stop_sequence" +STOP_ID = "stop_id" +CURRENT_STATUS = "current_status" + +# --------------------------------------------------------------------------- +# Enum value set (must match runs/models.py VehicleStopStatus.current_status) +# --------------------------------------------------------------------------- + +VALID_STATUSES: frozenset[str] = frozenset( + { + "INCOMING_AT", + "STOPPED_AT", + "IN_TRANSIT_TO", + } +) + + +# --------------------------------------------------------------------------- +# Reader: str → typed (tolerant) +# --------------------------------------------------------------------------- + + +def from_redis(hash: dict) -> dict: + """Convert a raw Redis vehicle_stop_status hash (all strings) to typed Python. + + Tolerant: absent keys are omitted; values that fail coercion for optional + fields are silently dropped. ``current_status`` is passed through as-is if + present; invalid enum values are dropped (tolerant read). + """ + result: dict = {} + + raw_seq = hash.get(CURRENT_STOP_SEQUENCE) + if raw_seq is not None and raw_seq != "": + try: + result[CURRENT_STOP_SEQUENCE] = int(raw_seq) + except (ValueError, TypeError): + pass # tolerant: drop bad optional values + + raw_stop_id = hash.get(STOP_ID) + if raw_stop_id is not None and raw_stop_id != "": + result[STOP_ID] = raw_stop_id + + raw_status = hash.get(CURRENT_STATUS) + if raw_status is not None and raw_status != "": + # Tolerant on read: accept any value present in Redis + result[CURRENT_STATUS] = raw_status + + return result + + +# --------------------------------------------------------------------------- +# Writer: typed → str (strict) +# --------------------------------------------------------------------------- + + +def validate_for_write(payload: dict) -> dict[str, str]: + """Validate a vehicle_stop_status payload and return a Redis-ready mapping. + + Strict: + - ``current_status`` must be a valid enum value; raises ``ValueError`` + on mismatch. + - ``current_stop_sequence``, if present, must be coercible to int. + - ``current_status`` is required; raises ``ValueError`` if missing. + """ + result: dict[str, str] = {} + + # Optional int field + seq = payload.get(CURRENT_STOP_SEQUENCE) + if seq is not None: + try: + result[CURRENT_STOP_SEQUENCE] = str(int(seq)) + except (ValueError, TypeError) as exc: + raise ValueError( + f"vehicle_stop_status.validate_for_write: optional field " + f"'{CURRENT_STOP_SEQUENCE}' cannot be coerced to int: {seq!r}" + ) from exc + + # Optional str field + stop_id = payload.get(STOP_ID) + if stop_id is not None: + result[STOP_ID] = str(stop_id) + + # Required enum field + status = payload.get(CURRENT_STATUS) + if status is None: + raise ValueError( + f"vehicle_stop_status.validate_for_write: required field " + f"'{CURRENT_STATUS}' is missing" + ) + if status not in VALID_STATUSES: + raise ValueError( + f"vehicle_stop_status.validate_for_write: '{status}' is not a valid " + f"current_status; expected one of {sorted(VALID_STATUSES)}" + ) + result[CURRENT_STATUS] = status + + return result From c2ca1ed3b586e292fa06ed7787587b93d598f801 Mon Sep 17 00:00:00 2001 From: Jae Date: Mon, 8 Jun 2026 20:33:42 -0600 Subject: [PATCH 12/23] refactor(mqtt): ingest only edge entities, write typed hashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route inbound telemetry through the telemetry contract instead of blindly hset-ing every field as a string: - Subscribe to position and occupancy only; stop subscribing to progression (decommissioned server-side; the simulator may still publish it). - position payloads go through position.validate_for_write; occupancy through occupancy.validate_for_write. occupancy_status is server policy: the edge-sent value is discarded and recomputed via occupancy.classify_status from the raw percentage at write time. - Malformed payloads (e.g. position missing lat/lon) are logged and dropped without touching last_seen or the detection delegate — a bad payload never crashes ingestion. Unknown leaves are dropped at debug. - All Redis keys come from telemetry.keys; the runs:last_seen update and the detect_from_telemetry delegate (raw data) are preserved. Unit-tested with a fake redis + patched dispatch (7 tests). --- backend/realtime_engine/mqtt.py | 72 ++++- backend/realtime_engine/tests/__init__.py | 0 .../tests/test_mqtt_ingestion.py | 257 ++++++++++++++++++ 3 files changed, 320 insertions(+), 9 deletions(-) create mode 100644 backend/realtime_engine/tests/__init__.py create mode 100644 backend/realtime_engine/tests/test_mqtt_ingestion.py diff --git a/backend/realtime_engine/mqtt.py b/backend/realtime_engine/mqtt.py index 500914d..216881f 100644 --- a/backend/realtime_engine/mqtt.py +++ b/backend/realtime_engine/mqtt.py @@ -7,7 +7,16 @@ on the realtime-engine service, so other workers (schedule-engine, beat) skip it and don't double-subscribe to the broker. -Topic pattern: ``transit/vehicle//{position,progression,occupancy}``. +Topic pattern: ``transit/vehicle//{position,occupancy}``. + +Topic routing: +- ``position`` and ``occupancy`` are edge-sensed and written to + ``vehicle::position`` / ``vehicle::occupancy`` via the telemetry + contract (``runs.domain.telemetry``). +- ``progression`` is decommissioned server-side; we no longer subscribe even + though the simulator may still publish it. +- ``occupancy_status`` is a server policy decision: the edge-sent value is + discarded and recomputed with ``occupancy.classify_status`` at write time. """ import json @@ -19,6 +28,8 @@ from celery import bootsteps from django.utils.timezone import now +from runs.domain.telemetry import keys, occupancy, position + logger = logging.getLogger(__name__) MQTT_HOST = os.getenv("MQTT_HOST", "telemetry-broker") @@ -57,21 +68,64 @@ def _handle_telemetry(vehicle_id: str, leaf: str, payload_bytes: bytes) -> None: logger.warning("Non-JSON payload on vehicle %s/%s — ignored", vehicle_id, leaf) return - run_id = r.get(f"vehicle:{vehicle_id}:current_run") + run_id = r.get(keys.current_run_key(vehicle_id)) if not run_id: logger.debug("No active run for vehicle %s — dropping %s", vehicle_id, leaf) return - if isinstance(data, dict): - r.hset( - f"vehicle:{vehicle_id}:{leaf}", - mapping={k: str(v) for k, v in data.items()}, + if leaf == "position": + try: + mapping = position.validate_for_write(data) + except ValueError: + logger.warning( + "Invalid position payload for vehicle %s — dropped: %r", + vehicle_id, + data, + ) + return + r.hset(keys.position_key(vehicle_id), mapping=mapping) + + elif leaf == "occupancy": + # occupancy_status is server policy — discard any edge-sent value and + # recompute it from the raw percentage. + raw_pct = data.get("occupancy_percentage") + try: + pct = int(raw_pct) if raw_pct is not None else None + except (ValueError, TypeError): + pct = None + + occ_payload = { + k: v + for k, v in data.items() + if k != occupancy.OCCUPANCY_STATUS + } + occ_payload[occupancy.OCCUPANCY_STATUS] = occupancy.classify_status(pct) + + try: + mapping = occupancy.validate_for_write(occ_payload) + except ValueError: + logger.warning( + "Invalid occupancy payload for vehicle %s — dropped: %r", + vehicle_id, + data, + ) + return + r.hset(keys.occupancy_key(vehicle_id), mapping=mapping) + + else: + # Unknown leaf (e.g. legacy 'progression' still published by simulator). + # Drop silently at debug level — a bad payload must not crash ingestion. + logger.debug( + "Unknown telemetry leaf '%s' for vehicle %s — dropped", + leaf, + vehicle_id, ) + return - r.set(f"runs:last_seen:{run_id}", now().isoformat()) + r.set(keys.last_seen_key(run_id), now().isoformat()) # All detection heuristics live in runs.domain.detection; this consumer only - # ingests telemetry and delegates. + # ingests telemetry and delegates. Pass the raw data dict as before. from runs.domain.detection.dispatch import detect_from_telemetry detect_from_telemetry(run_id, vehicle_id, leaf, data) @@ -81,8 +135,8 @@ def _on_connect(client: mqtt.Client, userdata, flags, rc) -> None: if rc == 0: logger.info("MQTT connected: %s:%s", MQTT_HOST, MQTT_PORT) client.subscribe("transit/vehicle/+/position", qos=0) - client.subscribe("transit/vehicle/+/progression", qos=0) client.subscribe("transit/vehicle/+/occupancy", qos=0) + # 'progression' is intentionally NOT subscribed — decommissioned. else: logger.error("MQTT connection refused: rc=%d", rc) diff --git a/backend/realtime_engine/tests/__init__.py b/backend/realtime_engine/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/realtime_engine/tests/test_mqtt_ingestion.py b/backend/realtime_engine/tests/test_mqtt_ingestion.py new file mode 100644 index 0000000..52cc6cf --- /dev/null +++ b/backend/realtime_engine/tests/test_mqtt_ingestion.py @@ -0,0 +1,257 @@ +"""Pure unit tests for the MQTT telemetry ingestion path in mqtt.py. + +No real Redis, no real MQTT broker, no Django ORM. The module-level ``r`` +object and the lazily-imported ``detect_from_telemetry`` are both monkeypatched +so each test runs entirely in-process without I/O. + +Patch targets +------------- +- ``realtime_engine.mqtt.r`` — the Redis client used by _handle_telemetry. +- ``runs.domain.detection.dispatch.detect_from_telemetry`` + — the lazy import inside _handle_telemetry + resolves via the module object, so patching + the attribute on the module is the correct + target (as the import statement is + ``from runs.domain.detection.dispatch import + detect_from_telemetry`` inside the function). +""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock, call, patch + +import pytest + +import realtime_engine.mqtt as mqtt_module +from realtime_engine.mqtt import _handle_telemetry +from runs.domain.telemetry import keys, occupancy + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +VEHICLE_ID = "v-42" +RUN_ID = "run-99" + +_VALID_POSITION = { + "latitude": 51.5074, + "longitude": -0.1278, + "bearing": 90.0, + "speed": 8.5, + "timestamp": 1700000000, +} + +_VALID_OCCUPANCY_WITH_PCT = { + "occupancy_percentage": 90, + "occupancy_count": 45, + # edge-sent status — must be ignored / overwritten + "occupancy_status": "BOGUS_EDGE_VALUE", +} + +_VALID_OCCUPANCY_NO_PCT = { + "occupancy_count": 10, + # No occupancy_percentage key at all +} + + +_FAKE_NOW_ISO = "2026-06-08T12:00:00+00:00" + + +def _fake_redis(run_id: str = RUN_ID) -> MagicMock: + """Return a Mock that impersonates redis.Redis with decode_responses=True.""" + r = MagicMock() + r.get.return_value = run_id # simulates r.get(current_run_key) → run_id + return r + + +def _fake_now(): + """Replacement for django.utils.timezone.now — avoids needing Django settings.""" + m = MagicMock() + m.isoformat.return_value = _FAKE_NOW_ISO + return m + + +def _encode(data: dict) -> bytes: + return json.dumps(data).encode() + + +# --------------------------------------------------------------------------- +# Test 1 — Valid position payload: hset called with typed contract mapping +# --------------------------------------------------------------------------- + + +def test_valid_position_writes_position_key(monkeypatch): + fake_r = _fake_redis() + monkeypatch.setattr(mqtt_module, "r", fake_r) + monkeypatch.setattr(mqtt_module, "now", _fake_now) + + with patch("runs.domain.detection.dispatch.detect_from_telemetry") as mock_detect: + _handle_telemetry(VEHICLE_ID, "position", _encode(_VALID_POSITION)) + + # Verify hset was called with the correct Redis key + assert fake_r.hset.call_count == 1 + + # Extract the mapping regardless of whether it was passed as kwarg or positional + call_kwargs = fake_r.hset.call_args + redis_key = call_kwargs.args[0] if call_kwargs.args else call_kwargs.kwargs.get("name") + mapping = call_kwargs.kwargs.get("mapping") + + assert redis_key == keys.position_key(VEHICLE_ID) + + # All values must be strings (Redis-ready) + for v in mapping.values(): + assert isinstance(v, str), f"Expected str, got {type(v)} for value {v!r}" + + # Required fields present and round-trip correctly + assert float(mapping["latitude"]) == pytest.approx(51.5074) + assert float(mapping["longitude"]) == pytest.approx(-0.1278) + assert float(mapping["bearing"]) == pytest.approx(90.0) + + # last_seen updated with the run's key + fake_r.set.assert_called_once_with(keys.last_seen_key(RUN_ID), _FAKE_NOW_ISO) + + # detect_from_telemetry called with raw data + mock_detect.assert_called_once_with(RUN_ID, VEHICLE_ID, "position", _VALID_POSITION) + + +# --------------------------------------------------------------------------- +# Test 2 — Occupancy with percentage: status is recomputed, not trusted from edge +# --------------------------------------------------------------------------- + + +def test_occupancy_with_percentage_rewrites_status(monkeypatch): + """percentage=90 → FULL; edge-sent BOGUS_EDGE_VALUE must be overwritten.""" + fake_r = _fake_redis() + monkeypatch.setattr(mqtt_module, "r", fake_r) + monkeypatch.setattr(mqtt_module, "now", _fake_now) + + with patch("runs.domain.detection.dispatch.detect_from_telemetry") as mock_detect: + _handle_telemetry(VEHICLE_ID, "occupancy", _encode(_VALID_OCCUPANCY_WITH_PCT)) + + assert fake_r.hset.call_count == 1 + call_kwargs = fake_r.hset.call_args + redis_key = call_kwargs.args[0] if call_kwargs.args else call_kwargs.kwargs.get("name") + mapping = call_kwargs.kwargs.get("mapping") + + assert redis_key == keys.occupancy_key(VEHICLE_ID) + + # Status must be FULL (90 >= 80 threshold), NOT the bogus edge value + assert mapping[occupancy.OCCUPANCY_STATUS] == "FULL" + assert mapping[occupancy.OCCUPANCY_STATUS] != "BOGUS_EDGE_VALUE" + + # Numeric fields preserved as strings + assert int(mapping[occupancy.OCCUPANCY_PERCENTAGE]) == 90 + assert int(mapping[occupancy.OCCUPANCY_COUNT]) == 45 + + # detect called with raw data (including the original bogus status) + mock_detect.assert_called_once_with( + RUN_ID, VEHICLE_ID, "occupancy", _VALID_OCCUPANCY_WITH_PCT + ) + + +# --------------------------------------------------------------------------- +# Test 3 — Occupancy without percentage → NO_DATA_AVAILABLE +# --------------------------------------------------------------------------- + + +def test_occupancy_without_percentage_yields_no_data_available(monkeypatch): + fake_r = _fake_redis() + monkeypatch.setattr(mqtt_module, "r", fake_r) + monkeypatch.setattr(mqtt_module, "now", _fake_now) + + with patch("runs.domain.detection.dispatch.detect_from_telemetry"): + _handle_telemetry(VEHICLE_ID, "occupancy", _encode(_VALID_OCCUPANCY_NO_PCT)) + + mapping = fake_r.hset.call_args.kwargs.get("mapping") + assert mapping[occupancy.OCCUPANCY_STATUS] == "NO_DATA_AVAILABLE" + + +# --------------------------------------------------------------------------- +# Test 4 — Malformed position (missing lat/lon): dropped, no side effects +# --------------------------------------------------------------------------- + + +def test_malformed_position_is_dropped_without_side_effects(monkeypatch): + """A position payload missing lat/lon must be fully discarded. + + - No hset call. + - No last_seen update (r.set not called). + - detect_from_telemetry NOT called. + - No exception propagates out. + """ + fake_r = _fake_redis() + monkeypatch.setattr(mqtt_module, "r", fake_r) + + bad_payload = {"speed": 12.0, "bearing": 45.0} # no latitude / longitude + + with patch("runs.domain.detection.dispatch.detect_from_telemetry") as mock_detect: + # Must not raise + _handle_telemetry(VEHICLE_ID, "position", _encode(bad_payload)) + + fake_r.hset.assert_not_called() + fake_r.set.assert_not_called() + mock_detect.assert_not_called() + + +# --------------------------------------------------------------------------- +# Test 5 — Unknown leaf: dropped (no hset, detect not called) +# --------------------------------------------------------------------------- + + +def test_unknown_leaf_is_dropped(monkeypatch): + """Legacy 'progression' or any other unknown leaf must be silently dropped.""" + fake_r = _fake_redis() + monkeypatch.setattr(mqtt_module, "r", fake_r) + + progression_payload = { + "current_status": "STOPPED_AT", + "stop_id": "S99", + "current_stop_sequence": 5, + } + + with patch("runs.domain.detection.dispatch.detect_from_telemetry") as mock_detect: + _handle_telemetry(VEHICLE_ID, "progression", _encode(progression_payload)) + + fake_r.hset.assert_not_called() + fake_r.set.assert_not_called() + mock_detect.assert_not_called() + + +# --------------------------------------------------------------------------- +# Test 6 — No active run: nothing written, detect not called +# --------------------------------------------------------------------------- + + +def test_no_active_run_drops_all_telemetry(monkeypatch): + """When r.get(current_run_key) returns falsy, all leaves are dropped.""" + fake_r = _fake_redis(run_id="") # falsy empty string + fake_r.get.return_value = None # also covers None return + monkeypatch.setattr(mqtt_module, "r", fake_r) + + with patch("runs.domain.detection.dispatch.detect_from_telemetry") as mock_detect: + _handle_telemetry(VEHICLE_ID, "position", _encode(_VALID_POSITION)) + + fake_r.hset.assert_not_called() + fake_r.set.assert_not_called() + mock_detect.assert_not_called() + + +# --------------------------------------------------------------------------- +# Test 7 — _on_connect subscribes to position and occupancy only (not progression) +# --------------------------------------------------------------------------- + + +def test_on_connect_subscribes_position_and_occupancy_only(): + """_on_connect must subscribe to exactly 'position' and 'occupancy'. + + 'progression' must NOT be subscribed (decommissioned). + """ + mock_client = MagicMock() + mqtt_module._on_connect(mock_client, userdata=None, flags=None, rc=0) + + subscribed_topics = {c.args[0] for c in mock_client.subscribe.call_args_list} + assert "transit/vehicle/+/position" in subscribed_topics + assert "transit/vehicle/+/occupancy" in subscribed_topics + assert "transit/vehicle/+/progression" not in subscribed_topics From 361fef9f29d69f7db6de0c0128d8d1d32f6b351b Mon Sep 17 00:00:00 2001 From: Jae Date: Mon, 8 Jun 2026 20:33:53 -0600 Subject: [PATCH 13/23] feat(runs): write run::trip hash in lifecycle action In update_system_state, additively write the GTFS-RT-shaped TripDescriptor projection to run::trip alongside the flat run: hash. Values are already computed in the run mapping, so this is a near-free extra hset in the existing pipeline, reusing trip.project_from_run_hash and telemetry.keys. Gated on a present trip_id so a run without a trip never writes an invalid TripDescriptor (a non-empty projection of only direction_id/schedule_ relationship is skipped). This makes the builder's v["trip"] an as-is read instead of hand-picking the subset from the flat hash. Unit-tested import-light via a stubbed runs.models + fake redis pipeline (2 tests). --- backend/runs/domain/lifecycle/actions.py | 8 ++ .../runs/domain/lifecycle/tests/__init__.py | 0 .../lifecycle/tests/test_actions_trip.py | 118 ++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 backend/runs/domain/lifecycle/tests/__init__.py create mode 100644 backend/runs/domain/lifecycle/tests/test_actions_trip.py diff --git a/backend/runs/domain/lifecycle/actions.py b/backend/runs/domain/lifecycle/actions.py index eeef9f6..37a77fc 100644 --- a/backend/runs/domain/lifecycle/actions.py +++ b/backend/runs/domain/lifecycle/actions.py @@ -1,6 +1,7 @@ from typing import Any, TYPE_CHECKING from runs.models import Run import redis +from runs.domain.telemetry import keys, trip if TYPE_CHECKING: from runs.domain.lifecycle import Transition @@ -55,6 +56,13 @@ def update_system_state( pipe = r.pipeline() pipe.hset(run_key, mapping=mapping) + # Write the GTFS-RT-shaped TripDescriptor projection so the feed builders + # read run::trip as-is instead of hand-picking the trip subset out of + # the flat run hash. Values are already in `mapping`; this is additive. + trip_mapping = trip.project_from_run_hash(mapping) + if trip_mapping.get("trip_id"): + pipe.hset(keys.trip_key(str(run.id)), mapping=trip_mapping) + # Claim assignment keys so availability guards have a signal to read if vehicle_id: pipe.set(f"vehicle:{vehicle_id}:current_run", str(run.id)) diff --git a/backend/runs/domain/lifecycle/tests/__init__.py b/backend/runs/domain/lifecycle/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/runs/domain/lifecycle/tests/test_actions_trip.py b/backend/runs/domain/lifecycle/tests/test_actions_trip.py new file mode 100644 index 0000000..4387812 --- /dev/null +++ b/backend/runs/domain/lifecycle/tests/test_actions_trip.py @@ -0,0 +1,118 @@ +"""Unit tests for the run::trip projection write in update_system_state. + +The repo has no pytest-django harness; ``actions.py`` imports ``runs.models`` +at module top, which needs a configured Django. To keep this an import-light +unit test (matching the convention in ``runs/domain/detection/tests``), we stub +``runs.models`` before importing the action and drive it with a fake Redis +pipeline that records ``hset`` calls. The projection logic itself +(``trip.project_from_run_hash``) is covered separately in +``runs/domain/telemetry/tests/test_trip.py``; here we assert the *action glue*: +that the trip subset is written to ``run::trip`` and skipped when empty. +""" + +import sys +import types +from types import SimpleNamespace + +import pytest + +from runs.domain.telemetry import keys + + +@pytest.fixture +def actions_module(monkeypatch): + """Import actions.py with runs.models stubbed and a recording fake Redis.""" + fake_models = types.ModuleType("runs.models") + fake_models.Run = type("Run", (), {}) + monkeypatch.setitem(sys.modules, "runs.models", fake_models) + + # Import fresh so the stubbed runs.models is in effect. + import importlib + + actions = importlib.import_module("runs.domain.lifecycle.actions") + actions = importlib.reload(actions) + + monkeypatch.setattr(actions, "r", _FakeRedis()) + return actions + + +class _FakePipe: + def __init__(self): + self.hset_calls = [] # list[(key, mapping)] + self.set_calls = [] + + def hset(self, key, mapping=None, **kwargs): + self.hset_calls.append((key, mapping)) + + def set(self, key, value): + self.set_calls.append((key, value)) + + def execute(self): + return [] + + +class _FakeRedis: + def __init__(self): + self.pipe = _FakePipe() + + def pipeline(self): + return self.pipe + + +def _make_run(*, trip_id, route_id, direction_id=0): + """A run double exposing only what update_system_state touches.""" + empty_qs = SimpleNamespace( + values_list=lambda *a, **k: SimpleNamespace(first=lambda: None) + ) + return SimpleNamespace( + id="run-123", + route_id=route_id, + trip_id=trip_id, + direction_id=direction_id, + shape_id="shape-1", + schedule_relationship="SCHEDULED", + start_date=None, + start_time=None, + vehicle=empty_qs, + operator=empty_qs, + ) + + +def _transition(): + return SimpleNamespace(to_state=SimpleNamespace(value="IN_PROGRESS")) + + +def _trip_hsets(pipe): + return [m for (k, m) in pipe.hset_calls if k == keys.trip_key("run-123")] + + +def test_writes_trip_projection_when_trip_present(actions_module): + actions = actions_module + run = _make_run(trip_id="T1", route_id="R1", direction_id=1) + + actions.RunLifecycleActions.update_system_state(run, _transition(), {}) + + trip_writes = _trip_hsets(actions.r.pipe) + assert len(trip_writes) == 1 + mapping = trip_writes[0] + assert mapping == { + "trip_id": "T1", + "route_id": "R1", + "direction_id": "1", + "schedule_relationship": "SCHEDULED", + } + # Run-only fields must not leak into the trip projection. + assert "shape_id" not in mapping + assert "run_id" not in mapping + assert "run_lifecycle_state" not in mapping + + +def test_skips_trip_write_when_no_trip(actions_module): + actions = actions_module + run = _make_run(trip_id=None, route_id=None) + + actions.RunLifecycleActions.update_system_state(run, _transition(), {}) + + assert _trip_hsets(actions.r.pipe) == [] + # The flat run hash is still written regardless. + assert any(k == "run:run-123" for (k, _m) in actions.r.pipe.hset_calls) From 5ab7fc2d7ea5359b19a44898b3d84c615ea06640 Mon Sep 17 00:00:00 2001 From: Jae Date: Mon, 8 Jun 2026 20:48:35 -0600 Subject: [PATCH 14/23] feat(progression): server-side stop-status producer (seam) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce runs/domain/progression/ to produce run::vehicle_stop_status server-side, decoupling stop status from edge telemetry (it depends on the assigned trip, so it is server-computed and run-keyed). - compute.py: pure compute_stop_status(run_hash, position_hash, *, prev_state) with a seam/placeholder body — defaults current_status to IN_TRANSIT_TO and carries forward current_stop_sequence/stop_id from prev_state as a monotonic floor. The signature is locked (the progress-FSM branch consumes it); the real map-matching port (GPS->polyline projection, radius rules, GTFS shape model) is deferred and marked TODO. - producer.py: reads position + run + prior stop-status from Redis, delegates to compute, validates via the contract, writes the typed hash. - mqtt: one guarded call after each successful position write derives stop status; failures are logged and never break ingestion. Minimum seam — real map-matching deferred. Unit-tested (pure compute + monkeypatched-redis producer + mqtt wiring assertion). --- backend/realtime_engine/mqtt.py | 10 + .../tests/test_mqtt_ingestion.py | 46 +++++ backend/runs/domain/progression/__init__.py | 17 ++ backend/runs/domain/progression/compute.py | 93 ++++++++++ backend/runs/domain/progression/producer.py | 66 +++++++ .../runs/domain/progression/tests/__init__.py | 0 .../domain/progression/tests/test_compute.py | 173 ++++++++++++++++++ .../domain/progression/tests/test_producer.py | 162 ++++++++++++++++ 8 files changed, 567 insertions(+) create mode 100644 backend/runs/domain/progression/__init__.py create mode 100644 backend/runs/domain/progression/compute.py create mode 100644 backend/runs/domain/progression/producer.py create mode 100644 backend/runs/domain/progression/tests/__init__.py create mode 100644 backend/runs/domain/progression/tests/test_compute.py create mode 100644 backend/runs/domain/progression/tests/test_producer.py diff --git a/backend/realtime_engine/mqtt.py b/backend/realtime_engine/mqtt.py index 216881f..9a59f19 100644 --- a/backend/realtime_engine/mqtt.py +++ b/backend/realtime_engine/mqtt.py @@ -84,6 +84,16 @@ def _handle_telemetry(vehicle_id: str, leaf: str, payload_bytes: bytes) -> None: ) return r.hset(keys.position_key(vehicle_id), mapping=mapping) + # Seam: derive server-side stop status from the new position. + try: + from runs.domain.progression.producer import produce_stop_status + produce_stop_status(run_id, vehicle_id) + except Exception: + logger.exception( + "stop-status production failed for vehicle %s run %s", + vehicle_id, + run_id, + ) elif leaf == "occupancy": # occupancy_status is server policy — discard any edge-sent value and diff --git a/backend/realtime_engine/tests/test_mqtt_ingestion.py b/backend/realtime_engine/tests/test_mqtt_ingestion.py index 52cc6cf..da9393d 100644 --- a/backend/realtime_engine/tests/test_mqtt_ingestion.py +++ b/backend/realtime_engine/tests/test_mqtt_ingestion.py @@ -243,6 +243,52 @@ def test_no_active_run_drops_all_telemetry(monkeypatch): # --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# Test 8 — Successful position ingest calls produce_stop_status once +# --------------------------------------------------------------------------- + + +def test_valid_position_calls_produce_stop_status(monkeypatch): + """After a valid position write, produce_stop_status must be called once. + + The call is guarded (exceptions are swallowed); this test verifies the + happy-path invocation with the correct (run_id, vehicle_id) args. + """ + fake_r = _fake_redis() + monkeypatch.setattr(mqtt_module, "r", fake_r) + monkeypatch.setattr(mqtt_module, "now", _fake_now) + + with ( + patch("runs.domain.detection.dispatch.detect_from_telemetry"), + patch( + "runs.domain.progression.producer.produce_stop_status" + ) as mock_produce, + ): + _handle_telemetry(VEHICLE_ID, "position", _encode(_VALID_POSITION)) + + mock_produce.assert_called_once_with(RUN_ID, VEHICLE_ID) + + +def test_produce_stop_status_exception_does_not_propagate(monkeypatch): + """A failure in produce_stop_status must be swallowed — ingestion continues.""" + fake_r = _fake_redis() + monkeypatch.setattr(mqtt_module, "r", fake_r) + monkeypatch.setattr(mqtt_module, "now", _fake_now) + + with ( + patch("runs.domain.detection.dispatch.detect_from_telemetry"), + patch( + "runs.domain.progression.producer.produce_stop_status", + side_effect=RuntimeError("boom"), + ), + ): + # Must not raise — the guard swallows the exception + _handle_telemetry(VEHICLE_ID, "position", _encode(_VALID_POSITION)) + + # Position still written despite the failure + assert fake_r.hset.call_count >= 1 + + def test_on_connect_subscribes_position_and_occupancy_only(): """_on_connect must subscribe to exactly 'position' and 'occupancy'. diff --git a/backend/runs/domain/progression/__init__.py b/backend/runs/domain/progression/__init__.py new file mode 100644 index 0000000..6b0026c --- /dev/null +++ b/backend/runs/domain/progression/__init__.py @@ -0,0 +1,17 @@ +"""Server-side stop-status progression package (seam / placeholder implementation). + +Provides a stable contract for deriving ``run::vehicle_stop_status`` +from the vehicle's latest GPS position and run assignment. + +Public API +---------- +- :func:`.compute_stop_status` — pure function, no I/O (import-light). +- :func:`.produce_stop_status` — impure Redis glue; reads, computes, writes. + +The real map-matching algorithm (GPS → polyline projection → stop radius rules) +is deferred to a future port. See :mod:`.compute` for the TODO note and +the stable function signature that the port will swap. +""" + +from runs.domain.progression.compute import compute_stop_status # noqa: F401 +from runs.domain.progression.producer import produce_stop_status # noqa: F401 diff --git a/backend/runs/domain/progression/compute.py b/backend/runs/domain/progression/compute.py new file mode 100644 index 0000000..3b1308f --- /dev/null +++ b/backend/runs/domain/progression/compute.py @@ -0,0 +1,93 @@ +"""Server-side stop-status computation (seam / placeholder implementation). + +This module provides a single pure function :func:`compute_stop_status` that +derives a ``vehicle_stop_status`` contract dict from the current run hash and +the vehicle's latest position hash. + +**Current implementation: seam / placeholder body** + +The body is intentionally minimal — it defaults ``current_status`` to +``IN_TRANSIT_TO`` and carries forward ``current_stop_sequence`` / ``stop_id`` +from ``prev_state`` as a monotonic floor so values do not regress once a real +producer has set them. No map-matching is performed here. + +The function signature is **stable** — the future map-matching implementation +swaps only the body. +``run_hash`` is accepted now (currently unused) because the real implementation +needs ``shape_id`` / ``trip_id`` to look up the run's GTFS shape polyline. + +# TODO(map-matching port): +# Real algorithm: +# 1. Project the observed GPS point (position_hash latitude/longitude) onto +# the run's shape polyline (identified via run_hash["shape_id"]). +# 2. Find the nearest stop along the polyline within STOP_RADIUS_M. +# 3. Apply radius rules: +# - distance <= STOP_RADIUS_M and vehicle is stationary → STOPPED_AT +# - distance <= INCOMING_AT_RADIUS_M and approaching → INCOMING_AT +# - otherwise → IN_TRANSIT_TO +# 4. Enforce monotonic sequence: current_stop_sequence must never decrease +# from prev_state["current_stop_sequence"] (unless the run restarts). +# 5. Resolve stop_id from the GTFS stops table for the selected sequence. +# The port swaps only this body; callers and tests of the contract dict shape +# are unaffected because the signature and return type are already locked. +""" + +from __future__ import annotations + +from runs.domain.telemetry import vehicle_stop_status + + +def compute_stop_status( + run_hash: dict, + position_hash: dict, + *, + prev_state: dict | None = None, +) -> dict: + """Derive a vehicle_stop_status contract dict from current run and position. + + Parameters + ---------- + run_hash: + The full ``run:`` Redis hash (all strings, as returned by + ``r.hgetall``). In the seam this is unused; the future map-matching + implementation uses ``run_hash["shape_id"]`` / ``run_hash["trip_id"]``. + position_hash: + The typed position dict (as returned by ``position.from_redis``), + containing at minimum ``latitude`` and ``longitude`` as floats. + prev_state: + Optional previous vehicle_stop_status dict (as returned by + ``vehicle_stop_status.from_redis``). When present, ``current_stop_sequence`` + and ``stop_id`` are carried forward as a monotonic floor so values do + not regress between position updates. + + Returns + ------- + dict + A vehicle_stop_status contract dict with: + - ``current_status`` (str, always present — required by the contract) + - ``current_stop_sequence`` (int, optional — carried from prev_state) + - ``stop_id`` (str, optional — carried from prev_state) + + The returned dict always passes ``vehicle_stop_status.validate_for_write``. + + # TODO(map-matching port): Replace this seam body with real + # map-matching logic as described in the module docstring above. + """ + result: dict = { + vehicle_stop_status.CURRENT_STATUS: "IN_TRANSIT_TO", + } + + # Carry forward optional fields from prev_state as a monotonic floor. + # Once a real producer has set sequence/stop_id, they should not vanish + # between position updates (they will only change when the real algorithm + # detects a stop transition). + if prev_state is not None: + seq = prev_state.get(vehicle_stop_status.CURRENT_STOP_SEQUENCE) + if seq is not None: + result[vehicle_stop_status.CURRENT_STOP_SEQUENCE] = seq + + stop_id = prev_state.get(vehicle_stop_status.STOP_ID) + if stop_id is not None: + result[vehicle_stop_status.STOP_ID] = stop_id + + return result diff --git a/backend/runs/domain/progression/producer.py b/backend/runs/domain/progression/producer.py new file mode 100644 index 0000000..3f90a23 --- /dev/null +++ b/backend/runs/domain/progression/producer.py @@ -0,0 +1,66 @@ +"""Redis glue for the server-side stop-status producer (seam / placeholder implementation). + +This module is the impure counterpart to the pure :mod:`.compute` module. +It reads from Redis, delegates computation, validates the result, and writes +back to Redis. + +Called by ``realtime_engine/mqtt.py`` after every successful position write +so that ``run::vehicle_stop_status`` is kept current. + +The Redis client mirrors the pattern used in other producer modules in this +package: module-level client configured from environment variables. +""" + +from __future__ import annotations + +import logging +import os + +import redis + +from runs.domain.telemetry import keys, position, vehicle_stop_status +from runs.domain.progression.compute import compute_stop_status + +logger = logging.getLogger(__name__) + +r = redis.Redis( + host=os.getenv("REDIS_HOST", "state"), + port=int(os.getenv("REDIS_PORT", "6379")), + db=0, + decode_responses=True, +) + + +def produce_stop_status(run_id: str, vehicle_id: str) -> None: + """Derive and write ``run::vehicle_stop_status`` from the latest position. + + Reads the current position, run hash, and previous stop status from Redis, + delegates to :func:`.compute_stop_status`, validates the result, and writes + it back to ``run::vehicle_stop_status``. + + Returns immediately without writing anything if no position data is + available for the vehicle (nothing to derive from). + + Parameters + ---------- + run_id: + The active run id (string, as stored in ``vehicle::current_run``). + vehicle_id: + The vehicle id whose position was just updated. + """ + pos_raw = r.hgetall(keys.position_key(vehicle_id)) + if not pos_raw: + # No position data yet — nothing to derive stop status from. + return + + position_hash = position.from_redis(pos_raw) + + run_hash = r.hgetall(keys.run_key(run_id)) + + prev_raw = r.hgetall(keys.stop_status_key(run_id)) + prev_state = vehicle_stop_status.from_redis(prev_raw) if prev_raw else None + + computed = compute_stop_status(run_hash, position_hash, prev_state=prev_state) + + mapping = vehicle_stop_status.validate_for_write(computed) + r.hset(keys.stop_status_key(run_id), mapping=mapping) diff --git a/backend/runs/domain/progression/tests/__init__.py b/backend/runs/domain/progression/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/runs/domain/progression/tests/test_compute.py b/backend/runs/domain/progression/tests/test_compute.py new file mode 100644 index 0000000..a557128 --- /dev/null +++ b/backend/runs/domain/progression/tests/test_compute.py @@ -0,0 +1,173 @@ +"""Pure unit tests for compute_stop_status — no Django/Redis required. + +The key guarantee tested here: +- The seam always returns a dict that passes vehicle_stop_status.validate_for_write + (round-trip through the contract). This pins the output shape so that + the future map-matching port cannot accidentally break the contract. +""" + +from __future__ import annotations + +import pytest + +from runs.domain.progression.compute import compute_stop_status +from runs.domain.telemetry import vehicle_stop_status + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +_POSITION_HASH = { + "latitude": 51.5074, + "longitude": -0.1278, + "bearing": 90.0, + "speed": 8.5, + "timestamp": 1700000000, +} + +_RUN_HASH = { + "trip_id": "trip-1", + "route_id": "route-1", + "shape_id": "shape-1", +} + + +# --------------------------------------------------------------------------- +# Test 1 — Default seam: IN_TRANSIT_TO with no seq/stop_id when prev is None +# --------------------------------------------------------------------------- + + +def test_default_returns_in_transit_to_with_no_prev(): + result = compute_stop_status(_RUN_HASH, _POSITION_HASH, prev_state=None) + + assert result[vehicle_stop_status.CURRENT_STATUS] == "IN_TRANSIT_TO" + assert vehicle_stop_status.CURRENT_STOP_SEQUENCE not in result + assert vehicle_stop_status.STOP_ID not in result + + +# --------------------------------------------------------------------------- +# Test 2 — prev_state with sequence and stop_id: both carried forward +# --------------------------------------------------------------------------- + + +def test_carries_forward_sequence_and_stop_id_from_prev_state(): + prev = { + vehicle_stop_status.CURRENT_STOP_SEQUENCE: 5, + vehicle_stop_status.STOP_ID: "STOP-42", + vehicle_stop_status.CURRENT_STATUS: "STOPPED_AT", + } + + result = compute_stop_status(_RUN_HASH, _POSITION_HASH, prev_state=prev) + + assert result[vehicle_stop_status.CURRENT_STATUS] == "IN_TRANSIT_TO" + assert result[vehicle_stop_status.CURRENT_STOP_SEQUENCE] == 5 + assert result[vehicle_stop_status.STOP_ID] == "STOP-42" + + +# --------------------------------------------------------------------------- +# Test 3 — prev_state with only stop_id (no sequence): only stop_id carried +# --------------------------------------------------------------------------- + + +def test_carries_forward_only_stop_id_when_no_sequence_in_prev(): + prev = { + vehicle_stop_status.STOP_ID: "STOP-7", + vehicle_stop_status.CURRENT_STATUS: "IN_TRANSIT_TO", + } + + result = compute_stop_status(_RUN_HASH, _POSITION_HASH, prev_state=prev) + + assert result[vehicle_stop_status.STOP_ID] == "STOP-7" + assert vehicle_stop_status.CURRENT_STOP_SEQUENCE not in result + + +# --------------------------------------------------------------------------- +# Test 4 — prev_state with only sequence (no stop_id): only sequence carried +# --------------------------------------------------------------------------- + + +def test_carries_forward_only_sequence_when_no_stop_id_in_prev(): + prev = { + vehicle_stop_status.CURRENT_STOP_SEQUENCE: 3, + vehicle_stop_status.CURRENT_STATUS: "IN_TRANSIT_TO", + } + + result = compute_stop_status(_RUN_HASH, _POSITION_HASH, prev_state=prev) + + assert result[vehicle_stop_status.CURRENT_STOP_SEQUENCE] == 3 + assert vehicle_stop_status.STOP_ID not in result + + +# --------------------------------------------------------------------------- +# Test 5 — Round-trip: seam output always passes validate_for_write (no prev) +# --------------------------------------------------------------------------- + + +def test_seam_output_passes_validate_for_write_no_prev(): + result = compute_stop_status(_RUN_HASH, _POSITION_HASH, prev_state=None) + + # Must not raise — this is the key contract guarantee + mapping = vehicle_stop_status.validate_for_write(result) + + assert isinstance(mapping, dict) + assert mapping[vehicle_stop_status.CURRENT_STATUS] == "IN_TRANSIT_TO" + + +# --------------------------------------------------------------------------- +# Test 6 — Round-trip: seam output with prev_state also passes validate_for_write +# --------------------------------------------------------------------------- + + +def test_seam_output_passes_validate_for_write_with_prev(): + prev = { + vehicle_stop_status.CURRENT_STOP_SEQUENCE: 10, + vehicle_stop_status.STOP_ID: "TERM-1", + vehicle_stop_status.CURRENT_STATUS: "STOPPED_AT", + } + + result = compute_stop_status(_RUN_HASH, _POSITION_HASH, prev_state=prev) + + # Must not raise + mapping = vehicle_stop_status.validate_for_write(result) + + assert mapping[vehicle_stop_status.CURRENT_STATUS] == "IN_TRANSIT_TO" + assert mapping[vehicle_stop_status.CURRENT_STOP_SEQUENCE] == "10" + assert mapping[vehicle_stop_status.STOP_ID] == "TERM-1" + + +# --------------------------------------------------------------------------- +# Test 7 — run_hash is accepted even when empty (seam ignores it) +# --------------------------------------------------------------------------- + + +def test_accepts_empty_run_hash(): + result = compute_stop_status({}, _POSITION_HASH, prev_state=None) + + assert result[vehicle_stop_status.CURRENT_STATUS] == "IN_TRANSIT_TO" + + +# --------------------------------------------------------------------------- +# Test 8 — position_hash is accepted even when empty (seam ignores it) +# --------------------------------------------------------------------------- + + +def test_accepts_empty_position_hash(): + result = compute_stop_status(_RUN_HASH, {}, prev_state=None) + + assert result[vehicle_stop_status.CURRENT_STATUS] == "IN_TRANSIT_TO" + # Must still pass validate_for_write + vehicle_stop_status.validate_for_write(result) + + +# --------------------------------------------------------------------------- +# Test 9 — prev_state empty dict: behaves same as None (no carry-forward) +# --------------------------------------------------------------------------- + + +def test_empty_prev_state_dict_carries_nothing(): + result = compute_stop_status(_RUN_HASH, _POSITION_HASH, prev_state={}) + + assert result[vehicle_stop_status.CURRENT_STATUS] == "IN_TRANSIT_TO" + assert vehicle_stop_status.CURRENT_STOP_SEQUENCE not in result + assert vehicle_stop_status.STOP_ID not in result diff --git a/backend/runs/domain/progression/tests/test_producer.py b/backend/runs/domain/progression/tests/test_producer.py new file mode 100644 index 0000000..3a37db5 --- /dev/null +++ b/backend/runs/domain/progression/tests/test_producer.py @@ -0,0 +1,162 @@ +"""Unit tests for produce_stop_status — monkeypatched Redis, no I/O. + +The module-level ``r`` object in producer.py is replaced with a fake Redis +so all tests run entirely in-process. + +Patch target: ``runs.domain.progression.producer.r`` +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, call + +import pytest + +import runs.domain.progression.producer as producer_module +from runs.domain.progression.producer import produce_stop_status +from runs.domain.telemetry import keys, vehicle_stop_status + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +VEHICLE_ID = "v-42" +RUN_ID = "run-99" + +_POSITION_RAW = { + "latitude": "51.5074", + "longitude": "-0.1278", + "bearing": "90.0", + "speed": "8.5", + "timestamp": "1700000000", +} + +_RUN_RAW = { + "trip_id": "trip-1", + "route_id": "route-1", + "shape_id": "shape-1", +} + +_PREV_RAW = { + "current_stop_sequence": "5", + "stop_id": "STOP-42", + "current_status": "IN_TRANSIT_TO", +} + + +def _fake_redis( + position_raw: dict | None = None, + run_raw: dict | None = None, + prev_raw: dict | None = None, +) -> MagicMock: + """Return a Mock redis client whose hgetall returns canned hashes.""" + r = MagicMock() + + def hgetall_side_effect(key: str) -> dict: + if key == keys.position_key(VEHICLE_ID): + return position_raw if position_raw is not None else _POSITION_RAW + if key == keys.run_key(RUN_ID): + return run_raw if run_raw is not None else _RUN_RAW + if key == keys.stop_status_key(RUN_ID): + return prev_raw if prev_raw is not None else {} + return {} + + r.hgetall.side_effect = hgetall_side_effect + return r + + +# --------------------------------------------------------------------------- +# Test 1 — Happy path: writes stop_status_key with IN_TRANSIT_TO +# --------------------------------------------------------------------------- + + +def test_produces_stop_status_for_valid_position(monkeypatch): + fake_r = _fake_redis() + monkeypatch.setattr(producer_module, "r", fake_r) + + produce_stop_status(RUN_ID, VEHICLE_ID) + + fake_r.hset.assert_called_once() + call_args = fake_r.hset.call_args + written_key = call_args.args[0] if call_args.args else call_args.kwargs.get("name") + mapping = call_args.kwargs.get("mapping") + + assert written_key == keys.stop_status_key(RUN_ID) + assert mapping[vehicle_stop_status.CURRENT_STATUS] == "IN_TRANSIT_TO" + + # All values must be strings (Redis-ready) + for v in mapping.values(): + assert isinstance(v, str), f"Expected str, got {type(v)!r} for {v!r}" + + +# --------------------------------------------------------------------------- +# Test 2 — Empty position hash: returns early, no hset +# --------------------------------------------------------------------------- + + +def test_returns_early_when_position_hash_is_empty(monkeypatch): + fake_r = _fake_redis(position_raw={}) + monkeypatch.setattr(producer_module, "r", fake_r) + + produce_stop_status(RUN_ID, VEHICLE_ID) + + fake_r.hset.assert_not_called() + + +# --------------------------------------------------------------------------- +# Test 3 — prev_state with sequence and stop_id: carried into written mapping +# --------------------------------------------------------------------------- + + +def test_carries_prev_sequence_and_stop_id_into_written_mapping(monkeypatch): + fake_r = _fake_redis(prev_raw=_PREV_RAW) + monkeypatch.setattr(producer_module, "r", fake_r) + + produce_stop_status(RUN_ID, VEHICLE_ID) + + fake_r.hset.assert_called_once() + mapping = fake_r.hset.call_args.kwargs.get("mapping") + + assert mapping[vehicle_stop_status.CURRENT_STATUS] == "IN_TRANSIT_TO" + assert mapping[vehicle_stop_status.CURRENT_STOP_SEQUENCE] == "5" + assert mapping[vehicle_stop_status.STOP_ID] == "STOP-42" + + +# --------------------------------------------------------------------------- +# Test 4 — No prev stop-status in Redis: still writes with just IN_TRANSIT_TO +# --------------------------------------------------------------------------- + + +def test_writes_without_prev_state_when_stop_status_key_is_empty(monkeypatch): + fake_r = _fake_redis(prev_raw={}) + monkeypatch.setattr(producer_module, "r", fake_r) + + produce_stop_status(RUN_ID, VEHICLE_ID) + + fake_r.hset.assert_called_once() + mapping = fake_r.hset.call_args.kwargs.get("mapping") + + assert mapping[vehicle_stop_status.CURRENT_STATUS] == "IN_TRANSIT_TO" + assert vehicle_stop_status.CURRENT_STOP_SEQUENCE not in mapping + assert vehicle_stop_status.STOP_ID not in mapping + + +# --------------------------------------------------------------------------- +# Test 5 — Correct Redis keys are read and written +# --------------------------------------------------------------------------- + + +def test_reads_and_writes_correct_redis_keys(monkeypatch): + fake_r = _fake_redis() + monkeypatch.setattr(producer_module, "r", fake_r) + + produce_stop_status(RUN_ID, VEHICLE_ID) + + read_keys = {c.args[0] for c in fake_r.hgetall.call_args_list} + assert keys.position_key(VEHICLE_ID) in read_keys + assert keys.run_key(RUN_ID) in read_keys + assert keys.stop_status_key(RUN_ID) in read_keys + + written_key = fake_r.hset.call_args.args[0] if fake_r.hset.call_args.args else fake_r.hset.call_args.kwargs.get("name") + assert written_key == keys.stop_status_key(RUN_ID) From 603d8c49f6a68e61cb127f52af77f3d2cfa23c60 Mon Sep 17 00:00:00 2001 From: Jae Date: Mon, 8 Jun 2026 21:00:19 -0600 Subject: [PATCH 15/23] refactor(schedule_engine): builders read per-entity hashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The payoff of the entity division: the GTFS-RT feed builders now read one ready-made, correctly-typed hash per entity (written by the MQTT consumer and lifecycle actions) and assemble the message by update/assignment instead of the ~110-line per-field try/except block. - Extract a Django-free builders.py (pure assembly) from tasks.py (now thin I/O shells). builders.py imports only the telemetry contracts + keys + fake_stop_times, so the feed dict can be unit-tested against the protobuf without a Django harness. - build_vehicle_position_entity / build_trip_update_entity read run::trip, vehicle::position, vehicle::occupancy, run::vehicle_stop_status, run::congestion_level, vehicle::metadata via from_redis. The decommissioned vehicle::progression read is gone; stop status now comes from the run-keyed vehicle_stop_status hash. - timestamp is lifted to the VehiclePosition/TripUpdate level (the Position sub-message has no timestamp). occupancy_count is intentionally NOT emitted — it is not a GTFS-RT VehiclePosition field and would break ParseDict. - trip falls back to projecting the flat run hash when run::trip is absent. - fake_stop_times is untouched: it receives the vehicle_stop_status hash via its existing progression= parameter. Add __init__.py to realtime_engine and schedule_engine so their tests/ packages are uniquely qualified (the two app-level tests dirs otherwise collide as a bare 'tests' package under pytest). Tests assert the assembled feed ParseDicts + SerializeToStrings into gtfs_realtime_pb2 (the contract gate), the timestamp lift, and the occupancy_count exclusion. --- backend/realtime_engine/__init__.py | 0 backend/schedule_engine/__init__.py | 0 backend/schedule_engine/builders.py | 292 +++++++++++ backend/schedule_engine/tasks.py | 203 +------- backend/schedule_engine/tests/__init__.py | 0 .../schedule_engine/tests/test_builders.py | 483 ++++++++++++++++++ 6 files changed, 784 insertions(+), 194 deletions(-) create mode 100644 backend/realtime_engine/__init__.py create mode 100644 backend/schedule_engine/__init__.py create mode 100644 backend/schedule_engine/builders.py create mode 100644 backend/schedule_engine/tests/__init__.py create mode 100644 backend/schedule_engine/tests/test_builders.py diff --git a/backend/realtime_engine/__init__.py b/backend/realtime_engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/schedule_engine/__init__.py b/backend/schedule_engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/schedule_engine/builders.py b/backend/schedule_engine/builders.py new file mode 100644 index 0000000..bbe306e --- /dev/null +++ b/backend/schedule_engine/builders.py @@ -0,0 +1,292 @@ +"""Pure GTFS-RT feed assembly helpers — no Django, no Channels, no Celery. + +This module is the Django-free half of the schedule_engine feed pipeline. +It reads per-entity Redis hashes (written by the MQTT consumer and lifecycle +actions) via the telemetry contract ``from_redis`` helpers and assembles +GTFS-RT FeedMessage dicts. + +The Celery tasks in ``tasks.py`` call these helpers for the assembly step, +then handle the I/O (file writes, channel-layer broadcast). + +Import graph (one-directional): + tasks.py → builders.py → runs.domain.telemetry.* + keys + builders.py must NOT import tasks.py, Django, Channels, or Celery. +""" + +from datetime import datetime + +from runs.domain.telemetry import ( + congestion_level, + keys, + occupancy, + position, + trip, + vehicle_stop_status, +) + +from .fake_stop_times import build_stop_time_updates + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def get_current_timestamp() -> int: + """Return the current time as a Unix epoch integer.""" + return int(datetime.now().timestamp()) + + +def get_entity_id(vehicle_id: str) -> str: + """Return the GTFS-RT entity id for a given vehicle id.""" + return vehicle_id + + +# --------------------------------------------------------------------------- +# Single-entity assemblers +# --------------------------------------------------------------------------- + + +def build_vehicle_position_entity(r, run_id: str) -> dict | None: + """Assemble one GTFS-RT VehiclePosition entity dict from Redis. + + Reads the per-entity hashes (written by the MQTT consumer and lifecycle + actions) via the telemetry contract ``from_redis`` helpers. Returns ``None`` + when all of the sensor/status hashes are empty (preserves the existing skip + semantics). + + Parameters + ---------- + r: + A Redis client (or any fake with ``hgetall(key) -> dict``). + run_id: + The run identifier; used to look up ``run:`` and all + ``run::*`` hashes. + + Returns + ------- + dict | None + A GTFS-RT entity dict ready for ``json_format.ParseDict`` into + ``gtfs_realtime_pb2.FeedMessage``, or ``None`` to skip this run. + """ + run = r.hgetall(keys.run_key(run_id)) + if not run: + return None + vehicle_id = run.get("vehicle", "") + if not vehicle_id: + return None + + # Read per-entity hashes + position_raw = r.hgetall(keys.position_key(vehicle_id)) + occupancy_raw = r.hgetall(keys.occupancy_key(vehicle_id)) + stop_status_raw = r.hgetall(keys.stop_status_key(run_id)) + meta = r.hgetall(keys.metadata_key(vehicle_id)) + + # Skip when no live sensor/status data is present at all + if not position_raw and not occupancy_raw and not stop_status_raw: + return None + + entity: dict = {"id": vehicle_id, "vehicle": {}} + v = entity["vehicle"] + + # --- trip descriptor ------------------------------------------------ + trip_hash = trip.from_redis(r.hgetall(keys.trip_key(run_id))) + if not trip_hash: + # Fall back to the flat run hash (carries the same trip fields) + trip_hash = trip.from_redis(run) + # Default schedule_relationship to SCHEDULED when absent + if "schedule_relationship" not in trip_hash: + trip_hash["schedule_relationship"] = "SCHEDULED" + v["trip"] = trip_hash + + # --- vehicle descriptor (from metadata raw hash) -------------------- + v["vehicle"] = { + "id": meta.get("id", vehicle_id), + "label": meta.get("label", vehicle_id), + } + if meta.get("license_plate"): + v["vehicle"]["license_plate"] = meta["license_plate"] + if meta.get("wheelchair_accessible"): + v["vehicle"]["wheelchair_accessible"] = meta["wheelchair_accessible"] + + # --- position + timestamp lift (CRITICAL: timestamp at VP level) ---- + pos = position.from_redis(position_raw) + ts = pos.pop("timestamp", None) + if pos: # has at minimum latitude + longitude + v["position"] = pos + v["timestamp"] = ts if ts is not None else get_current_timestamp() + + # --- stop status (current_stop_sequence / stop_id / current_status) - + v.update(vehicle_stop_status.from_redis(stop_status_raw)) + + # --- occupancy: emit ONLY occupancy_status + occupancy_percentage --- + # occupancy_count is NOT a field on the GTFS-RT VehiclePosition message; + # emitting it would make ParseDict raise an unknown-field error. + occ = occupancy.from_redis(occupancy_raw) + if "occupancy_status" in occ: + v["occupancy_status"] = occ["occupancy_status"] + if "occupancy_percentage" in occ: + v["occupancy_percentage"] = occ["occupancy_percentage"] + + # --- congestion level (deferred; present only when hash is set) ----- + cong = congestion_level.from_redis(r.hgetall(keys.congestion_key(run_id))) + if "congestion_level" in cong: + v["congestion_level"] = cong["congestion_level"] + + return entity + + +def build_trip_update_entity(r, run_id: str) -> dict | None: + """Assemble one GTFS-RT TripUpdate entity dict from Redis. + + Returns ``None`` when neither position nor stop-status data are present + (mirrors the current skip semantics that checked ``not position and not + progression``). + + Parameters + ---------- + r: + A Redis client (or any fake with ``hgetall`` / ``smembers``). + run_id: + The run identifier. + + Returns + ------- + dict | None + A GTFS-RT entity dict, or ``None`` to skip this run. + """ + run = r.hgetall(keys.run_key(run_id)) + if not run: + return None + vehicle_id = run.get("vehicle", "") + if not vehicle_id: + return None + + # Read per-entity hashes + position_raw = r.hgetall(keys.position_key(vehicle_id)) + stop_status_raw = r.hgetall(keys.stop_status_key(run_id)) + meta = r.hgetall(keys.metadata_key(vehicle_id)) + + # Mirror current skip semantics: skip when neither position nor stop status + if not position_raw and not stop_status_raw: + return None + + entity: dict = {"id": get_entity_id(vehicle_id), "trip_update": {}} + tu = entity["trip_update"] + + # --- timestamp (lifted from position hash, same as VP) -------------- + pos = position.from_redis(position_raw) + ts = pos.pop("timestamp", None) + tu["timestamp"] = ts if ts is not None else get_current_timestamp() + + # --- trip descriptor ------------------------------------------------ + trip_hash = trip.from_redis(r.hgetall(keys.trip_key(run_id))) + if not trip_hash: + trip_hash = trip.from_redis(run) + if "schedule_relationship" not in trip_hash: + trip_hash["schedule_relationship"] = "SCHEDULED" + tu["trip"] = trip_hash + + # --- vehicle descriptor (note: no wheelchair_accessible here — matches + # current build_trip_updates behaviour) --------------------------- + tu["vehicle"] = { + "id": meta.get("id", vehicle_id), + "label": meta.get("label", vehicle_id), + } + if meta.get("license_plate"): + tu["vehicle"]["license_plate"] = meta["license_plate"] + + # --- stop time updates ---------------------------------------------- + # Pass the raw stop_status hash as `progression=`; fake_stop_times reads + # current_stop_sequence and current_status from it — which this hash provides. + stop_time_updates = build_stop_time_updates(run=run, progression=stop_status_raw) + tu["stop_time_update"] = [] + for update in stop_time_updates: + tu["stop_time_update"].append( + { + "stop_sequence": update["stop_sequence"], + "stop_id": update["stop_id"], + "arrival": { + "time": update["eta_posix"], + "uncertainty": update["uncertainty"], + }, + "departure": { + "time": update["eta_posix"], + "uncertainty": update["uncertainty"], + }, + } + ) + + return entity + + +# --------------------------------------------------------------------------- +# Full FeedMessage assemblers +# --------------------------------------------------------------------------- + + +def build_vehicle_positions_feed(r) -> dict: + """Build a complete GTFS-RT VehiclePositions FeedMessage dict. + + Iterates ``runs:in_progress``, assembles one entity per run via + :func:`build_vehicle_position_entity`, and skips ``None`` results. + + Parameters + ---------- + r: + A Redis client (or fake) supporting ``smembers`` and ``hgetall``. + + Returns + ------- + dict + A FeedMessage dict with ``header`` and ``entity`` list, ready for + ``json_format.ParseDict(feed_dict, gtfs_rt.FeedMessage())``. + """ + feed: dict = { + "header": { + "gtfs_realtime_version": "2.0", + "incrementality": "FULL_DATASET", + "timestamp": get_current_timestamp(), + }, + "entity": [], + } + + for run_id in r.smembers("runs:in_progress"): + entity = build_vehicle_position_entity(r, run_id) + if entity is not None: + feed["entity"].append(entity) + + return feed + + +def build_trip_updates_feed(r) -> dict: + """Build a complete GTFS-RT TripUpdates FeedMessage dict. + + Iterates ``runs:in_progress``, assembles one entity per run via + :func:`build_trip_update_entity`, and skips ``None`` results. + + Parameters + ---------- + r: + A Redis client (or fake) supporting ``smembers`` and ``hgetall``. + + Returns + ------- + dict + A FeedMessage dict with ``header`` and ``entity`` list. + """ + feed: dict = { + "header": { + "gtfs_realtime_version": "2.0", + "incrementality": "FULL_DATASET", + "timestamp": get_current_timestamp(), + }, + "entity": [], + } + + for run_id in r.smembers("runs:in_progress"): + entity = build_trip_update_entity(r, run_id) + if entity is not None: + feed["entity"].append(entity) + + return feed diff --git a/backend/schedule_engine/tasks.py b/backend/schedule_engine/tasks.py index f221588..fdea835 100644 --- a/backend/schedule_engine/tasks.py +++ b/backend/schedule_engine/tasks.py @@ -8,7 +8,13 @@ from django.conf import settings from google.transit import gtfs_realtime_pb2 as gtfs_rt from google.protobuf import json_format -from .fake_stop_times import build_stop_time_updates + +from .builders import ( + build_vehicle_positions_feed, + build_trip_updates_feed, + get_current_timestamp, + get_entity_id, +) _redis = None @@ -30,128 +36,12 @@ def get_feed_version(): return "1.0.0" -def get_entity_id(vehicle_id): - return vehicle_id - - -def get_current_timestamp(): - return int(datetime.now().timestamp()) - - @shared_task(queue="schedule_engine") def build_vehicle_positions(): """Build the VehiclePosition feed message.""" r = get_redis() - feed_message = { - "header": { - "gtfs_realtime_version": "2.0", - "incrementality": "FULL_DATASET", - "timestamp": get_current_timestamp(), - }, - "entity": [], - } - - runs_in_progress = r.smembers("runs:in_progress") - - for run_id in runs_in_progress: - run = r.hgetall(f"run:{run_id}") # run::trip - if not run: - continue - vehicle_id = run.get("vehicle", "") # run::vehicle - if not vehicle_id: - continue - - position = r.hgetall( - f"vehicle:{vehicle_id}:position" - ) # run::position (hash) - progression = r.hgetall(f"vehicle:{vehicle_id}:progression") - occupancy = r.hgetall(f"vehicle:{vehicle_id}:occupancy") - vehicle_meta = r.hgetall( - f"vehicle:{vehicle_id}:metadata" - ) # run::vehicle - - if not position and not progression and not occupancy: - continue - - entity: dict = {"id": vehicle_id, "vehicle": {}} - v = entity["vehicle"] - - if position.get("timestamp"): - try: - v["timestamp"] = int(float(position["timestamp"])) - except (ValueError, TypeError): - v["timestamp"] = get_current_timestamp() - else: - v["timestamp"] = get_current_timestamp() - - v["trip"] = { - "trip_id": run.get("trip_id", ""), - "route_id": run.get("route_id", ""), - "schedule_relationship": run.get("schedule_relationship", "SCHEDULED"), - } - if run.get("direction_id") not in (None, ""): - try: - v["trip"]["direction_id"] = int(run["direction_id"]) - except (ValueError, TypeError): - pass - if run.get("start_time"): - v["trip"]["start_time"] = run["start_time"] - if run.get("start_date"): - v["trip"]["start_date"] = run["start_date"] - - v["vehicle"] = { - "id": vehicle_meta.get("id", vehicle_id), - "label": vehicle_meta.get("label", vehicle_id), - } - if vehicle_meta.get("license_plate"): - v["vehicle"]["license_plate"] = vehicle_meta["license_plate"] - if vehicle_meta.get("wheelchair_accessible"): - v["vehicle"]["wheelchair_accessible"] = vehicle_meta[ - "wheelchair_accessible" - ] - - if position: - try: - pos_entry: dict = { - "latitude": float(position.get("latitude", 0)), - "longitude": float(position.get("longitude", 0)), - } - if position.get("bearing"): - pos_entry["bearing"] = float(position["bearing"]) - if position.get("speed"): - pos_entry["speed"] = float(position["speed"]) - if position.get("odometer"): - pos_entry["odometer"] = float(position["odometer"]) - v["position"] = pos_entry - except (ValueError, TypeError): - pass - - if progression: - if progression.get("current_stop_sequence"): - try: - v["current_stop_sequence"] = int( - progression["current_stop_sequence"] - ) - except (ValueError, TypeError): - pass - if progression.get("stop_id"): - v["stop_id"] = progression["stop_id"] - if progression.get("current_status"): - v["current_status"] = progression["current_status"] - if progression.get("congestion_level"): - v["congestion_level"] = progression["congestion_level"] - - if occupancy: - if occupancy.get("occupancy_status"): - v["occupancy_status"] = occupancy["occupancy_status"] - if occupancy.get("occupancy_percentage"): - try: - v["occupancy_percentage"] = int(occupancy["occupancy_percentage"]) - except (ValueError, TypeError): - pass - - feed_message["entity"].append(entity) + feed_message = build_vehicle_positions_feed(r) output_dir = settings.BASE_DIR / "feed" / "files" output_dir.mkdir(parents=True, exist_ok=True) @@ -172,85 +62,10 @@ def build_vehicle_positions(): def build_trip_updates(): r = get_redis() - feed_message = { - "header": { - "gtfs_realtime_version": "2.0", - "incrementality": "FULL_DATASET", - "timestamp": get_current_timestamp(), - }, - "entity": [], - } + feed_message = build_trip_updates_feed(r) runs_in_progress = r.smembers("runs:in_progress") - for run_id in runs_in_progress: - run = r.hgetall(f"run:{run_id}") - if not run: - continue - vehicle_id = run.get("vehicle", "") - if not vehicle_id: - continue - - position = r.hgetall(f"vehicle:{vehicle_id}:position") - progression = r.hgetall(f"vehicle:{vehicle_id}:progression") - metadata = r.hgetall(f"vehicle:{vehicle_id}:metadata") - - if not position and not progression: - continue - - entity: dict = {"id": get_entity_id(vehicle_id), "trip_update": {}} - tu = entity["trip_update"] - - if position.get("timestamp"): - try: - tu["timestamp"] = int(float(position["timestamp"])) - except (ValueError, TypeError): - tu["timestamp"] = get_current_timestamp() - else: - tu["timestamp"] = get_current_timestamp() - - tu["trip"] = { - "trip_id": run.get("trip_id", ""), - "route_id": run.get("route_id", ""), - "schedule_relationship": run.get("schedule_relationship", "SCHEDULED"), - } - if run.get("direction_id") not in (None, ""): - try: - tu["trip"]["direction_id"] = int(run["direction_id"]) - except (ValueError, TypeError): - pass - if run.get("start_time"): - tu["trip"]["start_time"] = run["start_time"] - if run.get("start_date"): - tu["trip"]["start_date"] = run["start_date"] - - tu["vehicle"] = { - "id": metadata.get("id", vehicle_id), - "label": metadata.get("label", vehicle_id), - } - if metadata.get("license_plate"): - tu["vehicle"]["license_plate"] = metadata["license_plate"] - - stop_time_updates = build_stop_time_updates(run=run, progression=progression) - tu["stop_time_update"] = [] - for update in stop_time_updates: - tu["stop_time_update"].append( - { - "stop_sequence": update["stop_sequence"], - "stop_id": update["stop_id"], - "arrival": { - "time": update["eta_posix"], - "uncertainty": update["uncertainty"], - }, - "departure": { - "time": update["eta_posix"], - "uncertainty": update["uncertainty"], - }, - } - ) - - feed_message["entity"].append(entity) - output_dir = settings.BASE_DIR / "feed" / "files" output_dir.mkdir(parents=True, exist_ok=True) diff --git a/backend/schedule_engine/tests/__init__.py b/backend/schedule_engine/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/schedule_engine/tests/test_builders.py b/backend/schedule_engine/tests/test_builders.py new file mode 100644 index 0000000..a2179a2 --- /dev/null +++ b/backend/schedule_engine/tests/test_builders.py @@ -0,0 +1,483 @@ +"""Django-free unit tests for schedule_engine.builders. + +These tests import ONLY ``schedule_engine.builders`` (never ``tasks``). +A small dict-backed FakeRedis supports the subset of the Redis API the +builders use: ``smembers(key) -> set`` and ``hgetall(key) -> dict``. +All values in hashes are strings, matching the ``decode_responses=True`` +behaviour of the live client. + +The key proto gate: every feed dict is passed through +``json_format.ParseDict(feed, gtfs_rt.FeedMessage())`` *and* +``.SerializeToString()`` to catch proto2 unknown-field / required-field +errors at test time. +""" + +import json + +import pytest +from google.protobuf import json_format +from google.transit import gtfs_realtime_pb2 as gtfs_rt + +from schedule_engine.builders import ( + build_trip_updates_feed, + build_vehicle_position_entity, + build_vehicle_positions_feed, + get_current_timestamp, + get_entity_id, +) + + +# --------------------------------------------------------------------------- +# Fake Redis +# --------------------------------------------------------------------------- + + +class FakeRedis: + """Minimal dict-backed Redis stub. + + Supports: + - ``hgetall(key) -> dict[str, str]`` (empty dict when absent) + - ``smembers(key) -> set[str]`` (empty set when absent) + """ + + def __init__(self, data: dict | None = None): + # data maps key -> value where value is either a dict (hash) or set (set) + self._data: dict = data or {} + + def hgetall(self, key: str) -> dict: + val = self._data.get(key, {}) + return dict(val) if isinstance(val, dict) else {} + + def smembers(self, key: str) -> set: + val = self._data.get(key, set()) + return set(val) if isinstance(val, set) else set() + + +# --------------------------------------------------------------------------- +# Shared seed data helpers +# --------------------------------------------------------------------------- + +RUN_ID = "run-001" +VEHICLE_ID = "vehicle-42" + + +def _full_redis_data() -> dict: + """Return a FakeRedis data dict seeded with all hashes for one in-progress run.""" + return { + # membership set + "runs:in_progress": {RUN_ID}, + # flat run hash (lifecycle record + trip fields for fallback) + f"run:{RUN_ID}": { + "vehicle": VEHICLE_ID, + "trip_id": "trip-abc", + "route_id": "route-1", + "direction_id": "0", + "schedule_relationship": "SCHEDULED", + "shape_id": "shape-X", + "start_time": "08:00:00", + "start_date": "20260608", + }, + # GTFS-RT-shaped trip projection + f"run:{RUN_ID}:trip": { + "trip_id": "trip-abc", + "route_id": "route-1", + "direction_id": "0", + "schedule_relationship": "SCHEDULED", + "start_time": "08:00:00", + "start_date": "20260608", + }, + # edge position hash (vehicle-keyed) + f"vehicle:{VEHICLE_ID}:position": { + "latitude": "51.5074", + "longitude": "-0.1278", + "bearing": "90.0", + "timestamp": "1700000000", + }, + # edge occupancy hash — includes occupancy_count (edge-only field) + f"vehicle:{VEHICLE_ID}:occupancy": { + "occupancy_percentage": "35", + "occupancy_count": "18", + "occupancy_status": "FEW_SEATS_AVAILABLE", + }, + # server stop-status hash (run-keyed) + f"run:{RUN_ID}:vehicle_stop_status": { + "current_stop_sequence": "3", + "stop_id": "stop-99", + "current_status": "IN_TRANSIT_TO", + }, + # vehicle metadata + f"vehicle:{VEHICLE_ID}:metadata": { + "id": VEHICLE_ID, + "label": "Bus 42", + "license_plate": "XYZ-1234", + }, + } + + +def _parse_feed(feed_dict: dict) -> gtfs_rt.FeedMessage: + """Round-trip a feed dict through ParseDict + SerializeToString (the proto gate).""" + msg = json_format.ParseDict(feed_dict, gtfs_rt.FeedMessage()) + # SerializeToString catches proto2 required-field / unknown-field errors + raw = msg.SerializeToString() + # Deserialise back to verify the bytes are valid + roundtripped = gtfs_rt.FeedMessage() + roundtripped.ParseFromString(raw) + return roundtripped + + +# --------------------------------------------------------------------------- +# Helper tests +# --------------------------------------------------------------------------- + + +def test_get_current_timestamp_is_positive_int(): + ts = get_current_timestamp() + assert isinstance(ts, int) + assert ts > 0 + + +def test_get_entity_id_returns_vehicle_id(): + assert get_entity_id("v-7") == "v-7" + + +# --------------------------------------------------------------------------- +# build_vehicle_positions_feed — full seed +# --------------------------------------------------------------------------- + + +class TestBuildVehiclePositionsFeedFullSeed: + def setup_method(self): + self.r = FakeRedis(_full_redis_data()) + self.feed = build_vehicle_positions_feed(self.r) + self.proto_msg = _parse_feed(self.feed) + + def test_proto_gate_parse_and_serialize(self): + """ParseDict + SerializeToString must not raise.""" + # Already executed in setup_method via _parse_feed; reaching here = pass. + assert self.proto_msg is not None + + def test_exactly_one_entity(self): + assert len(self.feed["entity"]) == 1 + + def test_entity_id_is_vehicle_id(self): + assert self.feed["entity"][0]["id"] == VEHICLE_ID + + def test_timestamp_lifted_to_vehicle_position_level(self): + v = self.feed["entity"][0]["vehicle"] + assert v["timestamp"] == 1700000000 + + def test_position_sub_dict_has_no_timestamp(self): + pos = self.feed["entity"][0]["vehicle"]["position"] + assert "timestamp" not in pos + + def test_position_has_lat_lon(self): + pos = self.feed["entity"][0]["vehicle"]["position"] + assert pytest.approx(pos["latitude"]) == 51.5074 + assert pytest.approx(pos["longitude"]) == -0.1278 + + def test_current_status_from_stop_status_hash(self): + v = self.feed["entity"][0]["vehicle"] + assert v["current_status"] == "IN_TRANSIT_TO" + + def test_current_stop_sequence_from_stop_status_hash(self): + v = self.feed["entity"][0]["vehicle"] + assert v["current_stop_sequence"] == 3 + + def test_stop_id_from_stop_status_hash(self): + v = self.feed["entity"][0]["vehicle"] + assert v["stop_id"] == "stop-99" + + def test_occupancy_status_present(self): + v = self.feed["entity"][0]["vehicle"] + assert v["occupancy_status"] == "FEW_SEATS_AVAILABLE" + + def test_occupancy_percentage_present(self): + v = self.feed["entity"][0]["vehicle"] + assert v["occupancy_percentage"] == 35 + + def test_trip_id_from_trip_hash(self): + v = self.feed["entity"][0]["vehicle"] + assert v["trip"]["trip_id"] == "trip-abc" + + def test_vehicle_descriptor_present(self): + v = self.feed["entity"][0]["vehicle"] + assert v["vehicle"]["id"] == VEHICLE_ID + assert v["vehicle"]["label"] == "Bus 42" + + def test_proto_entity_timestamp_matches(self): + entity = self.proto_msg.entity[0] + assert entity.vehicle.timestamp == 1700000000 + + def test_proto_position_has_lat_lon(self): + entity = self.proto_msg.entity[0] + assert pytest.approx(entity.vehicle.position.latitude) == 51.5074 + assert pytest.approx(entity.vehicle.position.longitude) == -0.1278 + + +# --------------------------------------------------------------------------- +# Explicit occupancy_count exclusion +# --------------------------------------------------------------------------- + + +class TestOccupancyCountExclusion: + """occupancy_count must NOT appear in the feed dict — it is not a GTFS-RT + VehiclePosition field and would cause ParseDict to raise.""" + + def setup_method(self): + self.r = FakeRedis(_full_redis_data()) + self.feed = build_vehicle_positions_feed(self.r) + + def test_occupancy_count_absent_from_entity_vehicle_dict(self): + v = self.feed["entity"][0]["vehicle"] + assert "occupancy_count" not in v + + def test_occupancy_count_absent_from_serialised_json(self): + # Belt-and-suspenders: check the JSON string too + feed_json = json.dumps(self.feed) + assert "occupancy_count" not in feed_json + + def test_proto_gate_still_succeeds_without_occupancy_count(self): + """Confirm ParseDict succeeds on the dict that excludes occupancy_count.""" + _parse_feed(self.feed) # would raise if occupancy_count leaked through + + +# --------------------------------------------------------------------------- +# Timestamp lift +# --------------------------------------------------------------------------- + + +class TestTimestampLift: + """A position hash with ``timestamp`` must surface it at VehiclePosition + level, not inside the position sub-dict.""" + + def test_timestamp_in_position_hash_lifted_to_vp_level(self): + data = _full_redis_data() + data[f"vehicle:{VEHICLE_ID}:position"]["timestamp"] = "1700001234" + r = FakeRedis(data) + feed = build_vehicle_positions_feed(r) + v = feed["entity"][0]["vehicle"] + assert v["timestamp"] == 1700001234 + assert "timestamp" not in v["position"] + + def test_missing_timestamp_uses_current_time(self): + data = _full_redis_data() + del data[f"vehicle:{VEHICLE_ID}:position"]["timestamp"] + r = FakeRedis(data) + feed = build_vehicle_positions_feed(r) + v = feed["entity"][0]["vehicle"] + # Should be a recent unix timestamp (within the last minute) + now = get_current_timestamp() + assert 0 < v["timestamp"] <= now + 5 # small tolerance for clock skew + + def test_timestamp_absent_from_position_sub_dict_when_missing(self): + data = _full_redis_data() + del data[f"vehicle:{VEHICLE_ID}:position"]["timestamp"] + r = FakeRedis(data) + feed = build_vehicle_positions_feed(r) + v = feed["entity"][0]["vehicle"] + assert "timestamp" not in v.get("position", {}) + + +# --------------------------------------------------------------------------- +# Skip semantics +# --------------------------------------------------------------------------- + + +class TestSkipSemantics: + """A run whose vehicle has no position, occupancy, or stop-status data + must produce zero entities in the feed.""" + + def test_no_sensor_data_produces_zero_entities(self): + data = { + "runs:in_progress": {RUN_ID}, + f"run:{RUN_ID}": { + "vehicle": VEHICLE_ID, + "trip_id": "trip-abc", + "route_id": "route-1", + }, + # Deliberately omit position, occupancy, and stop_status hashes + } + r = FakeRedis(data) + feed = build_vehicle_positions_feed(r) + assert feed["entity"] == [] + + def test_run_missing_from_redis_produces_zero_entities(self): + data = { + "runs:in_progress": {"nonexistent-run"}, + } + r = FakeRedis(data) + feed = build_vehicle_positions_feed(r) + assert feed["entity"] == [] + + def test_run_without_vehicle_field_produces_zero_entities(self): + data = { + "runs:in_progress": {RUN_ID}, + f"run:{RUN_ID}": {"trip_id": "trip-abc"}, # no vehicle field + } + r = FakeRedis(data) + feed = build_vehicle_positions_feed(r) + assert feed["entity"] == [] + + def test_proto_gate_succeeds_on_empty_feed(self): + data = {"runs:in_progress": set()} + r = FakeRedis(data) + feed = build_vehicle_positions_feed(r) + assert feed["entity"] == [] + _parse_feed(feed) # must not raise + + +# --------------------------------------------------------------------------- +# build_vehicle_position_entity — unit-level +# --------------------------------------------------------------------------- + + +class TestBuildVehiclePositionEntityUnit: + def test_returns_none_when_run_missing(self): + r = FakeRedis({"runs:in_progress": {RUN_ID}}) + assert build_vehicle_position_entity(r, RUN_ID) is None + + def test_returns_none_when_no_vehicle_field(self): + data = {f"run:{RUN_ID}": {"trip_id": "t"}} + r = FakeRedis(data) + assert build_vehicle_position_entity(r, RUN_ID) is None + + def test_schedule_relationship_defaults_to_scheduled(self): + data = _full_redis_data() + # Remove schedule_relationship from the trip hash + del data[f"run:{RUN_ID}:trip"]["schedule_relationship"] + del data[f"run:{RUN_ID}"]["schedule_relationship"] + r = FakeRedis(data) + entity = build_vehicle_position_entity(r, RUN_ID) + assert entity is not None + assert entity["vehicle"]["trip"]["schedule_relationship"] == "SCHEDULED" + + def test_trip_fallback_from_flat_run_hash(self): + """When run::trip is absent, trip fields come from the flat run hash.""" + data = _full_redis_data() + del data[f"run:{RUN_ID}:trip"] + r = FakeRedis(data) + entity = build_vehicle_position_entity(r, RUN_ID) + assert entity is not None + assert entity["vehicle"]["trip"]["trip_id"] == "trip-abc" + + def test_congestion_level_emitted_when_present(self): + data = _full_redis_data() + data[f"run:{RUN_ID}:congestion_level"] = {"congestion_level": "STOP_AND_GO"} + r = FakeRedis(data) + entity = build_vehicle_position_entity(r, RUN_ID) + assert entity is not None + assert entity["vehicle"]["congestion_level"] == "STOP_AND_GO" + + def test_congestion_level_absent_when_hash_missing(self): + r = FakeRedis(_full_redis_data()) + entity = build_vehicle_position_entity(r, RUN_ID) + assert entity is not None + assert "congestion_level" not in entity["vehicle"] + + def test_license_plate_included_when_present(self): + r = FakeRedis(_full_redis_data()) + entity = build_vehicle_position_entity(r, RUN_ID) + assert entity is not None + assert entity["vehicle"]["vehicle"]["license_plate"] == "XYZ-1234" + + def test_wheelchair_accessible_included_when_present(self): + data = _full_redis_data() + data[f"vehicle:{VEHICLE_ID}:metadata"]["wheelchair_accessible"] = "1" + r = FakeRedis(data) + entity = build_vehicle_position_entity(r, RUN_ID) + assert entity is not None + assert entity["vehicle"]["vehicle"]["wheelchair_accessible"] == "1" + + +# --------------------------------------------------------------------------- +# build_trip_updates_feed +# --------------------------------------------------------------------------- + + +class TestBuildTripUpdatesFeed: + def setup_method(self): + self.r = FakeRedis(_full_redis_data()) + self.feed = build_trip_updates_feed(self.r) + self.proto_msg = _parse_feed(self.feed) + + def test_proto_gate_parse_and_serialize(self): + assert self.proto_msg is not None + + def test_exactly_one_entity(self): + assert len(self.feed["entity"]) == 1 + + def test_trip_id_from_trip_hash(self): + tu = self.feed["entity"][0]["trip_update"] + assert tu["trip"]["trip_id"] == "trip-abc" + + def test_vehicle_descriptor_present(self): + tu = self.feed["entity"][0]["trip_update"] + assert tu["vehicle"]["id"] == VEHICLE_ID + assert tu["vehicle"]["label"] == "Bus 42" + + def test_vehicle_no_wheelchair_in_trip_update(self): + """Trip updates do not include wheelchair_accessible (matches current code).""" + data = _full_redis_data() + data[f"vehicle:{VEHICLE_ID}:metadata"]["wheelchair_accessible"] = "1" + r = FakeRedis(data) + feed = build_trip_updates_feed(r) + tu = feed["entity"][0]["trip_update"] + assert "wheelchair_accessible" not in tu["vehicle"] + + def test_timestamp_lifted_from_position(self): + tu = self.feed["entity"][0]["trip_update"] + assert tu["timestamp"] == 1700000000 + + def test_stop_time_update_key_present(self): + tu = self.feed["entity"][0]["trip_update"] + assert "stop_time_update" in tu + # The route_id in test data may not be in the CSV; list may be empty — that's fine. + assert isinstance(tu["stop_time_update"], list) + + def test_skip_when_no_position_and_no_stop_status(self): + data = { + "runs:in_progress": {RUN_ID}, + f"run:{RUN_ID}": {"vehicle": VEHICLE_ID, "trip_id": "t", "route_id": "r"}, + f"vehicle:{VEHICLE_ID}:metadata": {"id": VEHICLE_ID, "label": "B"}, + } + r = FakeRedis(data) + feed = build_trip_updates_feed(r) + assert feed["entity"] == [] + + def test_proto_gate_trip_id_roundtrip(self): + entity = self.proto_msg.entity[0] + assert entity.trip_update.trip.trip_id == "trip-abc" + + def test_license_plate_in_trip_update_vehicle(self): + tu = self.feed["entity"][0]["trip_update"] + assert tu["vehicle"]["license_plate"] == "XYZ-1234" + + def test_trip_fallback_from_flat_run_hash(self): + data = _full_redis_data() + del data[f"run:{RUN_ID}:trip"] + r = FakeRedis(data) + feed = build_trip_updates_feed(r) + assert len(feed["entity"]) == 1 + tu = feed["entity"][0]["trip_update"] + assert tu["trip"]["trip_id"] == "trip-abc" + + +# --------------------------------------------------------------------------- +# Feed header +# --------------------------------------------------------------------------- + + +class TestFeedHeader: + def test_vp_feed_header_fields(self): + r = FakeRedis({"runs:in_progress": set()}) + feed = build_vehicle_positions_feed(r) + assert feed["header"]["gtfs_realtime_version"] == "2.0" + assert feed["header"]["incrementality"] == "FULL_DATASET" + assert isinstance(feed["header"]["timestamp"], int) + + def test_tu_feed_header_fields(self): + r = FakeRedis({"runs:in_progress": set()}) + feed = build_trip_updates_feed(r) + assert feed["header"]["gtfs_realtime_version"] == "2.0" + assert feed["header"]["incrementality"] == "FULL_DATASET" + assert isinstance(feed["header"]["timestamp"], int) From 8ee2a8718b681f5e83559fa3a7a27aa28f963959 Mon Sep 17 00:00:00 2001 From: Jae Date: Mon, 8 Jun 2026 21:06:53 -0600 Subject: [PATCH 16/23] chore(redis): align ops scripts + mapping docstring to entity keys Bring the operational scripts and the Run model's mapping docstring in line with the entity key schema now that producers/builders use it. - cleanup_redis.py: drop the decommissioned vehicle:*:progression deletes; force_cleanup_all now also clears the server run-keyed entity hashes (run:*:trip, run:*:vehicle_stop_status, run:*:congestion_level). - inspect_redis.py: stop reading vehicle::progression; resolve the run via vehicle::current_run and read run::vehicle_stop_status (stop status) and run::congestion_level, printed as separate sections. - runs/models.py: rewrite the stale bottom docstring (which claimed run::position / :current_status / :occupancy_status) to the real schema, split by producer (edge vehicle:* vs server run:*), noting the timestamp lift and that vehicle::progression is decommissioned. (No docs reference these keys; backend/scripts/cleanup_runs.py was also aligned but is untracked and left for separate staging.) --- backend/runs/models.py | 58 ++++++++++++++++++++++++++++------------ scripts/cleanup_redis.py | 13 ++++++--- scripts/inspect_redis.py | 31 ++++++++++++++------- 3 files changed, 72 insertions(+), 30 deletions(-) diff --git a/backend/runs/models.py b/backend/runs/models.py index 54d321e..1ee4e40 100644 --- a/backend/runs/models.py +++ b/backend/runs/models.py @@ -167,28 +167,52 @@ class OccupancyStatus(models.Model): """ -Mapping for GTFS Realtime VehiclePosition to our data model: +Mapping: GTFS-RT VehiclePosition entities → Redis keys -Run: -- trip (Redis (hash): run::trip) -- vehicle (Redis (hash): run::vehicle) +Ownership is visible in the namespace: + Edge-sensed → vehicle::* (written by MQTT consumer) + Server-computed → run::* (written by lifecycle actions / progression step) -Position: -- position (Redis (hash): run::position) +Position (vehicle::position) + Producer: edge (MQTT) + Fields: latitude, longitude, bearing?, speed?, odometer?, timestamp + Note: VehiclePosition.timestamp is lifted from this hash by the builder; + it is NOT included inside the GTFS-RT Position sub-message. -VehicleStopStatus: -- current_stop_sequence (Redis (string): run::current_stop_sequence) -- stop_id (Redis (string): run::stop_id) -- current_status (Redis (string): run::current_status) +OccupancyStatus (vehicle::occupancy) + Producer: edge (MQTT) for raw counts; server buckets the enum at ingestion. + Fields: occupancy_percentage?, occupancy_count?, occupancy_status (enum) -CongestionLevel: -- congestion_level (Redis (string): run::congestion_level) +VehicleDescriptor / metadata (vehicle::metadata) + Producer: server (lifecycle action on run start) + Fields: id, label, license_plate?, wheelchair_accessible? -OccupancyStatus: -- occupancy_status (Redis (string): run::occupancy_status) -- occupancy_percentage (Redis (string): run::occupancy_percentage) +TripDescriptor (run::trip) + Producer: server (lifecycle action — GTFS-RT-shaped projection of run: hash) + Fields: trip_id, route_id, direction_id?, schedule_relationship?, start_time?, start_date? + +VehicleStopStatus (run::vehicle_stop_status) + Producer: server (progression step — map-matching position against assigned trip stops) + Fields: current_stop_sequence?, stop_id?, current_status (enum) + Note: Run-keyed because stop identity requires the assigned trip; not a raw sensor value. + +CongestionLevel (run::congestion_level) + Producer: server (deferred — producer not yet implemented; key reserved) + Fields: congestion_level (enum) + +Run assignment record (run:) + Producer: server (lifecycle action) + Fields: trip_id, route_id, direction_id, shape_id, schedule_relationship, + start_time, start_date, vehicle, operator, run_lifecycle_state, … + Note: run::trip is the GTFS-RT-shaped projection of its trip subset. + +Stale detection: runs:last_seen: (string, ISO-8601 timestamp) +Active-run sets: runs:in_progress, runs:tracking + +DECOMMISSIONED: + vehicle::progression — removed; data split into + run::vehicle_stop_status (stop fields) and run::congestion_level. Not mapped: -- multi_carriage_details (omitted) -- timestamp + multi_carriage_details (omitted) """ diff --git a/scripts/cleanup_redis.py b/scripts/cleanup_redis.py index e54c43d..d7f05cc 100755 --- a/scripts/cleanup_redis.py +++ b/scripts/cleanup_redis.py @@ -127,11 +127,13 @@ def delete_vehicle_data(r: redis.Redis, vehicle_id: str, dry_run: bool = False) Returns: Number of keys deleted (or would be deleted) """ - # Keys to delete + # Keys to delete (vehicle-scoped edge data only). + # Run-keyed stop-status and congestion hashes (run::vehicle_stop_status, + # run::congestion_level) are cleaned via the run path / force_cleanup_all. + # vehicle::progression is decommissioned — no longer written. keys_to_delete = [ f"vehicle:{vehicle_id}:metadata", f"vehicle:{vehicle_id}:position", - f"vehicle:{vehicle_id}:progression", f"vehicle:{vehicle_id}:occupancy", ] @@ -202,12 +204,15 @@ def force_cleanup_all(r: redis.Redis, dry_run: bool = False) -> int: """ deleted_count = 0 - # Get all vehicle-related keys + # Get all vehicle-keyed and run-keyed entity keys. + # vehicle:*:progression is decommissioned — omitted intentionally. patterns = [ "vehicle:*:metadata", "vehicle:*:position", - "vehicle:*:progression", "vehicle:*:occupancy", + "run:*:trip", + "run:*:vehicle_stop_status", + "run:*:congestion_level", ] for pattern in patterns: diff --git a/scripts/inspect_redis.py b/scripts/inspect_redis.py index 1d61b52..40c808d 100755 --- a/scripts/inspect_redis.py +++ b/scripts/inspect_redis.py @@ -138,9 +138,15 @@ def inspect_vehicles(r: redis.Redis, show_age: bool = False) -> None: # Get vehicle data vehicle_data = r.hgetall(f"vehicle:{vehicle_id}:metadata") position = r.hgetall(f"vehicle:{vehicle_id}:position") - progression = r.hgetall(f"vehicle:{vehicle_id}:progression") occupancy = r.hgetall(f"vehicle:{vehicle_id}:occupancy") + # Resolve the current run for this vehicle (vehicle → run linkage). + # Stop status and congestion are run-keyed because they depend on the + # assigned trip; vehicle:*:progression is decommissioned. + run_id = r.get(f"vehicle:{vehicle_id}:current_run") + stop_status = r.hgetall(f"run:{run_id}:vehicle_stop_status") if run_id else {} + congestion = r.hgetall(f"run:{run_id}:congestion_level") if run_id else {} + print(f" {vehicle_id}") # Basic data @@ -169,15 +175,22 @@ def inspect_vehicles(r: redis.Redis, show_age: bool = False) -> None: else: print(f" Position: [NO DATA]") - # Progression - if progression: - print(f" Progression:") - print(f" Stop Seq: {progression.get('current_stop_sequence', 'N/A')}") - print(f" Stop ID: {progression.get('stop_id', 'N/A')}") - print(f" Status: {progression.get('current_status', 'N/A')}") - print(f" Congest: {progression.get('congestion_level', 'N/A')}") + # Stop status (run::vehicle_stop_status — server-computed, run-keyed) + if stop_status: + print(f" Stop Status (run:{run_id}):") + print(f" Stop Seq: {stop_status.get('current_stop_sequence', 'N/A')}") + print(f" Stop ID: {stop_status.get('stop_id', 'N/A')}") + print(f" Status: {stop_status.get('current_status', 'N/A')}") + else: + run_label = f"run:{run_id}" if run_id else "no current run" + print(f" Stop Status ({run_label}): [NO DATA]") + + # Congestion level (run::congestion_level — server-computed, deferred) + if congestion: + print(f" Congestion:") + print(f" Level: {congestion.get('congestion_level', 'N/A')}") else: - print(f" Progression: [NO DATA]") + print(f" Congestion: [NO DATA]") # Occupancy if occupancy: From 04cfb664074194e52ce6918cf21486a946893b38 Mon Sep 17 00:00:00 2001 From: Jae Date: Tue, 9 Jun 2026 19:05:33 -0600 Subject: [PATCH 17/23] refactor(detection): instance-based detectors with enum events Convert lifecycle and periodic detectors from static methods on classes to instance methods, building results via self.fsm, and replace all event string literals with RunLifecycleEvents members. - lifecycle_detectors.py / periodic_detectors.py: drop @staticmethod, reference self.fsm, use RunLifecycleEvents.* (str-enum, value-compatible) - registry.py: store detector instances instead of class references - tests: call detectors via instances - result.py: clarify DetectionResult.event is a RunLifecycleEvents member --- .../domain/detection/lifecycle_detectors.py | 23 +++++++++--------- .../domain/detection/periodic_detectors.py | 17 ++++++++----- backend/runs/domain/detection/registry.py | 12 +++++----- backend/runs/domain/detection/result.py | 4 +++- .../domain/detection/tests/test_detectors.py | 24 +++++++++---------- 5 files changed, 43 insertions(+), 37 deletions(-) diff --git a/backend/runs/domain/detection/lifecycle_detectors.py b/backend/runs/domain/detection/lifecycle_detectors.py index 3815531..8290017 100644 --- a/backend/runs/domain/detection/lifecycle_detectors.py +++ b/backend/runs/domain/detection/lifecycle_detectors.py @@ -10,6 +10,7 @@ from typing import Any from runs.domain.lifecycle.states import RunLifecycleStates +from runs.domain.lifecycle.events import RunLifecycleEvents from runs.domain.detection.result import DetectionResult FSM = "lifecycle" @@ -23,12 +24,11 @@ class RunTrackingStartedDetector: fsm = FSM - @staticmethod def detect( - run_state: str, leaf: str, data: dict[str, Any], payload: dict[str, Any] + self, run_state: str, leaf: str, data: dict[str, Any], payload: dict[str, Any] ) -> DetectionResult | None: if run_state == RunLifecycleStates.CONFIRMED.value: - return DetectionResult(FSM, "run_tracking_started") + return DetectionResult(self.fsm, RunLifecycleEvents.RUN_TRACKING_STARTED) return None @@ -37,13 +37,12 @@ class RunStartedDetector: fsm = FSM - @staticmethod def detect( - run_state: str, leaf: str, data: dict[str, Any], payload: dict[str, Any] + self, run_state: str, leaf: str, data: dict[str, Any], payload: dict[str, Any] ) -> DetectionResult | None: if run_state == RunLifecycleStates.TRACKING.value and leaf == "position": if float(data.get("speed", 0) or 0) > MIN_MOVING_SPEED: - return DetectionResult(FSM, "run_started") + return DetectionResult(self.fsm, RunLifecycleEvents.RUN_STARTED) return None @@ -52,12 +51,11 @@ class RunTrackingRestoredDetector: fsm = FSM - @staticmethod def detect( - run_state: str, leaf: str, data: dict[str, Any], payload: dict[str, Any] + self, run_state: str, leaf: str, data: dict[str, Any], payload: dict[str, Any] ) -> DetectionResult | None: if run_state == RunLifecycleStates.NO_SIGNAL.value: - return DetectionResult(FSM, "run_tracking_restored") + return DetectionResult(self.fsm, RunLifecycleEvents.RUN_TRACKING_RESTORED) return None @@ -70,12 +68,13 @@ class RunCompletedDetector: fsm = FSM - @staticmethod def detect( - run_state: str, leaf: str, data: dict[str, Any], payload: dict[str, Any] + self, run_state: str, leaf: str, data: dict[str, Any], payload: dict[str, Any] ) -> DetectionResult | None: if run_state == RunLifecycleStates.IN_PROGRESS.value and leaf == "progression": stop_id = data.get("stop_id") if data.get("current_status") == "STOPPED_AT" and stop_id: - return DetectionResult(FSM, "complete_run", {"stop_id": stop_id}) + return DetectionResult( + self.fsm, RunLifecycleEvents.COMPLETE_RUN, {"stop_id": stop_id} + ) return None diff --git a/backend/runs/domain/detection/periodic_detectors.py b/backend/runs/domain/detection/periodic_detectors.py index 4075fdb..7898135 100644 --- a/backend/runs/domain/detection/periodic_detectors.py +++ b/backend/runs/domain/detection/periodic_detectors.py @@ -11,6 +11,7 @@ from typing import Any from runs.domain.lifecycle.states import RunLifecycleStates +from runs.domain.lifecycle.events import RunLifecycleEvents from runs.domain.detection.result import DetectionResult from runs.domain.detection.thresholds import TELEMETRY_GRACE_S, TELEMETRY_EXPIRY_S @@ -22,15 +23,16 @@ class RunTrackingLostDetector: fsm = FSM - @staticmethod def detect( - run_state: str, staleness_s: float, payload: dict[str, Any] + self, run_state: str, staleness_s: float, payload: dict[str, Any] ) -> DetectionResult | None: if ( run_state == RunLifecycleStates.IN_PROGRESS.value and TELEMETRY_GRACE_S < staleness_s <= TELEMETRY_EXPIRY_S ): - return DetectionResult(FSM, "run_tracking_lost", {"actor_role": "system"}) + return DetectionResult( + self.fsm, RunLifecycleEvents.RUN_TRACKING_LOST, {"actor_role": "system"} + ) return None @@ -39,13 +41,16 @@ class RunTrackingExpiredDetector: fsm = FSM - @staticmethod def detect( - run_state: str, staleness_s: float, payload: dict[str, Any] + self, run_state: str, staleness_s: float, payload: dict[str, Any] ) -> DetectionResult | None: if ( run_state == RunLifecycleStates.NO_SIGNAL.value and staleness_s > TELEMETRY_EXPIRY_S ): - return DetectionResult(FSM, "run_tracking_expired", {"actor_role": "system"}) + return DetectionResult( + self.fsm, + RunLifecycleEvents.RUN_TRACKING_EXPIRED, + {"actor_role": "system"}, + ) return None diff --git a/backend/runs/domain/detection/registry.py b/backend/runs/domain/detection/registry.py index 201c60a..4063bef 100644 --- a/backend/runs/domain/detection/registry.py +++ b/backend/runs/domain/detection/registry.py @@ -18,14 +18,14 @@ # Evaluated on every incoming telemetry message. TELEMETRY_DETECTORS = [ - RunTrackingStartedDetector, - RunStartedDetector, - RunTrackingRestoredDetector, - RunCompletedDetector, + RunTrackingStartedDetector(), + RunStartedDetector(), + RunTrackingRestoredDetector(), + RunCompletedDetector(), ] # Evaluated by the periodic staleness scan. PERIODIC_DETECTORS = [ - RunTrackingLostDetector, - RunTrackingExpiredDetector, + RunTrackingLostDetector(), + RunTrackingExpiredDetector(), ] diff --git a/backend/runs/domain/detection/result.py b/backend/runs/domain/detection/result.py index 6bead82..f1168a5 100644 --- a/backend/runs/domain/detection/result.py +++ b/backend/runs/domain/detection/result.py @@ -11,7 +11,9 @@ class DetectionResult: Attributes: fsm: Routing key for the target state machine (``"lifecycle"`` or ``"progress"``). The dispatcher uses this to pick the service/task. - event: The event name to fire (the FSM event enum *value*). + event: The event to fire — a ``RunLifecycleEvents`` member. As a + ``str`` enum it compares equal to its value, so downstream string + consumers (Celery payloads, seed-event checks) keep working. extra_payload: Fields the detector adds on top of the base payload (e.g. ``{"stop_id": ...}`` for completion). Merged by the dispatcher. """ diff --git a/backend/runs/domain/detection/tests/test_detectors.py b/backend/runs/domain/detection/tests/test_detectors.py index d379283..06526ae 100644 --- a/backend/runs/domain/detection/tests/test_detectors.py +++ b/backend/runs/domain/detection/tests/test_detectors.py @@ -30,14 +30,14 @@ def test_tracking_started_fires_on_any_telemetry_when_confirmed(): - res = RunTrackingStartedDetector.detect(CONFIRMED, "position", {}, {}) + res = RunTrackingStartedDetector().detect(CONFIRMED, "position", {}, {}) assert res is not None assert res.fsm == "lifecycle" assert res.event == "run_tracking_started" def test_tracking_started_silent_when_not_confirmed(): - assert RunTrackingStartedDetector.detect(TRACKING, "position", {}, {}) is None + assert RunTrackingStartedDetector().detect(TRACKING, "position", {}, {}) is None @pytest.mark.parametrize( @@ -45,26 +45,26 @@ def test_tracking_started_silent_when_not_confirmed(): [(0.0, None), (0.5, None), (0.51, "run_started"), (12.0, "run_started")], ) def test_run_started_speed_boundary(speed, expected): - res = RunStartedDetector.detect(TRACKING, "position", {"speed": speed}, {}) + res = RunStartedDetector().detect(TRACKING, "position", {"speed": speed}, {}) assert (res.event if res else None) == expected def test_run_started_ignores_non_position_leaf(): - assert RunStartedDetector.detect(TRACKING, "progression", {"speed": 9}, {}) is None + assert RunStartedDetector().detect(TRACKING, "progression", {"speed": 9}, {}) is None def test_run_started_silent_when_not_tracking(): - assert RunStartedDetector.detect(IN_PROGRESS, "position", {"speed": 9}, {}) is None + assert RunStartedDetector().detect(IN_PROGRESS, "position", {"speed": 9}, {}) is None def test_tracking_restored_fires_when_no_signal(): - res = RunTrackingRestoredDetector.detect(NO_SIGNAL, "occupancy", {}, {}) + res = RunTrackingRestoredDetector().detect(NO_SIGNAL, "occupancy", {}, {}) assert res is not None and res.event == "run_tracking_restored" def test_completed_fires_on_stopped_at_with_stop_id(): data = {"current_status": "STOPPED_AT", "stop_id": "TERM-1"} - res = RunCompletedDetector.detect(IN_PROGRESS, "progression", data, {}) + res = RunCompletedDetector().detect(IN_PROGRESS, "progression", data, {}) assert res is not None assert res.event == "complete_run" assert res.extra_payload == {"stop_id": "TERM-1"} @@ -72,12 +72,12 @@ def test_completed_fires_on_stopped_at_with_stop_id(): def test_completed_silent_without_stop_id(): data = {"current_status": "STOPPED_AT"} - assert RunCompletedDetector.detect(IN_PROGRESS, "progression", data, {}) is None + assert RunCompletedDetector().detect(IN_PROGRESS, "progression", data, {}) is None def test_completed_silent_when_not_stopped(): data = {"current_status": "IN_TRANSIT_TO", "stop_id": "X"} - assert RunCompletedDetector.detect(IN_PROGRESS, "progression", data, {}) is None + assert RunCompletedDetector().detect(IN_PROGRESS, "progression", data, {}) is None # -------------------------------------------------------------------------- @@ -99,7 +99,7 @@ def test_completed_silent_when_not_stopped(): ], ) def test_tracking_lost_window(staleness, state, expected): - res = RunTrackingLostDetector.detect(state, staleness, {}) + res = RunTrackingLostDetector().detect(state, staleness, {}) assert (res.event if res else None) == expected @@ -112,11 +112,11 @@ def test_tracking_lost_window(staleness, state, expected): ], ) def test_tracking_expired_window(staleness, state, expected): - res = RunTrackingExpiredDetector.detect(state, staleness, {}) + res = RunTrackingExpiredDetector().detect(state, staleness, {}) assert (res.event if res else None) == expected def test_periodic_detectors_tag_lifecycle_and_system_actor(): - res = RunTrackingLostDetector.detect(IN_PROGRESS, GRACE + 5, {}) + res = RunTrackingLostDetector().detect(IN_PROGRESS, GRACE + 5, {}) assert res.fsm == "lifecycle" assert res.extra_payload.get("actor_role") == "system" From e63af5ddfde816437029b30ee37f9dda190d07a6 Mon Sep 17 00:00:00 2001 From: Jae Date: Tue, 9 Jun 2026 20:09:23 -0600 Subject: [PATCH 18/23] feat(telemetry): add run::stop_time_updates projection contract + key --- backend/runs/domain/telemetry/__init__.py | 2 + backend/runs/domain/telemetry/keys.py | 24 ++ .../domain/telemetry/stop_time_updates.py | 201 ++++++++++ .../telemetry/tests/test_stop_time_updates.py | 362 ++++++++++++++++++ 4 files changed, 589 insertions(+) create mode 100644 backend/runs/domain/telemetry/stop_time_updates.py create mode 100644 backend/runs/domain/telemetry/tests/test_stop_time_updates.py diff --git a/backend/runs/domain/telemetry/__init__.py b/backend/runs/domain/telemetry/__init__.py index b96a74a..eba1e40 100644 --- a/backend/runs/domain/telemetry/__init__.py +++ b/backend/runs/domain/telemetry/__init__.py @@ -10,6 +10,7 @@ - :mod:`.vehicle_stop_status` — ``run::vehicle_stop_status`` hash (server). - :mod:`.congestion_level` — ``run::congestion_level`` hash (server, deferred). - :mod:`.trip` — ``run::trip`` hash + projection helper (server). +- :mod:`.stop_time_updates` — ``run::stop_time_updates`` JSON string (server projection). All modules are import-light (no Django, no Redis at module top level) so they are safe to use in unit tests under plain pytest. @@ -28,4 +29,5 @@ vehicle_stop_status, congestion_level, trip, + stop_time_updates, ) diff --git a/backend/runs/domain/telemetry/keys.py b/backend/runs/domain/telemetry/keys.py index 09b8f57..36c9c9e 100644 --- a/backend/runs/domain/telemetry/keys.py +++ b/backend/runs/domain/telemetry/keys.py @@ -100,3 +100,27 @@ def last_seen_key(run_id: str) -> str: Updated by the MQTT consumer on every message to enable stale detection. """ return f"runs:last_seen:{run_id}" + + +def stop_time_updates_key(run_id: str) -> str: + """Redis string: JSON array of upcoming stop-time-update entries for the run. + + This is a Redis **string** key (not a hash) holding a JSON-encoded array — a + materialized projection/cache written by the stop-times producer with a + staleness TTL. The GTFS-RT builder reads this key and treats a missing or + empty value as "skip stop_time_update" (honest empty list in the feed). + + Each array entry has the shape:: + + { + "stop_sequence": int, + "stop_id": str, + "arrival_time": int, # POSIX seconds + "departure_time": int, # POSIX seconds (== arrival_time for now) + "uncertainty": int, + } + + Written with a staleness TTL so a stalled producer allows the projection to + expire rather than serving stale arrivals. Run lifecycle owns hard cleanup. + """ + return f"run:{run_id}:stop_time_updates" diff --git a/backend/runs/domain/telemetry/stop_time_updates.py b/backend/runs/domain/telemetry/stop_time_updates.py new file mode 100644 index 0000000..523b707 --- /dev/null +++ b/backend/runs/domain/telemetry/stop_time_updates.py @@ -0,0 +1,201 @@ +"""GTFS-RT StopTimeUpdate projection contract. + +Covers the ``run::stop_time_updates`` Redis **string** key, which holds a +JSON-encoded array — a materialized projection/cache written by the stop-times +producer with a staleness TTL. It is NOT a typed entity-hash; the key stores a +single JSON string. + +The GTFS-RT builder reads this key and treats a missing or empty value as "skip +stop_time_update" (honest empty list in the feed). + +Per-entry contract +------------------ +Each entry in the array has exactly these fields: + + { + "stop_sequence": int, + "stop_id": str, + "arrival_time": int, # POSIX seconds + "departure_time": int, # POSIX seconds (== arrival_time for now) + "uncertainty": int, + } + +Producer: ``runs/domain/progression/stop_times.py``. +Consumer: ``schedule_engine/builders.py::build_trip_update_entity``. + +This module imports only stdlib ``json`` — no Django, no Redis — so it is safe +to import from any layer without pulling in Django or Redis. +""" + +import json + +# --------------------------------------------------------------------------- +# Field-name constants +# --------------------------------------------------------------------------- + +STOP_SEQUENCE = "stop_sequence" +STOP_ID = "stop_id" +ARRIVAL_TIME = "arrival_time" +DEPARTURE_TIME = "departure_time" +UNCERTAINTY = "uncertainty" + +_REQUIRED_FIELDS = (STOP_SEQUENCE, STOP_ID, ARRIVAL_TIME, DEPARTURE_TIME, UNCERTAINTY) + + +# --------------------------------------------------------------------------- +# Reader: str | None → list[dict] (tolerant) +# --------------------------------------------------------------------------- + + +def from_redis(raw_json: str | None) -> list[dict]: + """Decode a JSON string from Redis into a typed list of stop-time-update entries. + + Tolerant: + - ``None``, empty string, or malformed JSON ⇒ return ``[]``. + - A JSON value that is not a list ⇒ return ``[]``. + - Entries missing any required field are silently dropped. + - Entries with fields that cannot be coerced to the required type are silently + dropped. + + Parameters + ---------- + raw_json: + The raw string value returned by ``r.get(stop_time_updates_key(run_id))``. + + Returns + ------- + list[dict] + A list of typed dicts, each guaranteed to have all five required fields + with the correct Python types. + """ + if not raw_json: + return [] + + try: + parsed = json.loads(raw_json) + except (json.JSONDecodeError, ValueError): + return [] + + if not isinstance(parsed, list): + return [] + + result: list[dict] = [] + for entry in parsed: + if not isinstance(entry, dict): + continue + typed = _coerce_entry(entry) + if typed is not None: + result.append(typed) + return result + + +def _coerce_entry(entry: dict) -> dict | None: + """Attempt to coerce a single raw dict to the typed contract. + + Returns ``None`` if any required field is missing or cannot be coerced. + """ + # stop_id: required str + raw_stop_id = entry.get(STOP_ID) + if raw_stop_id is None: + return None + stop_id = str(raw_stop_id) + if not stop_id: + return None + + # Integer fields: stop_sequence, arrival_time, departure_time, uncertainty + int_fields = (STOP_SEQUENCE, ARRIVAL_TIME, DEPARTURE_TIME, UNCERTAINTY) + coerced_ints: dict[str, int] = {} + for field in int_fields: + raw = entry.get(field) + if raw is None: + return None + try: + coerced_ints[field] = int(raw) + except (ValueError, TypeError): + return None + + return { + STOP_SEQUENCE: coerced_ints[STOP_SEQUENCE], + STOP_ID: stop_id, + ARRIVAL_TIME: coerced_ints[ARRIVAL_TIME], + DEPARTURE_TIME: coerced_ints[DEPARTURE_TIME], + UNCERTAINTY: coerced_ints[UNCERTAINTY], + } + + +# --------------------------------------------------------------------------- +# Writer: list[dict] → str (strict) +# --------------------------------------------------------------------------- + + +def to_redis(entries: list[dict]) -> str: + """Encode a list of stop-time-update entries to a JSON string for Redis. + + Strict: every entry must have all five required fields with values that are + coercible to the correct types. Raises ``ValueError`` on the first bad entry + with a message identifying the problem. + + Parameters + ---------- + entries: + A list of dicts, each expected to contain the five required fields. + + Returns + ------- + str + A JSON-encoded string suitable for ``r.set(stop_time_updates_key(run_id), ...)``. + + Raises + ------ + ValueError + If any entry is missing a required field or contains a value with a bad type. + """ + validated: list[dict] = [] + for idx, entry in enumerate(entries): + if not isinstance(entry, dict): + raise ValueError( + f"stop_time_updates.to_redis: entry[{idx}] is not a dict: {entry!r}" + ) + + # stop_id: required str (must be non-empty) + raw_stop_id = entry.get(STOP_ID) + if raw_stop_id is None: + raise ValueError( + f"stop_time_updates.to_redis: entry[{idx}] missing required field " + f"'{STOP_ID}'" + ) + stop_id = str(raw_stop_id) + if not stop_id: + raise ValueError( + f"stop_time_updates.to_redis: entry[{idx}] field '{STOP_ID}' is empty" + ) + + # Integer fields: stop_sequence, arrival_time, departure_time, uncertainty + int_fields = (STOP_SEQUENCE, ARRIVAL_TIME, DEPARTURE_TIME, UNCERTAINTY) + coerced_ints: dict[str, int] = {} + for field in int_fields: + raw = entry.get(field) + if raw is None: + raise ValueError( + f"stop_time_updates.to_redis: entry[{idx}] missing required field " + f"'{field}'" + ) + try: + coerced_ints[field] = int(raw) + except (ValueError, TypeError) as exc: + raise ValueError( + f"stop_time_updates.to_redis: entry[{idx}] field '{field}' cannot " + f"be coerced to int: {raw!r}" + ) from exc + + validated.append( + { + STOP_SEQUENCE: coerced_ints[STOP_SEQUENCE], + STOP_ID: stop_id, + ARRIVAL_TIME: coerced_ints[ARRIVAL_TIME], + DEPARTURE_TIME: coerced_ints[DEPARTURE_TIME], + UNCERTAINTY: coerced_ints[UNCERTAINTY], + } + ) + + return json.dumps(validated) diff --git a/backend/runs/domain/telemetry/tests/test_stop_time_updates.py b/backend/runs/domain/telemetry/tests/test_stop_time_updates.py new file mode 100644 index 0000000..3cd2ae3 --- /dev/null +++ b/backend/runs/domain/telemetry/tests/test_stop_time_updates.py @@ -0,0 +1,362 @@ +"""Pure unit tests for the stop_time_updates entity contract. + +No Django, no Redis — runnable under plain pytest. + +Coverage target: ≥80% of runs/domain/telemetry/stop_time_updates.py. +""" + +import json + +import pytest + +from runs.domain.telemetry import stop_time_updates +from runs.domain.telemetry.stop_time_updates import ( + ARRIVAL_TIME, + DEPARTURE_TIME, + STOP_ID, + STOP_SEQUENCE, + UNCERTAINTY, + from_redis, + to_redis, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_VALID_ENTRY = { + STOP_SEQUENCE: 3, + STOP_ID: "stop-99", + ARRIVAL_TIME: 1700000000, + DEPARTURE_TIME: 1700000000, + UNCERTAINTY: 120, +} + +_VALID_ENTRY_2 = { + STOP_SEQUENCE: 4, + STOP_ID: "stop-100", + ARRIVAL_TIME: 1700000300, + DEPARTURE_TIME: 1700000300, + UNCERTAINTY: 60, +} + + +def _json(*entries) -> str: + return json.dumps(list(entries)) + + +# --------------------------------------------------------------------------- +# Field-name constants +# --------------------------------------------------------------------------- + + +def test_field_name_constants_are_correct_strings(): + assert stop_time_updates.STOP_SEQUENCE == "stop_sequence" + assert stop_time_updates.STOP_ID == "stop_id" + assert stop_time_updates.ARRIVAL_TIME == "arrival_time" + assert stop_time_updates.DEPARTURE_TIME == "departure_time" + assert stop_time_updates.UNCERTAINTY == "uncertainty" + + +# --------------------------------------------------------------------------- +# Round-trip: to_redis → from_redis +# --------------------------------------------------------------------------- + + +def test_round_trip_single_entry(): + entries = [_VALID_ENTRY] + result = from_redis(to_redis(entries)) + assert len(result) == 1 + e = result[0] + assert e[STOP_SEQUENCE] == 3 + assert e[STOP_ID] == "stop-99" + assert e[ARRIVAL_TIME] == 1700000000 + assert e[DEPARTURE_TIME] == 1700000000 + assert e[UNCERTAINTY] == 120 + + +def test_round_trip_multiple_entries(): + entries = [_VALID_ENTRY, _VALID_ENTRY_2] + result = from_redis(to_redis(entries)) + assert len(result) == 2 + assert result[0][STOP_SEQUENCE] == 3 + assert result[1][STOP_SEQUENCE] == 4 + + +def test_round_trip_empty_list(): + result = from_redis(to_redis([])) + assert result == [] + + +def test_round_trip_types_are_correct(): + result = from_redis(to_redis([_VALID_ENTRY])) + e = result[0] + assert isinstance(e[STOP_SEQUENCE], int) + assert isinstance(e[STOP_ID], str) + assert isinstance(e[ARRIVAL_TIME], int) + assert isinstance(e[DEPARTURE_TIME], int) + assert isinstance(e[UNCERTAINTY], int) + + +# --------------------------------------------------------------------------- +# from_redis — tolerant behaviour +# --------------------------------------------------------------------------- + + +def test_from_redis_none_returns_empty_list(): + assert from_redis(None) == [] + + +def test_from_redis_empty_string_returns_empty_list(): + assert from_redis("") == [] + + +def test_from_redis_malformed_json_returns_empty_list(): + assert from_redis("{not valid json") == [] + + +def test_from_redis_json_object_not_list_returns_empty_list(): + # A JSON dict, not a list + assert from_redis(json.dumps({"key": "value"})) == [] + + +def test_from_redis_json_null_returns_empty_list(): + assert from_redis("null") == [] + + +def test_from_redis_json_integer_not_list_returns_empty_list(): + assert from_redis("42") == [] + + +def test_from_redis_json_string_not_list_returns_empty_list(): + assert from_redis('"hello"') == [] + + +def test_from_redis_drops_entry_missing_stop_id(): + bad_entry = { + STOP_SEQUENCE: 1, + # STOP_ID missing + ARRIVAL_TIME: 1700000000, + DEPARTURE_TIME: 1700000000, + UNCERTAINTY: 120, + } + result = from_redis(_json(bad_entry)) + assert result == [] + + +def test_from_redis_drops_entry_missing_stop_sequence(): + bad_entry = { + # STOP_SEQUENCE missing + STOP_ID: "stop-1", + ARRIVAL_TIME: 1700000000, + DEPARTURE_TIME: 1700000000, + UNCERTAINTY: 120, + } + result = from_redis(_json(bad_entry)) + assert result == [] + + +def test_from_redis_drops_entry_missing_arrival_time(): + bad_entry = { + STOP_SEQUENCE: 1, + STOP_ID: "stop-1", + # ARRIVAL_TIME missing + DEPARTURE_TIME: 1700000000, + UNCERTAINTY: 120, + } + result = from_redis(_json(bad_entry)) + assert result == [] + + +def test_from_redis_drops_entry_missing_departure_time(): + bad_entry = { + STOP_SEQUENCE: 1, + STOP_ID: "stop-1", + ARRIVAL_TIME: 1700000000, + # DEPARTURE_TIME missing + UNCERTAINTY: 120, + } + result = from_redis(_json(bad_entry)) + assert result == [] + + +def test_from_redis_drops_entry_missing_uncertainty(): + bad_entry = { + STOP_SEQUENCE: 1, + STOP_ID: "stop-1", + ARRIVAL_TIME: 1700000000, + DEPARTURE_TIME: 1700000000, + # UNCERTAINTY missing + } + result = from_redis(_json(bad_entry)) + assert result == [] + + +def test_from_redis_drops_entry_with_non_int_stop_sequence(): + bad_entry = { + STOP_SEQUENCE: "not-a-number", + STOP_ID: "stop-1", + ARRIVAL_TIME: 1700000000, + DEPARTURE_TIME: 1700000000, + UNCERTAINTY: 120, + } + result = from_redis(_json(bad_entry)) + assert result == [] + + +def test_from_redis_drops_entry_with_non_int_arrival_time(): + bad_entry = { + STOP_SEQUENCE: 1, + STOP_ID: "stop-1", + ARRIVAL_TIME: "bad-time", + DEPARTURE_TIME: 1700000000, + UNCERTAINTY: 120, + } + result = from_redis(_json(bad_entry)) + assert result == [] + + +def test_from_redis_coerces_string_numerics(): + """Int fields given as numeric strings must be coerced successfully on read.""" + entry = { + STOP_SEQUENCE: "5", + STOP_ID: "stop-A", + ARRIVAL_TIME: "1700000500", + DEPARTURE_TIME: "1700000500", + UNCERTAINTY: "90", + } + result = from_redis(_json(entry)) + assert len(result) == 1 + e = result[0] + assert e[STOP_SEQUENCE] == 5 + assert isinstance(e[STOP_SEQUENCE], int) + assert e[ARRIVAL_TIME] == 1700000500 + assert isinstance(e[ARRIVAL_TIME], int) + assert e[UNCERTAINTY] == 90 + + +def test_from_redis_keeps_valid_drops_invalid_entries(): + """Mixed list: valid entry is kept, invalid entry is dropped.""" + valid = dict(_VALID_ENTRY) + bad = {STOP_ID: "stop-X"} # missing most fields + result = from_redis(_json(valid, bad)) + assert len(result) == 1 + assert result[0][STOP_ID] == "stop-99" + + +def test_from_redis_drops_non_dict_entries(): + """Non-dict items inside the list are silently skipped.""" + raw = json.dumps([_VALID_ENTRY, "not-a-dict", 42, None]) + result = from_redis(raw) + assert len(result) == 1 + assert result[0][STOP_ID] == "stop-99" + + +# --------------------------------------------------------------------------- +# to_redis — strict validation +# --------------------------------------------------------------------------- + + +def test_to_redis_raises_on_missing_stop_id(): + bad = { + STOP_SEQUENCE: 1, + ARRIVAL_TIME: 1700000000, + DEPARTURE_TIME: 1700000000, + UNCERTAINTY: 120, + } + with pytest.raises(ValueError, match=STOP_ID): + to_redis([bad]) + + +def test_to_redis_raises_on_missing_stop_sequence(): + bad = { + STOP_ID: "stop-1", + ARRIVAL_TIME: 1700000000, + DEPARTURE_TIME: 1700000000, + UNCERTAINTY: 120, + } + with pytest.raises(ValueError, match=STOP_SEQUENCE): + to_redis([bad]) + + +def test_to_redis_raises_on_missing_arrival_time(): + bad = { + STOP_SEQUENCE: 1, + STOP_ID: "stop-1", + DEPARTURE_TIME: 1700000000, + UNCERTAINTY: 120, + } + with pytest.raises(ValueError, match=ARRIVAL_TIME): + to_redis([bad]) + + +def test_to_redis_raises_on_missing_departure_time(): + bad = { + STOP_SEQUENCE: 1, + STOP_ID: "stop-1", + ARRIVAL_TIME: 1700000000, + UNCERTAINTY: 120, + } + with pytest.raises(ValueError, match=DEPARTURE_TIME): + to_redis([bad]) + + +def test_to_redis_raises_on_missing_uncertainty(): + bad = { + STOP_SEQUENCE: 1, + STOP_ID: "stop-1", + ARRIVAL_TIME: 1700000000, + DEPARTURE_TIME: 1700000000, + } + with pytest.raises(ValueError, match=UNCERTAINTY): + to_redis([bad]) + + +def test_to_redis_raises_on_bad_int_stop_sequence(): + bad = dict(_VALID_ENTRY) + bad[STOP_SEQUENCE] = "not-an-int" + with pytest.raises(ValueError, match=STOP_SEQUENCE): + to_redis([bad]) + + +def test_to_redis_raises_on_bad_int_arrival_time(): + bad = dict(_VALID_ENTRY) + bad[ARRIVAL_TIME] = "bad-ts" + with pytest.raises(ValueError, match=ARRIVAL_TIME): + to_redis([bad]) + + +def test_to_redis_raises_on_non_dict_entry(): + with pytest.raises(ValueError, match="not a dict"): + to_redis(["not-a-dict"]) + + +def test_to_redis_returns_json_string(): + result = to_redis([_VALID_ENTRY]) + assert isinstance(result, str) + parsed = json.loads(result) + assert isinstance(parsed, list) + assert len(parsed) == 1 + + +def test_to_redis_accepts_string_numerics(): + """String-numeric values must be accepted by to_redis (coerced to int).""" + entry = { + STOP_SEQUENCE: "7", + STOP_ID: "stop-B", + ARRIVAL_TIME: "1700001000", + DEPARTURE_TIME: "1700001000", + UNCERTAINTY: "60", + } + result = to_redis([entry]) + parsed = json.loads(result) + assert parsed[0][STOP_SEQUENCE] == 7 + assert isinstance(parsed[0][STOP_SEQUENCE], int) + + +def test_to_redis_empty_stop_id_raises(): + bad = dict(_VALID_ENTRY) + bad[STOP_ID] = "" + with pytest.raises(ValueError, match=STOP_ID): + to_redis([bad]) From ad63d8d5d58d148eaa8baaf0d442e851ec27fdbb Mon Sep 17 00:00:00 2001 From: Jae Date: Tue, 9 Jun 2026 20:12:01 -0600 Subject: [PATCH 19/23] feat(progression): build cached shape/stop geometry from GTFS Add pure geometry primitives (geo.py) and per-shape geometry builders with in-process caching (shapes.py). geo.py contains both haversine_m (ported from the sim) and project_point_to_polyline; shapes.py needs projection to precompute each stop's progress_m along the polyline, so both live in this commit (deviation from the original plan that split them across two commits). load_shape_geometry uses Django models imported inside the function to stay import-light; the module-level cache is keyed by (feed_id, shape_id, trip_id) and cleared via invalidate_cache() to be wired at feed-import time. --- backend/runs/domain/progression/geo.py | 144 ++++++++ backend/runs/domain/progression/shapes.py | 319 ++++++++++++++++++ .../runs/domain/progression/tests/test_geo.py | 164 +++++++++ .../domain/progression/tests/test_shapes.py | 275 +++++++++++++++ 4 files changed, 902 insertions(+) create mode 100644 backend/runs/domain/progression/geo.py create mode 100644 backend/runs/domain/progression/shapes.py create mode 100644 backend/runs/domain/progression/tests/test_geo.py create mode 100644 backend/runs/domain/progression/tests/test_shapes.py diff --git a/backend/runs/domain/progression/geo.py b/backend/runs/domain/progression/geo.py new file mode 100644 index 0000000..5422952 --- /dev/null +++ b/backend/runs/domain/progression/geo.py @@ -0,0 +1,144 @@ +"""Pure geometry primitives for map-matching — no I/O, stdlib math only. + +Two public functions: + haversine_m — great-circle distance in metres (ported from sim) + project_point_to_polyline — project a GPS point onto a cumulative polyline +""" + +from __future__ import annotations + +import math + +# Earth radius used in the simulator (kinematics.py) — ported verbatim. +_R = 6_371_000.0 + + +# --------------------------------------------------------------------------- +# Haversine distance +# --------------------------------------------------------------------------- + + +def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Return the great-circle distance in metres between two WGS-84 points. + + Ported verbatim from simulator_app/domain/kinematics.py (R = 6_371_000 m). + """ + r = _R + p1, p2 = math.radians(lat1), math.radians(lat2) + dp = math.radians(lat2 - lat1) + dl = math.radians(lon2 - lon1) + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 + return 2 * r * math.asin(math.sqrt(a)) + + +# --------------------------------------------------------------------------- +# Point-to-polyline projection +# --------------------------------------------------------------------------- + + +def project_point_to_polyline( + lat: float, + lon: float, + polyline: list[tuple[float, float, float]], +) -> dict: + """Project a GPS point onto a cumulative polyline. + + Parameters + ---------- + lat, lon: + Observed WGS-84 position. + polyline: + Ordered list of ``(lat, lon, cum_dist_m)`` tuples as produced by + ``shapes.build_polyline``. Must contain at least one point. + + Returns + ------- + dict with keys: + ``progress_m`` – distance along the polyline to the projected foot + (i.e. cum_dist_m of the segment start + along-track + distance to the foot). + ``cross_track_m`` – perpendicular distance from the point to the nearest + segment (always >= 0). + ``segment_idx`` – index of the segment (0 = first pair) whose foot is + nearest. For a 1-point polyline, segment_idx = 0. + + Edge cases + ---------- + * Empty polyline → returns ``{"progress_m": 0.0, "cross_track_m": 0.0, + "segment_idx": 0}`` (defensive fallback). + * 1-point polyline → the single point is the only candidate; cross_track is + the haversine distance to that point; progress_m = cum_dist of that point + (always 0.0 for the first point). + * Parameter t outside [0, 1] is clamped → the foot is the nearer endpoint. + """ + if not polyline: + return {"progress_m": 0.0, "cross_track_m": 0.0, "segment_idx": 0} + + if len(polyline) == 1: + only = polyline[0] + d = haversine_m(lat, lon, only[0], only[1]) + return {"progress_m": only[2], "cross_track_m": d, "segment_idx": 0} + + best_cross_m = float("inf") + best_progress_m = polyline[0][2] + best_seg = 0 + + for i in range(len(polyline) - 1): + lat0, lon0, cum0 = polyline[i] + lat1, lon1, cum1 = polyline[i + 1] + + seg_len_m = cum1 - cum0 + if seg_len_m < 1e-9: + # Degenerate (duplicate) segment — treat as a single point. + d = haversine_m(lat, lon, lat0, lon0) + if d < best_cross_m: + best_cross_m = d + best_progress_m = cum0 + best_seg = i + continue + + # Local equirectangular projection centred on segment start. + # x = R * cos(lat0) * Δlon_rad (easting, metres) + # y = R * Δlat_rad (northing, metres) + cos_lat0 = math.cos(math.radians(lat0)) + + # Segment vector in local metres. + dx_seg = _R * cos_lat0 * math.radians(lon1 - lon0) + dy_seg = _R * math.radians(lat1 - lat0) + + # Point vector relative to segment start, in local metres. + dx_pt = _R * cos_lat0 * math.radians(lon - lon0) + dy_pt = _R * math.radians(lat - lat0) + + # Scalar projection parameter t ∈ [0, 1]. + seg_len_sq = dx_seg ** 2 + dy_seg ** 2 # = seg_len_m² but more numerically clean + t = (dx_pt * dx_seg + dy_pt * dy_seg) / seg_len_sq + t = max(0.0, min(1.0, t)) + + # Foot of perpendicular in local metres. + fx = dx_seg * t + fy = dy_seg * t + + # Perpendicular (cross-track) distance. + cross_m = math.sqrt((dx_pt - fx) ** 2 + (dy_pt - fy) ** 2) + + # Along-track distance from segment start to foot. + along_m = math.sqrt(fx ** 2 + fy ** 2) + + # Signed check: if t=0 the foot is at the start; dot product with the + # point vector confirms along_m direction. Since t is clamped we just + # take along_m = t * seg_len_m to stay consistent with the parameter. + along_m = t * math.sqrt(seg_len_sq) + + progress_m = cum0 + along_m + + if cross_m < best_cross_m: + best_cross_m = cross_m + best_progress_m = progress_m + best_seg = i + + return { + "progress_m": best_progress_m, + "cross_track_m": best_cross_m, + "segment_idx": best_seg, + } diff --git a/backend/runs/domain/progression/shapes.py b/backend/runs/domain/progression/shapes.py new file mode 100644 index 0000000..ffd454d --- /dev/null +++ b/backend/runs/domain/progression/shapes.py @@ -0,0 +1,319 @@ +"""Per-shape geometry: build, cache, and load GTFS shape+stop geometry. + +Public surface +-------------- +ShapeGeometry – frozen dataclass; one per (shape_id, trip_id) pair. +build_polyline(...) – pure: raw GTFS rows → cumulative polyline tuples. +build_stops(...) – pure: stop rows + polyline → stops with progress_m. +assemble_geometry(...) – pure: combine the two into a ShapeGeometry. +load_shape_geometry(...) – ORM loader (Django imported inside the function). +get_shape_geometry(...) – cached wrapper; returns None when data unavailable. +invalidate_cache() – clear the module-level cache (call after GTFS import). + +Cache notes +----------- +The cache is a plain module-level dict keyed by ``(feed_id, shape_id, trip_id)``. +``invalidate_cache()`` must be wired to the GTFS feed import hook externally +(the hook registration is out of scope for this module — see the import pipeline). + +The ETA/look-ahead step can slice ``geometry.stops`` to only upcoming stops +(``stop_sequence >= current_stop_sequence``); the list is ordered by sequence. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from runs.domain.progression.geo import haversine_m, project_point_to_polyline + + +# --------------------------------------------------------------------------- +# Frozen dataclass +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class ShapeGeometry: + """Immutable, hashable geometry bundle for a (shape_id, trip_id) pair. + + Attributes + ---------- + shape_id: + GTFS shape_id string. + trip_id: + GTFS trip_id string (used to look up StopTimes). + polyline: + Ordered ``(lat, lon, cum_dist_m)`` tuples from shape start to end. + First point always has ``cum_dist_m = 0.0``. + stops: + Ordered stop dicts, one per stop-time row, sorted by ``stop_sequence``. + Each dict has keys: + ``stop_id`` (str) + ``stop_sequence`` (int) + ``lat`` (float) + ``lon`` (float) + ``progress_m`` (float) — stop projected onto the polyline. + """ + + shape_id: str + trip_id: str + polyline: tuple[tuple[float, float, float], ...] + stops: tuple[dict, ...] + + +# --------------------------------------------------------------------------- +# Pure builders +# --------------------------------------------------------------------------- + + +def build_polyline( + points: list[tuple[float, float, int]], +) -> list[tuple[float, float, float]]: + """Convert raw GTFS shape rows to a cumulative-distance polyline. + + Parameters + ---------- + points: + List of ``(lat, lon, shape_pt_sequence)`` tuples, pre-sorted by + ``shape_pt_sequence`` ascending (the caller is responsible for ordering). + + Returns + ------- + list of ``(lat, lon, cum_dist_m)`` tuples. First point has + ``cum_dist_m = 0.0``; subsequent points add the haversine distance from + the previous point. + """ + if not points: + return [] + + result: list[tuple[float, float, float]] = [] + cum = 0.0 + prev_lat: float | None = None + prev_lon: float | None = None + + for lat, lon, _seq in points: + if prev_lat is None: + cum = 0.0 + else: + cum += haversine_m(prev_lat, prev_lon, lat, lon) + result.append((lat, lon, cum)) + prev_lat, prev_lon = lat, lon + + return result + + +def build_stops( + stop_rows: list[dict], + polyline: list[tuple[float, float, float]], +) -> list[dict]: + """Project each stop onto the polyline and return enriched stop dicts. + + Parameters + ---------- + stop_rows: + List of dicts, each with keys ``stop_id`` (str), ``stop_sequence`` + (int), ``lat`` (float), ``lon`` (float). + polyline: + Cumulative polyline as returned by ``build_polyline``. + + Returns + ------- + List of dicts (same order as input) with the additional key + ``progress_m`` (float). + """ + result = [] + for row in stop_rows: + proj = project_point_to_polyline(row["lat"], row["lon"], polyline) + result.append( + { + "stop_id": row["stop_id"], + "stop_sequence": row["stop_sequence"], + "lat": row["lat"], + "lon": row["lon"], + "progress_m": proj["progress_m"], + } + ) + return result + + +def assemble_geometry( + shape_id: str, + trip_id: str, + shape_points: list[tuple[float, float, int]], + stop_rows: list[dict], +) -> ShapeGeometry: + """Assemble a ShapeGeometry from raw GTFS rows. + + Parameters + ---------- + shape_id: + GTFS shape_id. + trip_id: + GTFS trip_id. + shape_points: + ``(lat, lon, shape_pt_sequence)`` tuples ordered by sequence ascending. + stop_rows: + List of dicts ``{stop_id, stop_sequence, lat, lon}`` ordered by + ``stop_sequence`` ascending. + + Returns + ------- + ShapeGeometry (frozen, hashable). + """ + polyline = build_polyline(shape_points) + stops = build_stops(stop_rows, polyline) + return ShapeGeometry( + shape_id=shape_id, + trip_id=trip_id, + polyline=tuple(polyline), + stops=tuple(stops), + ) + + +# --------------------------------------------------------------------------- +# ORM loader (Django imported inside function) +# --------------------------------------------------------------------------- + + +def load_shape_geometry( + shape_id: str, + trip_id: str, + *, + feed=None, +) -> ShapeGeometry | None: + """Load shape + stop geometry from the database and assemble it. + + Django models are imported inside this function so that the module is + safe to import in plain-pytest without a configured Django app (mirrors + the pattern used in ``runs/domain/lifecycle/guards.py``). + + Parameters + ---------- + shape_id: + GTFS shape_id to look up in the Shape table. + trip_id: + GTFS trip_id to look up stop times. + feed: + Optional Feed ORM instance. When ``None``, the current feed is + resolved via ``Feed.objects.filter(is_current=True).first()``. + + Returns + ------- + ``ShapeGeometry`` on success, ``None`` if: + - no current GTFS feed exists, or + - the shape_id has no points in the Shape table, or + - the trip_id has no stop-time rows. + """ + from feed.models import Feed, Shape, StopTime, Stop # noqa: PLC0415 + + if feed is None: + feed = Feed.objects.filter(is_current=True).first() + if feed is None: + return None + + # ---- Shape points ------------------------------------------------------- + shape_qs = ( + Shape.objects.filter(feed=feed, shape_id=shape_id) + .order_by("shape_pt_sequence") + .values_list("shape_pt_lat", "shape_pt_lon", "shape_pt_sequence") + ) + shape_points = [ + (float(lat), float(lon), int(seq)) + for lat, lon, seq in shape_qs + ] + if not shape_points: + return None + + # ---- Stop-time rows for the trip ---------------------------------------- + stop_time_qs = ( + StopTime.objects.filter(feed=feed, trip_id=trip_id) + .order_by("stop_sequence") + .values_list("stop_id", "stop_sequence") + ) + st_rows = list(stop_time_qs) + if not st_rows: + return None + + stop_ids = [sid for sid, _seq in st_rows] + stop_coord_map = { + row["stop_id"]: (float(row["stop_lat"]), float(row["stop_lon"])) + for row in Stop.objects.filter(feed=feed, stop_id__in=stop_ids).values( + "stop_id", "stop_lat", "stop_lon" + ) + } + + stop_rows = [] + for sid, seq in st_rows: + coords = stop_coord_map.get(sid) + if coords is None: + # Skip stops whose coordinates are not in the feed. + continue + stop_rows.append( + { + "stop_id": sid, + "stop_sequence": int(seq), + "lat": coords[0], + "lon": coords[1], + } + ) + + if not stop_rows: + return None + + return assemble_geometry(shape_id, trip_id, shape_points, stop_rows) + + +# --------------------------------------------------------------------------- +# Module-level cache +# --------------------------------------------------------------------------- + +_CACHE: dict[tuple, ShapeGeometry] = {} + + +def get_shape_geometry( + shape_id: str, + trip_id: str, + *, + feed=None, +) -> ShapeGeometry | None: + """Return a cached ShapeGeometry, loading from the ORM on first access. + + The cache key is ``(feed_id, shape_id, trip_id)`` so that different feeds + do not collide. When ``feed`` is ``None`` the current feed is resolved + inside ``load_shape_geometry`` and the resulting feed id is used as the + key. + + Returns ``None`` when the shape or trip cannot be found (propagated from + ``load_shape_geometry``). + """ + # We need the feed_id to form the cache key. Resolve lazily. + resolved_feed = feed + if resolved_feed is None: + try: + from feed.models import Feed # noqa: PLC0415 + + resolved_feed = Feed.objects.filter(is_current=True).first() + except Exception: + # No Django configured (e.g. plain-pytest). Use a sentinel key. + resolved_feed = None + + feed_id = getattr(resolved_feed, "pk", None) + cache_key = (feed_id, shape_id, trip_id) + + if cache_key in _CACHE: + return _CACHE[cache_key] + + geom = load_shape_geometry(shape_id, trip_id, feed=resolved_feed) + if geom is not None: + _CACHE[cache_key] = geom + return geom + + +def invalidate_cache() -> None: + """Clear the in-process ShapeGeometry cache. + + Call this after a GTFS feed import completes so that stale geometry is + not served to in-flight requests. The import pipeline hook that triggers + this call is wired separately (outside this module). + """ + _CACHE.clear() diff --git a/backend/runs/domain/progression/tests/test_geo.py b/backend/runs/domain/progression/tests/test_geo.py new file mode 100644 index 0000000..43f3356 --- /dev/null +++ b/backend/runs/domain/progression/tests/test_geo.py @@ -0,0 +1,164 @@ +"""Pure unit tests for geo.py — no Django/Redis required. + +Tests cover: +- haversine_m: known distances (latitude degree ≈ 111 km, equator longitude, + identical points, cardinal moves). +- project_point_to_polyline: on-vertex, perpendicular offset, before-start + clamp, after-end clamp, degenerate polylines (empty, single point). +""" + +from __future__ import annotations + +import math + +import pytest + +from runs.domain.progression.geo import haversine_m, project_point_to_polyline + + +# --------------------------------------------------------------------------- +# haversine_m +# --------------------------------------------------------------------------- + + +class TestHaversineM: + def test_identical_points_zero(self): + assert haversine_m(0.0, 0.0, 0.0, 0.0) == pytest.approx(0.0, abs=1e-6) + + def test_one_degree_latitude_near_equator(self): + # 1° of latitude ≈ 111 195 m (varies slightly; allow 0.5% tolerance). + d = haversine_m(0.0, 0.0, 1.0, 0.0) + assert d == pytest.approx(111_195.0, rel=0.005) + + def test_one_degree_longitude_at_equator(self): + # At equator, 1° lon ≈ same as 1° lat. + d = haversine_m(0.0, 0.0, 0.0, 1.0) + assert d == pytest.approx(111_195.0, rel=0.005) + + def test_one_degree_longitude_at_45_lat(self): + # At 45° N, 1° lon ≈ 111 195 * cos(45°) ≈ 78 626 m. + d = haversine_m(45.0, 0.0, 45.0, 1.0) + expected = 111_195.0 * math.cos(math.radians(45.0)) + assert d == pytest.approx(expected, rel=0.01) + + def test_symmetric(self): + assert haversine_m(51.5, -0.1, 48.8, 2.3) == pytest.approx( + haversine_m(48.8, 2.3, 51.5, -0.1), rel=1e-10 + ) + + def test_known_paris_london(self): + # Paris (48.8566, 2.3522) to London (51.5074, -0.1278) ≈ 340 km. + d = haversine_m(48.8566, 2.3522, 51.5074, -0.1278) + assert 330_000 < d < 350_000 + + def test_non_negative(self): + assert haversine_m(10.0, 10.0, -10.0, -10.0) > 0 + + +# --------------------------------------------------------------------------- +# project_point_to_polyline +# --------------------------------------------------------------------------- + + +def _make_straight_polyline( + lat0: float, lon0: float, lat1: float, lon1: float, n: int = 5 +) -> list[tuple[float, float, float]]: + """Build a uniform n-point polyline between two WGS-84 coords.""" + from runs.domain.progression.shapes import build_polyline + + raw = [ + (lat0 + (lat1 - lat0) * i / (n - 1), lon0 + (lon1 - lon0) * i / (n - 1), i) + for i in range(n) + ] + return build_polyline(raw) + + +class TestProjectPointToPolyline: + def test_empty_polyline_returns_zeros(self): + result = project_point_to_polyline(0.0, 0.0, []) + assert result == {"progress_m": 0.0, "cross_track_m": 0.0, "segment_idx": 0} + + def test_single_point_polyline(self): + poly = [(10.0, 20.0, 0.0)] + result = project_point_to_polyline(10.0, 20.0, poly) + assert result["cross_track_m"] == pytest.approx(0.0, abs=1e-3) + assert result["progress_m"] == pytest.approx(0.0, abs=1e-3) + assert result["segment_idx"] == 0 + + def test_point_exactly_on_first_vertex(self): + poly = _make_straight_polyline(0.0, 0.0, 1.0, 0.0, n=5) + result = project_point_to_polyline(0.0, 0.0, poly) + assert result["cross_track_m"] == pytest.approx(0.0, abs=1.0) # ≤1 m + assert result["progress_m"] == pytest.approx(0.0, abs=1.0) + assert result["segment_idx"] == 0 + + def test_point_exactly_on_last_vertex(self): + poly = _make_straight_polyline(0.0, 0.0, 1.0, 0.0, n=5) + total_m = poly[-1][2] + result = project_point_to_polyline(1.0, 0.0, poly) + assert result["cross_track_m"] == pytest.approx(0.0, abs=1.0) + assert result["progress_m"] == pytest.approx(total_m, rel=0.005) + assert result["segment_idx"] == len(poly) - 2 + + def test_midpoint_on_segment(self): + """A point at the geographic midpoint of the polyline should have + cross_track ≈ 0 and progress ≈ total_m / 2.""" + poly = _make_straight_polyline(0.0, 0.0, 0.0, 1.0, n=5) # east-west + total_m = poly[-1][2] + mid_lat, mid_lon = 0.0, 0.5 + result = project_point_to_polyline(mid_lat, mid_lon, poly) + assert result["cross_track_m"] == pytest.approx(0.0, abs=5.0) # ≤5 m + assert result["progress_m"] == pytest.approx(total_m / 2.0, rel=0.01) + + def test_perpendicular_offset_gives_correct_cross_track(self): + """A point 0.01° north of the midpoint of an east-west line should + have cross_track ≈ haversine_m(0, 0, 0.01, 0) and progress ≈ mid.""" + poly = _make_straight_polyline(0.0, 0.0, 0.0, 1.0, n=5) + total_m = poly[-1][2] + offset_deg = 0.01 + expected_cross_m = haversine_m(0.0, 0.0, offset_deg, 0.0) + result = project_point_to_polyline(offset_deg, 0.5, poly) + assert result["cross_track_m"] == pytest.approx(expected_cross_m, rel=0.05) + assert result["progress_m"] == pytest.approx(total_m / 2.0, rel=0.05) + + def test_point_before_start_clamps_to_zero(self): + """A point 'behind' the polyline should project to the start (t=0 clamp).""" + poly = _make_straight_polyline(0.0, 0.0, 0.0, 1.0, n=3) + # Place point due west of the start — behind the east-pointing line. + result = project_point_to_polyline(0.0, -0.5, poly) + assert result["progress_m"] == pytest.approx(0.0, abs=10.0) + assert result["segment_idx"] == 0 + + def test_point_after_end_clamps_to_total(self): + """A point 'beyond' the end should project to the last vertex.""" + poly = _make_straight_polyline(0.0, 0.0, 0.0, 1.0, n=3) + total_m = poly[-1][2] + result = project_point_to_polyline(0.0, 1.5, poly) + assert result["progress_m"] == pytest.approx(total_m, rel=0.01) + assert result["segment_idx"] == len(poly) - 2 + + def test_segment_idx_matches_nearest_segment(self): + """For a two-segment polyline with a right-angle bend, the nearest + segment should be reported correctly.""" + # Two segments: (0,0)→(0,1) then (0,1)→(1,1). + from runs.domain.progression.shapes import build_polyline + + raw = [(0.0, 0.0, 0), (0.0, 1.0, 1), (1.0, 1.0, 2)] + poly = build_polyline(raw) + # A point north of the midpoint of the second segment. + result = project_point_to_polyline(0.5, 1.001, poly) + assert result["segment_idx"] == 1 + + def test_returns_dict_with_required_keys(self): + poly = _make_straight_polyline(10.0, 10.0, 11.0, 11.0, n=3) + result = project_point_to_polyline(10.5, 10.5, poly) + assert set(result.keys()) == {"progress_m", "cross_track_m", "segment_idx"} + assert isinstance(result["progress_m"], float) + assert isinstance(result["cross_track_m"], float) + assert isinstance(result["segment_idx"], int) + + def test_progress_non_negative(self): + poly = _make_straight_polyline(0.0, 0.0, 1.0, 1.0, n=4) + result = project_point_to_polyline(0.5, 0.5, poly) + assert result["progress_m"] >= 0.0 + assert result["cross_track_m"] >= 0.0 diff --git a/backend/runs/domain/progression/tests/test_shapes.py b/backend/runs/domain/progression/tests/test_shapes.py new file mode 100644 index 0000000..ad596c3 --- /dev/null +++ b/backend/runs/domain/progression/tests/test_shapes.py @@ -0,0 +1,275 @@ +"""Pure unit tests for shapes.py — no Django/Redis required. + +Tests cover: +- build_polyline: cumulative distances, empty input, single point. +- build_stops: progress_m assigned, monotonic on straight shapes. +- assemble_geometry: returns valid ShapeGeometry. +- get_shape_geometry caching: second call doesn't invoke the loader again. +- invalidate_cache: clears cached entry so next call reloads. +""" + +from __future__ import annotations + +import pytest + +from runs.domain.progression.geo import haversine_m +from runs.domain.progression.shapes import ( + ShapeGeometry, + assemble_geometry, + build_polyline, + build_stops, + get_shape_geometry, + invalidate_cache, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _straight_shape_points(n: int = 5) -> list[tuple[float, float, int]]: + """n points along the prime meridian from (0,0) to (n-1 deg, 0).""" + return [(float(i), 0.0, i) for i in range(n)] + + +def _straight_stops(n: int = 3) -> list[dict]: + """n stops distributed along the prime meridian.""" + return [ + {"stop_id": f"S{i}", "stop_sequence": i + 1, "lat": float(i), "lon": 0.0} + for i in range(n) + ] + + +# --------------------------------------------------------------------------- +# build_polyline +# --------------------------------------------------------------------------- + + +class TestBuildPolyline: + def test_empty_input(self): + assert build_polyline([]) == [] + + def test_single_point_cum_zero(self): + result = build_polyline([(10.0, 20.0, 1)]) + assert len(result) == 1 + lat, lon, cum = result[0] + assert lat == pytest.approx(10.0) + assert lon == pytest.approx(20.0) + assert cum == pytest.approx(0.0) + + def test_two_points_cumulative(self): + raw = [(0.0, 0.0, 0), (1.0, 0.0, 1)] + result = build_polyline(raw) + assert result[0][2] == pytest.approx(0.0) + expected_d = haversine_m(0.0, 0.0, 1.0, 0.0) + assert result[1][2] == pytest.approx(expected_d, rel=1e-9) + + def test_three_points_matches_manual_haversine(self): + raw = [(0.0, 0.0, 0), (1.0, 0.0, 1), (2.0, 0.0, 2)] + result = build_polyline(raw) + d01 = haversine_m(0.0, 0.0, 1.0, 0.0) + d12 = haversine_m(1.0, 0.0, 2.0, 0.0) + assert result[0][2] == pytest.approx(0.0) + assert result[1][2] == pytest.approx(d01, rel=1e-9) + assert result[2][2] == pytest.approx(d01 + d12, rel=1e-9) + + def test_cumulative_is_monotonically_increasing(self): + raw = _straight_shape_points(5) + result = build_polyline(raw) + cums = [r[2] for r in result] + for i in range(1, len(cums)): + assert cums[i] > cums[i - 1] + + def test_preserves_lat_lon(self): + raw = [(51.5, -0.1, 0), (51.6, -0.1, 1)] + result = build_polyline(raw) + assert result[0][0] == pytest.approx(51.5) + assert result[0][1] == pytest.approx(-0.1) + assert result[1][0] == pytest.approx(51.6) + + +# --------------------------------------------------------------------------- +# build_stops +# --------------------------------------------------------------------------- + + +class TestBuildStops: + def test_empty_input(self): + poly = build_polyline(_straight_shape_points(3)) + assert build_stops([], poly) == [] + + def test_single_stop_on_first_vertex(self): + poly = build_polyline(_straight_shape_points(3)) + stops = [{"stop_id": "X", "stop_sequence": 1, "lat": 0.0, "lon": 0.0}] + result = build_stops(stops, poly) + assert len(result) == 1 + assert result[0]["progress_m"] == pytest.approx(0.0, abs=1.0) + assert result[0]["stop_id"] == "X" + assert result[0]["stop_sequence"] == 1 + + def test_progress_m_present_for_all_stops(self): + poly = build_polyline(_straight_shape_points(5)) + stops = _straight_stops(3) + result = build_stops(stops, poly) + assert all("progress_m" in s for s in result) + + def test_progress_m_monotonic_along_straight_shape(self): + """Stops at equally-spaced latitudes along the prime meridian must + have strictly increasing progress_m.""" + raw_pts = [(float(i), 0.0, i) for i in range(5)] + poly = build_polyline(raw_pts) + stop_rows = [ + {"stop_id": f"S{i}", "stop_sequence": i, "lat": float(i), "lon": 0.0} + for i in range(5) + ] + result = build_stops(stop_rows, poly) + progs = [r["progress_m"] for r in result] + for i in range(1, len(progs)): + assert progs[i] > progs[i - 1], ( + f"progress not monotonic at index {i}: {progs}" + ) + + def test_stop_matches_manual_projection(self): + """A stop at the exact first vertex should project to progress ≈ 0.""" + poly = build_polyline([(0.0, 0.0, 0), (1.0, 0.0, 1)]) + stops = [{"stop_id": "A", "stop_sequence": 1, "lat": 0.0, "lon": 0.0}] + result = build_stops(stops, poly) + assert result[0]["progress_m"] == pytest.approx(0.0, abs=1.0) + + +# --------------------------------------------------------------------------- +# assemble_geometry +# --------------------------------------------------------------------------- + + +class TestAssembleGeometry: + def test_returns_shape_geometry_instance(self): + pts = _straight_shape_points(3) + stops = _straight_stops(2) + geom = assemble_geometry("shp-1", "trip-1", pts, stops) + assert isinstance(geom, ShapeGeometry) + + def test_fields_populated(self): + pts = _straight_shape_points(4) + stops = _straight_stops(3) + geom = assemble_geometry("shp-A", "trip-B", pts, stops) + assert geom.shape_id == "shp-A" + assert geom.trip_id == "trip-B" + assert len(geom.polyline) == 4 + assert len(geom.stops) == 3 + + def test_polyline_is_tuple_of_tuples(self): + pts = _straight_shape_points(3) + geom = assemble_geometry("s", "t", pts, []) + assert isinstance(geom.polyline, tuple) + for item in geom.polyline: + assert isinstance(item, tuple) + + def test_stops_is_tuple_of_dicts(self): + pts = _straight_shape_points(3) + stops = _straight_stops(2) + geom = assemble_geometry("s", "t", pts, stops) + assert isinstance(geom.stops, tuple) + for s in geom.stops: + assert isinstance(s, dict) + assert "progress_m" in s + + def test_hashable(self): + pts = _straight_shape_points(2) + geom = assemble_geometry("s", "t", pts, []) + # Should not raise — frozen dataclass with tuples. + h = hash(geom) + assert isinstance(h, int) + + +# --------------------------------------------------------------------------- +# Caching behaviour (monkeypatched loader) +# --------------------------------------------------------------------------- + + +def _make_test_geom(shape_id: str = "shp", trip_id: str = "trp") -> ShapeGeometry: + pts = _straight_shape_points(3) + stops = _straight_stops(2) + return assemble_geometry(shape_id, trip_id, pts, stops) + + +class TestCache: + def setup_method(self): + invalidate_cache() + + def test_get_shape_geometry_returns_none_when_loader_returns_none( + self, monkeypatch + ): + monkeypatch.setattr( + "runs.domain.progression.shapes.load_shape_geometry", + lambda *a, **kw: None, + ) + # Also patch the feed lookup inside get_shape_geometry so no ORM is hit. + monkeypatch.setattr( + "runs.domain.progression.shapes.Feed", + None, + raising=False, + ) + result = get_shape_geometry("no-shape", "no-trip") + assert result is None + + def test_get_shape_geometry_caches_on_second_call(self, monkeypatch): + """Second call must NOT invoke load_shape_geometry again.""" + geom = _make_test_geom() + call_count = {"n": 0} + + def fake_load(shape_id, trip_id, *, feed=None): + call_count["n"] += 1 + return geom + + monkeypatch.setattr( + "runs.domain.progression.shapes.load_shape_geometry", fake_load + ) + # Prevent the ORM Feed lookup inside get_shape_geometry from running. + import runs.domain.progression.shapes as _shapes_mod + + monkeypatch.setattr(_shapes_mod, "_CACHE", {}) + + # Patch the internal Feed import to avoid Django hitting ORM. + # We rely on the try/except in get_shape_geometry — when Feed cannot be + # imported (no Django), feed_id=None is used as the cache key. + r1 = get_shape_geometry("shp", "trp") + r2 = get_shape_geometry("shp", "trp") + + assert r1 is geom + assert r2 is geom + # load_shape_geometry should only have been called once. + assert call_count["n"] == 1 + + def test_invalidate_cache_forces_reload(self, monkeypatch): + geom1 = _make_test_geom("s1", "t1") + geom2 = _make_test_geom("s2", "t2") + calls = {"geoms": [geom1, geom2]} + + def fake_load(shape_id, trip_id, *, feed=None): + return calls["geoms"].pop(0) + + monkeypatch.setattr( + "runs.domain.progression.shapes.load_shape_geometry", fake_load + ) + import runs.domain.progression.shapes as _shapes_mod + + monkeypatch.setattr(_shapes_mod, "_CACHE", {}) + + r1 = get_shape_geometry("shp", "trp") + invalidate_cache() + r2 = get_shape_geometry("shp", "trp") + + # After invalidation, the loader was called a second time → different geom. + assert r1 is geom1 + assert r2 is geom2 + + def test_invalidate_cache_clears_all_entries(self, monkeypatch): + import runs.domain.progression.shapes as _shapes_mod + + g = _make_test_geom() + _shapes_mod._CACHE[(None, "shp", "trp")] = g + assert len(_shapes_mod._CACHE) == 1 + invalidate_cache() + assert len(_shapes_mod._CACHE) == 0 From 8cf5f5a683966723d1ca790178d7aae31d712c37 Mon Sep 17 00:00:00 2001 From: Jae Date: Tue, 9 Jun 2026 20:14:48 -0600 Subject: [PATCH 20/23] feat(progression): real map-matching for vehicle_stop_status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the placeholder seam body of compute_stop_status with a real GPS→polyline map-matching algorithm: project the observed position onto the GTFS shape polyline, select the upcoming stop by progress_m, apply three-state radius rules (STOPPED_AT/INCOMING_AT/IN_TRANSIT_TO), and enforce a monotonic sequence floor from prev_state. A broad except-Exception fallback reproduces the original seam behaviour (IN_TRANSIT_TO + carry-forward) on any ORM error, missing feed, or unexpected position payload so the producer never raises. --- backend/runs/domain/progression/compute.py | 221 +++++++++++++---- .../domain/progression/tests/test_compute.py | 224 +++++++++++++++++- 2 files changed, 391 insertions(+), 54 deletions(-) diff --git a/backend/runs/domain/progression/compute.py b/backend/runs/domain/progression/compute.py index 3b1308f..1dc3253 100644 --- a/backend/runs/domain/progression/compute.py +++ b/backend/runs/domain/progression/compute.py @@ -1,40 +1,65 @@ -"""Server-side stop-status computation (seam / placeholder implementation). - -This module provides a single pure function :func:`compute_stop_status` that -derives a ``vehicle_stop_status`` contract dict from the current run hash and -the vehicle's latest position hash. - -**Current implementation: seam / placeholder body** - -The body is intentionally minimal — it defaults ``current_status`` to -``IN_TRANSIT_TO`` and carries forward ``current_stop_sequence`` / ``stop_id`` -from ``prev_state`` as a monotonic floor so values do not regress once a real -producer has set them. No map-matching is performed here. - -The function signature is **stable** — the future map-matching implementation -swaps only the body. -``run_hash`` is accepted now (currently unused) because the real implementation -needs ``shape_id`` / ``trip_id`` to look up the run's GTFS shape polyline. - -# TODO(map-matching port): -# Real algorithm: -# 1. Project the observed GPS point (position_hash latitude/longitude) onto -# the run's shape polyline (identified via run_hash["shape_id"]). -# 2. Find the nearest stop along the polyline within STOP_RADIUS_M. -# 3. Apply radius rules: -# - distance <= STOP_RADIUS_M and vehicle is stationary → STOPPED_AT -# - distance <= INCOMING_AT_RADIUS_M and approaching → INCOMING_AT -# - otherwise → IN_TRANSIT_TO -# 4. Enforce monotonic sequence: current_stop_sequence must never decrease -# from prev_state["current_stop_sequence"] (unless the run restarts). -# 5. Resolve stop_id from the GTFS stops table for the selected sequence. -# The port swaps only this body; callers and tests of the contract dict shape -# are unaffected because the signature and return type are already locked. +"""Server-side stop-status computation via real GPS→polyline map-matching. + +Algorithm +--------- +Given the current ``run_hash`` (which carries ``shape_id`` and ``trip_id``) and +the latest ``position_hash`` (typed GPS fix), the function: + +1. Loads the cached GTFS shape geometry for the (shape_id, trip_id) pair via + ``shapes.get_shape_geometry``. +2. Projects the observed GPS point onto the shape polyline to obtain + ``point_progress_m`` — the along-track distance from the shape start. +3. Selects the *upcoming* stop: the stop whose ``progress_m`` is the smallest + value >= ``point_progress_m`` (i.e. the next stop ahead). If no stop is + ahead of the vehicle the last stop is chosen. +4. Computes the great-circle distance from the observed point to the candidate + stop and applies three-state radius rules: + + * ``distance <= STOP_RADIUS_M`` AND (speed unknown OR speed <= + STATIONARY_SPEED_MPS) → ``STOPPED_AT`` + * ``distance <= INCOMING_AT_RADIUS_M`` AND vehicle is still approaching + (``point_progress_m < stop.progress_m``) → ``INCOMING_AT`` + * otherwise → ``IN_TRANSIT_TO`` + +5. Enforces a monotonic sequence floor using ``prev_state``: if the new + candidate's stop_sequence < prev sequence, the previous sequence/stop_id are + kept (mirrors the sim debounce: only advance when stop_id changes). + +The entire algorithm is wrapped in ``try/except Exception`` and falls back to +the original seam behaviour (``IN_TRANSIT_TO`` + carry-forward of prev_state) +on any exception, including ORM connection errors, missing GTFS data, or +unexpected position payloads. The producer calls this on every position tick +and must never raise. + +Signature and return contract are FROZEN — callers (``producer.py``), the +contract dict shape, and ``vehicle_stop_status.validate_for_write`` are unaffected. """ from __future__ import annotations from runs.domain.telemetry import vehicle_stop_status +from runs.domain.progression import shapes +from runs.domain.progression.geo import haversine_m + + +# --------------------------------------------------------------------------- +# Tunable constants (comment explains intent for each) +# --------------------------------------------------------------------------- + +# Vehicle is considered *at* a stop when within this radius. +STOP_RADIUS_M = 20.0 + +# Vehicle is *incoming* when within this radius and still approaching. +INCOMING_AT_RADIUS_M = 50.0 + +# Speed at or below this threshold (m/s) qualifies as stationary. +# 0.5 m/s ≈ 1.8 km/h — slow enough to be considered dwell. +STATIONARY_SPEED_MPS = 0.5 + + +# --------------------------------------------------------------------------- +# Public function +# --------------------------------------------------------------------------- def compute_stop_status( @@ -49,45 +74,143 @@ def compute_stop_status( ---------- run_hash: The full ``run:`` Redis hash (all strings, as returned by - ``r.hgetall``). In the seam this is unused; the future map-matching - implementation uses ``run_hash["shape_id"]`` / ``run_hash["trip_id"]``. + ``r.hgetall``). Must contain ``shape_id`` and ``trip_id`` for real + map-matching; falls back to seam behaviour otherwise. position_hash: The typed position dict (as returned by ``position.from_redis``), containing at minimum ``latitude`` and ``longitude`` as floats. prev_state: Optional previous vehicle_stop_status dict (as returned by - ``vehicle_stop_status.from_redis``). When present, ``current_stop_sequence`` - and ``stop_id`` are carried forward as a monotonic floor so values do - not regress between position updates. + ``vehicle_stop_status.from_redis``). ``current_stop_sequence`` and + ``stop_id`` are carried forward as a monotonic floor so values do not + regress between position updates. Returns ------- dict A vehicle_stop_status contract dict with: - ``current_status`` (str, always present — required by the contract) - - ``current_stop_sequence`` (int, optional — carried from prev_state) - - ``stop_id`` (str, optional — carried from prev_state) - - The returned dict always passes ``vehicle_stop_status.validate_for_write``. + - ``current_stop_sequence`` (int, optional) + - ``stop_id`` (str, optional) - # TODO(map-matching port): Replace this seam body with real - # map-matching logic as described in the module docstring above. + Always passes ``vehicle_stop_status.validate_for_write``. + Never raises. """ + try: + return _compute_with_map_matching(run_hash, position_hash, prev_state) + except Exception: + return _fallback(prev_state) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _fallback(prev_state: dict | None) -> dict: + """Seam / fallback: IN_TRANSIT_TO + carry-forward of prev_state fields.""" result: dict = { vehicle_stop_status.CURRENT_STATUS: "IN_TRANSIT_TO", } - - # Carry forward optional fields from prev_state as a monotonic floor. - # Once a real producer has set sequence/stop_id, they should not vanish - # between position updates (they will only change when the real algorithm - # detects a stop transition). if prev_state is not None: seq = prev_state.get(vehicle_stop_status.CURRENT_STOP_SEQUENCE) if seq is not None: result[vehicle_stop_status.CURRENT_STOP_SEQUENCE] = seq - stop_id = prev_state.get(vehicle_stop_status.STOP_ID) if stop_id is not None: result[vehicle_stop_status.STOP_ID] = stop_id + return result + +def _compute_with_map_matching( + run_hash: dict, + position_hash: dict, + prev_state: dict | None, +) -> dict: + """Core map-matching logic — may raise; callers wrap in try/except.""" + # Step 1: resolve shape / trip ids. + shape_id = run_hash.get("shape_id") + trip_id = run_hash.get("trip_id") + if not shape_id or not trip_id: + return _fallback(prev_state) + + # Step 2: load geometry (may be None if feed / shape not found). + geom = shapes.get_shape_geometry(shape_id, trip_id) + if geom is None: + return _fallback(prev_state) + + # Step 3: observed coordinates. + observed_lat = position_hash.get("latitude") + observed_lon = position_hash.get("longitude") + if observed_lat is None or observed_lon is None: + return _fallback(prev_state) + observed_lat = float(observed_lat) + observed_lon = float(observed_lon) + + # Step 4: project observed point onto polyline. + proj = shapes.project_point_to_polyline( + observed_lat, observed_lon, list(geom.polyline) + ) + point_progress_m: float = proj["progress_m"] + + # Step 5: choose the candidate (upcoming) stop. + candidate = _pick_upcoming_stop(geom.stops, point_progress_m) + if candidate is None: + return _fallback(prev_state) + + # Step 6: distance + radius rules. + dist_m = haversine_m( + observed_lat, observed_lon, candidate["lat"], candidate["lon"] + ) + speed = position_hash.get("speed") + if speed is not None: + speed = float(speed) + + if dist_m <= STOP_RADIUS_M and (speed is None or speed <= STATIONARY_SPEED_MPS): + status = "STOPPED_AT" + elif dist_m <= INCOMING_AT_RADIUS_M and point_progress_m < candidate["progress_m"]: + status = "INCOMING_AT" + else: + status = "IN_TRANSIT_TO" + + chosen_seq: int = candidate["stop_sequence"] + chosen_stop_id: str = candidate["stop_id"] + + # Step 7: monotonic guard — never regress sequence. + if prev_state is not None: + prev_seq = prev_state.get(vehicle_stop_status.CURRENT_STOP_SEQUENCE) + if prev_seq is not None: + prev_seq = int(prev_seq) + if chosen_seq < prev_seq: + # New candidate is behind prev — hold position. + chosen_seq = prev_seq + chosen_stop_id = prev_state.get(vehicle_stop_status.STOP_ID, chosen_stop_id) + + result: dict = { + vehicle_stop_status.CURRENT_STATUS: status, + vehicle_stop_status.CURRENT_STOP_SEQUENCE: chosen_seq, + vehicle_stop_status.STOP_ID: chosen_stop_id, + } return result + + +def _pick_upcoming_stop( + stops: tuple[dict, ...], + point_progress_m: float, +) -> dict | None: + """Return the next stop ahead of ``point_progress_m``, or the last stop. + + Stops are expected to be ordered by stop_sequence (ascending). The + upcoming stop is the one with the smallest ``progress_m`` that is >= + ``point_progress_m``. If the vehicle has passed all stops, the last stop + is returned. + """ + if not stops: + return None + + for stop in stops: + if stop["progress_m"] >= point_progress_m: + return stop + + # Vehicle has passed all stops — return the terminal stop. + return stops[-1] diff --git a/backend/runs/domain/progression/tests/test_compute.py b/backend/runs/domain/progression/tests/test_compute.py index a557128..191b4aa 100644 --- a/backend/runs/domain/progression/tests/test_compute.py +++ b/backend/runs/domain/progression/tests/test_compute.py @@ -1,16 +1,33 @@ """Pure unit tests for compute_stop_status — no Django/Redis required. -The key guarantee tested here: -- The seam always returns a dict that passes vehicle_stop_status.validate_for_write - (round-trip through the contract). This pins the output shape so that - the future map-matching port cannot accidentally break the contract. +Two test groups: + +1. **Fallback / contract group** (tests 1–9, original): + These use a ``_RUN_HASH`` that references real shape/trip ids, but since no + Django ORM is configured in plain-pytest the ``get_shape_geometry`` call + raises, which triggers the broad ``except Exception`` fallback inside + ``compute_stop_status``. The fallback reproduces the old seam behaviour: + ``IN_TRANSIT_TO`` + carry-forward of ``prev_state``. All original + contract assertions hold unchanged. + +2. **Real map-matching group** (new tests at the bottom): + ``shapes.get_shape_geometry`` is monkeypatched to return a known straight-line + ``ShapeGeometry`` with a small set of colinear stops. Canned positions are fed + in to assert the status sequence ``IN_TRANSIT_TO → INCOMING_AT → STOPPED_AT`` + and that ``current_stop_sequence`` never regresses on jittered GPS. """ from __future__ import annotations import pytest -from runs.domain.progression.compute import compute_stop_status +from runs.domain.progression.compute import ( + INCOMING_AT_RADIUS_M, + STATIONARY_SPEED_MPS, + STOP_RADIUS_M, + compute_stop_status, +) +from runs.domain.progression.shapes import ShapeGeometry, assemble_geometry, invalidate_cache from runs.domain.telemetry import vehicle_stop_status @@ -171,3 +188,200 @@ def test_empty_prev_state_dict_carries_nothing(): assert result[vehicle_stop_status.CURRENT_STATUS] == "IN_TRANSIT_TO" assert vehicle_stop_status.CURRENT_STOP_SEQUENCE not in result assert vehicle_stop_status.STOP_ID not in result + + +# =========================================================================== +# Real map-matching tests (monkeypatched geometry) +# =========================================================================== +# +# Geometry: straight north-south line along the prime meridian. +# Shape: 5 points from 0° to 4° latitude (≈ 444 km total). +# Stops: 3 stops at 0°, 2°, 4° latitude. +# +# The helper ``_make_geom`` assembles this without any ORM calls. +# ``shapes.get_shape_geometry`` is patched to return it directly. +# --------------------------------------------------------------------------- + + +def _make_straight_geom() -> ShapeGeometry: + """Build a canned north-pointing straight-line ShapeGeometry.""" + shape_points = [(float(i), 0.0, i) for i in range(5)] # (lat, lon, seq) + stop_rows = [ + {"stop_id": "S0", "stop_sequence": 1, "lat": 0.0, "lon": 0.0}, + {"stop_id": "S1", "stop_sequence": 2, "lat": 2.0, "lon": 0.0}, + {"stop_id": "S2", "stop_sequence": 3, "lat": 4.0, "lon": 0.0}, + ] + return assemble_geometry("shp-straight", "trip-straight", shape_points, stop_rows) + + +def _pos(lat: float, lon: float = 0.0, speed: float | None = 8.0) -> dict: + """Build a minimal position_hash dict.""" + p = {"latitude": lat, "longitude": lon, "bearing": 0.0, "timestamp": 1700000000} + if speed is not None: + p["speed"] = speed + return p + + +def _run_hash() -> dict: + return {"trip_id": "trip-straight", "shape_id": "shp-straight"} + + +class TestMapMatchingStatuses: + """Status progression on a canned straight-line shape.""" + + @pytest.fixture(autouse=True) + def patch_geometry(self, monkeypatch): + geom = _make_straight_geom() + monkeypatch.setattr( + "runs.domain.progression.shapes.get_shape_geometry", + lambda *a, **kw: geom, + ) + invalidate_cache() + + def test_far_from_stop_is_in_transit_to(self): + """Position at lat=0.5° with speed 8 m/s — far from S1 (2°).""" + result = compute_stop_status(_run_hash(), _pos(0.5), prev_state=None) + assert result[vehicle_stop_status.CURRENT_STATUS] == "IN_TRANSIT_TO" + + def test_incoming_at_when_within_incoming_radius(self): + """Place vehicle just under INCOMING_AT_RADIUS_M before S1.""" + from runs.domain.progression.geo import haversine_m as hav + + # Find lat just inside INCOMING_AT_RADIUS_M of S1 (2.0, 0.0). + # 1° ≈ 111 195 m. INCOMING_AT_RADIUS_M = 50 m → Δlat ≈ 50/111195. + delta_lat = (INCOMING_AT_RADIUS_M - 5) / 111_195.0 # a little inside + incoming_lat = 2.0 - delta_lat + result = compute_stop_status(_run_hash(), _pos(incoming_lat, speed=8.0), prev_state=None) + assert result[vehicle_stop_status.CURRENT_STATUS] == "INCOMING_AT" + assert result[vehicle_stop_status.STOP_ID] == "S1" + + def test_stopped_at_when_within_stop_radius_and_slow(self): + """Place vehicle inside STOP_RADIUS_M of S1 at near-zero speed.""" + delta_lat = (STOP_RADIUS_M - 2) / 111_195.0 + at_lat = 2.0 - delta_lat + result = compute_stop_status( + _run_hash(), _pos(at_lat, speed=STATIONARY_SPEED_MPS - 0.1), prev_state=None + ) + assert result[vehicle_stop_status.CURRENT_STATUS] == "STOPPED_AT" + assert result[vehicle_stop_status.STOP_ID] == "S1" + + def test_stopped_at_when_speed_absent(self): + """No speed field → treated as stationary, so STOPPED_AT inside radius.""" + delta_lat = (STOP_RADIUS_M - 2) / 111_195.0 + at_lat = 2.0 - delta_lat + result = compute_stop_status( + _run_hash(), _pos(at_lat, speed=None), prev_state=None + ) + assert result[vehicle_stop_status.CURRENT_STATUS] == "STOPPED_AT" + + def test_fast_inside_stop_radius_is_incoming_at(self): + """Vehicle inside STOP_RADIUS_M but moving fast → INCOMING_AT. + + When a vehicle is within STOP_RADIUS_M but speed exceeds the stationary + threshold the STOPPED_AT condition is not met. The vehicle is still + within INCOMING_AT_RADIUS_M (which is the outer radius) and still + approaching, so INCOMING_AT is the correct status. + """ + delta_lat = (STOP_RADIUS_M - 2) / 111_195.0 + at_lat = 2.0 - delta_lat + result = compute_stop_status( + _run_hash(), _pos(at_lat, speed=5.0), prev_state=None + ) + # INCOMING_AT: within INCOMING_AT_RADIUS_M and still approaching. + assert result[vehicle_stop_status.CURRENT_STATUS] == "INCOMING_AT" + + def test_status_sequence_in_transit_incoming_stopped(self): + """Full sequence: far → near → at-stop.""" + rh = _run_hash() + far = compute_stop_status(rh, _pos(0.5), prev_state=None) + assert far[vehicle_stop_status.CURRENT_STATUS] == "IN_TRANSIT_TO" + + delta_lat = (INCOMING_AT_RADIUS_M - 5) / 111_195.0 + incoming = compute_stop_status(rh, _pos(2.0 - delta_lat, speed=8.0), prev_state=far) + assert incoming[vehicle_stop_status.CURRENT_STATUS] == "INCOMING_AT" + + at_lat = 2.0 - (STOP_RADIUS_M - 2) / 111_195.0 + stopped = compute_stop_status(rh, _pos(at_lat, speed=0.1), prev_state=incoming) + assert stopped[vehicle_stop_status.CURRENT_STATUS] == "STOPPED_AT" + + def test_stop_id_present_in_result(self): + result = compute_stop_status(_run_hash(), _pos(0.5), prev_state=None) + assert vehicle_stop_status.STOP_ID in result + + def test_stop_sequence_present_in_result(self): + result = compute_stop_status(_run_hash(), _pos(0.5), prev_state=None) + assert vehicle_stop_status.CURRENT_STOP_SEQUENCE in result + + def test_result_always_passes_validate_for_write(self): + positions = [_pos(lat) for lat in [0.1, 0.5, 1.0, 1.9, 2.0, 2.5, 3.5, 4.0]] + rh = _run_hash() + prev = None + for pos in positions: + result = compute_stop_status(rh, pos, prev_state=prev) + vehicle_stop_status.validate_for_write(result) + prev = result + + +class TestMonotonicGuard: + """current_stop_sequence must never decrease between position ticks.""" + + @pytest.fixture(autouse=True) + def patch_geometry(self, monkeypatch): + geom = _make_straight_geom() + monkeypatch.setattr( + "runs.domain.progression.shapes.get_shape_geometry", + lambda *a, **kw: geom, + ) + invalidate_cache() + + def test_sequence_never_regresses_on_jittered_gps(self): + """Feed a sequence of positions that sometimes step backward; + current_stop_sequence must be non-decreasing.""" + rh = _run_hash() + # Approach S1 (lat 2.0), cross it, then jitter backward. + lats = [0.5, 1.0, 1.5, 1.9, 2.0, 1.95, 1.5, 1.0, 2.5, 3.0] + prev = None + prev_seq = 0 + for lat in lats: + result = compute_stop_status(rh, _pos(lat, speed=2.0), prev_state=prev) + seq = result.get(vehicle_stop_status.CURRENT_STOP_SEQUENCE, prev_seq) + assert seq >= prev_seq, ( + f"Sequence regressed from {prev_seq} to {seq} at lat={lat}" + ) + prev_seq = seq + prev = result + + def test_sequence_holds_when_new_candidate_behind_prev(self): + """If the map-matcher picks a stop behind the prev sequence, the prev + sequence and stop_id are kept.""" + rh = _run_hash() + # Give a prev_state that's already at stop 2 (S1, sequence=2). + prev = { + vehicle_stop_status.CURRENT_STATUS: "IN_TRANSIT_TO", + vehicle_stop_status.CURRENT_STOP_SEQUENCE: 2, + vehicle_stop_status.STOP_ID: "S1", + } + # Position at lat=0.5 would normally pick S0 (sequence=1) — behind prev. + result = compute_stop_status(rh, _pos(0.5), prev_state=prev) + # The guard must clamp to sequence 2. + assert result[vehicle_stop_status.CURRENT_STOP_SEQUENCE] == 2 + assert result[vehicle_stop_status.STOP_ID] == "S1" + + +class TestFallbackEdgeCases: + """Extra fallback-path coverage (no monkeypatch needed — no geometry loaded).""" + + def test_missing_shape_id_fallback(self): + rh = {"trip_id": "trip-1"} # no shape_id + result = compute_stop_status(rh, _pos(0.0), prev_state=None) + assert result[vehicle_stop_status.CURRENT_STATUS] == "IN_TRANSIT_TO" + + def test_missing_trip_id_fallback(self): + rh = {"shape_id": "shp-1"} # no trip_id + result = compute_stop_status(rh, _pos(0.0), prev_state=None) + assert result[vehicle_stop_status.CURRENT_STATUS] == "IN_TRANSIT_TO" + + def test_missing_lat_lon_fallback(self): + rh = _run_hash() # valid, but get_shape_geometry will ORM-fail → fallback anyway + result = compute_stop_status(rh, {}, prev_state=None) + assert result[vehicle_stop_status.CURRENT_STATUS] == "IN_TRANSIT_TO" From 7b078896431dd479e848665785b3b39ced385457 Mon Sep 17 00:00:00 2001 From: Jae Date: Tue, 9 Jun 2026 20:14:08 -0600 Subject: [PATCH 21/23] refactor(schedule_engine): builder reads stop_time_updates projection; add placeholder producer --- backend/realtime_engine/mqtt.py | 10 + backend/runs/domain/progression/stop_times.py | 85 +++++++++ .../tests/test_stop_times_producer.py | 176 ++++++++++++++++++ backend/schedule_engine/builders.py | 25 +-- .../schedule_engine/tests/test_builders.py | 75 +++++++- 5 files changed, 357 insertions(+), 14 deletions(-) create mode 100644 backend/runs/domain/progression/stop_times.py create mode 100644 backend/runs/domain/progression/tests/test_stop_times_producer.py diff --git a/backend/realtime_engine/mqtt.py b/backend/realtime_engine/mqtt.py index 9a59f19..f52b8c4 100644 --- a/backend/realtime_engine/mqtt.py +++ b/backend/realtime_engine/mqtt.py @@ -94,6 +94,16 @@ def _handle_telemetry(vehicle_id: str, leaf: str, payload_bytes: bytes) -> None: vehicle_id, run_id, ) + # Seam: compute and cache the stop-time-updates projection. + try: + from runs.domain.progression.stop_times import produce_stop_times + produce_stop_times(run_id, vehicle_id) + except Exception: + logger.exception( + "stop-time-updates production failed for vehicle %s run %s", + vehicle_id, + run_id, + ) elif leaf == "occupancy": # occupancy_status is server policy — discard any edge-sent value and diff --git a/backend/runs/domain/progression/stop_times.py b/backend/runs/domain/progression/stop_times.py new file mode 100644 index 0000000..00909e7 --- /dev/null +++ b/backend/runs/domain/progression/stop_times.py @@ -0,0 +1,85 @@ +"""Redis glue for the server-side stop-time-updates producer (seam / placeholder). + +This module is the impure counterpart to the pure computation in +``schedule_engine/fake_stop_times.py``. It reads from Redis, delegates to the +existing fake builder, maps the fake output to the typed contract, and writes the +projection back to Redis as a JSON string under ``run::stop_time_updates``. + +Called by ``realtime_engine/mqtt.py`` after every successful position write so +that ``run::stop_time_updates`` is kept current for the GTFS-RT builder. + +The Redis client mirrors the pattern used in ``producer.py``: module-level client +configured from environment variables. + +Do NOT export ``produce_stop_times`` from ``runs.domain.progression.__init__``. +Import it by full module path:: + + from runs.domain.progression.stop_times import produce_stop_times +""" + +from __future__ import annotations + +import logging +import os + +import redis + +from runs.domain.telemetry import keys, stop_time_updates + +logger = logging.getLogger(__name__) + +# Comfortably above the position-update interval (~1–5 s) so a stalled producer +# expires the projection instead of serving stale arrivals; run lifecycle still +# owns hard cleanup. +STOP_TIME_UPDATES_TTL_S = 60 + +r = redis.Redis( + host=os.getenv("REDIS_HOST", "state"), + port=int(os.getenv("REDIS_PORT", "6379")), + db=0, + decode_responses=True, +) + + +def produce_stop_times(run_id: str, vehicle_id: str) -> None: # noqa: ARG001 + """Derive and write ``run::stop_time_updates`` from the current run state. + + Reads the run hash and the current stop-status progression hash from Redis, + delegates to the fake stop-time builder, maps each fake entry to the typed + contract, and writes the JSON projection back to Redis with a staleness TTL. + + Returns immediately without writing anything if the run hash is absent (nothing + to derive from). + + Parameters + ---------- + run_id: + The active run id (string, as stored in ``vehicle::current_run``). + vehicle_id: + The vehicle id whose position was just updated (reserved for future use). + """ + run_hash = r.hgetall(keys.run_key(run_id)) + if not run_hash: + return + + prev_raw = r.hgetall(keys.stop_status_key(run_id)) + + from schedule_engine.fake_stop_times import build_stop_time_updates + + fake_entries = build_stop_time_updates(run=run_hash, progression=prev_raw) + + # Map fake entries {stop_sequence, stop_id, eta_posix, uncertainty} + # → contract entries {stop_sequence, stop_id, arrival_time, departure_time, uncertainty} + mapped = [ + { + stop_time_updates.STOP_SEQUENCE: entry["stop_sequence"], + stop_time_updates.STOP_ID: entry["stop_id"], + stop_time_updates.ARRIVAL_TIME: entry["eta_posix"], + stop_time_updates.DEPARTURE_TIME: entry["eta_posix"], + stop_time_updates.UNCERTAINTY: entry["uncertainty"], + } + for entry in fake_entries + ] + + payload = stop_time_updates.to_redis(mapped) + r.set(keys.stop_time_updates_key(run_id), payload, ex=STOP_TIME_UPDATES_TTL_S) diff --git a/backend/runs/domain/progression/tests/test_stop_times_producer.py b/backend/runs/domain/progression/tests/test_stop_times_producer.py new file mode 100644 index 0000000..bb571e7 --- /dev/null +++ b/backend/runs/domain/progression/tests/test_stop_times_producer.py @@ -0,0 +1,176 @@ +"""Unit tests for produce_stop_times — monkeypatched Redis, no I/O. + +The module-level ``r`` object in stop_times.py is replaced with a MagicMock so +all tests run entirely in-process without a live Redis instance. + +Patch target: ``runs.domain.progression.stop_times.r`` +""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest + +import runs.domain.progression.stop_times as stop_times_module +from runs.domain.progression.stop_times import STOP_TIME_UPDATES_TTL_S, produce_stop_times +from runs.domain.telemetry import keys, stop_time_updates + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +VEHICLE_ID = "v-42" +RUN_ID = "run-99" + +_RUN_RAW = { + "trip_id": "trip-1", + "route_id": "route-1", + "shape_id": "shape-1", + "vehicle": VEHICLE_ID, +} + +_STOP_STATUS_RAW = { + "current_stop_sequence": "3", + "stop_id": "STOP-42", + "current_status": "IN_TRANSIT_TO", +} + +# Two fake entries that build_stop_time_updates might return +_FAKE_STOP_ENTRIES = [ + {"stop_sequence": 3, "stop_id": "stop-1", "eta_posix": 1700001000, "uncertainty": 120}, + {"stop_sequence": 4, "stop_id": "stop-2", "eta_posix": 1700001300, "uncertainty": 120}, +] + + +def _fake_redis(run_raw=None, stop_status_raw=None) -> MagicMock: + r = MagicMock() + + def hgetall_side_effect(key: str) -> dict: + if key == keys.run_key(RUN_ID): + return run_raw if run_raw is not None else _RUN_RAW + if key == keys.stop_status_key(RUN_ID): + return stop_status_raw if stop_status_raw is not None else {} + return {} + + r.hgetall.side_effect = hgetall_side_effect + return r + + +# --------------------------------------------------------------------------- +# Test 1 — Empty run hash: returns early, no r.set called +# --------------------------------------------------------------------------- + + +def test_returns_early_when_run_hash_is_empty(monkeypatch): + fake_r = _fake_redis(run_raw={}) + monkeypatch.setattr(stop_times_module, "r", fake_r) + + produce_stop_times(RUN_ID, VEHICLE_ID) + + fake_r.set.assert_not_called() + + +# --------------------------------------------------------------------------- +# Test 2 — Happy path: maps fake entries to contract and writes JSON +# --------------------------------------------------------------------------- + + +def test_happy_path_writes_stop_time_updates_key(monkeypatch): + fake_r = _fake_redis() + monkeypatch.setattr(stop_times_module, "r", fake_r) + + with patch( + "schedule_engine.fake_stop_times.build_stop_time_updates", + return_value=_FAKE_STOP_ENTRIES, + ): + produce_stop_times(RUN_ID, VEHICLE_ID) + + fake_r.set.assert_called_once() + call_args = fake_r.set.call_args + written_key = call_args.args[0] if call_args.args else call_args.kwargs.get("name") + assert written_key == keys.stop_time_updates_key(RUN_ID) + + +def test_happy_path_payload_is_valid_json(monkeypatch): + fake_r = _fake_redis() + monkeypatch.setattr(stop_times_module, "r", fake_r) + + with patch( + "schedule_engine.fake_stop_times.build_stop_time_updates", + return_value=_FAKE_STOP_ENTRIES, + ): + produce_stop_times(RUN_ID, VEHICLE_ID) + + payload_str = fake_r.set.call_args.args[1] + parsed = json.loads(payload_str) + assert isinstance(parsed, list) + assert len(parsed) == 2 + + +def test_fake_to_contract_mapping(monkeypatch): + """Each fake entry {eta_posix} must map to contract {arrival_time, departure_time}.""" + fake_r = _fake_redis() + monkeypatch.setattr(stop_times_module, "r", fake_r) + + with patch( + "schedule_engine.fake_stop_times.build_stop_time_updates", + return_value=_FAKE_STOP_ENTRIES, + ): + produce_stop_times(RUN_ID, VEHICLE_ID) + + payload_str = fake_r.set.call_args.args[1] + entries = stop_time_updates.from_redis(payload_str) + assert len(entries) == 2 + # Verify the eta_posix → arrival_time / departure_time mapping + assert entries[0][stop_time_updates.ARRIVAL_TIME] == 1700001000 + assert entries[0][stop_time_updates.DEPARTURE_TIME] == 1700001000 + assert entries[0][stop_time_updates.STOP_SEQUENCE] == 3 + assert entries[0][stop_time_updates.STOP_ID] == "stop-1" + assert entries[0][stop_time_updates.UNCERTAINTY] == 120 + + +def test_writes_with_ttl(monkeypatch): + """r.set must be called with ex=STOP_TIME_UPDATES_TTL_S.""" + fake_r = _fake_redis() + monkeypatch.setattr(stop_times_module, "r", fake_r) + + with patch( + "schedule_engine.fake_stop_times.build_stop_time_updates", + return_value=_FAKE_STOP_ENTRIES, + ): + produce_stop_times(RUN_ID, VEHICLE_ID) + + call_kwargs = fake_r.set.call_args.kwargs + assert call_kwargs.get("ex") == STOP_TIME_UPDATES_TTL_S + + +# --------------------------------------------------------------------------- +# Test 3 — Empty fake entries: writes empty JSON array +# --------------------------------------------------------------------------- + + +def test_empty_fake_entries_writes_empty_array(monkeypatch): + fake_r = _fake_redis() + monkeypatch.setattr(stop_times_module, "r", fake_r) + + with patch( + "schedule_engine.fake_stop_times.build_stop_time_updates", + return_value=[], + ): + produce_stop_times(RUN_ID, VEHICLE_ID) + + fake_r.set.assert_called_once() + payload_str = fake_r.set.call_args.args[1] + assert json.loads(payload_str) == [] + + +# --------------------------------------------------------------------------- +# Test 4 — TTL constant is 60 +# --------------------------------------------------------------------------- + + +def test_ttl_constant_is_60(): + assert STOP_TIME_UPDATES_TTL_S == 60 diff --git a/backend/schedule_engine/builders.py b/backend/schedule_engine/builders.py index bbe306e..8af9acc 100644 --- a/backend/schedule_engine/builders.py +++ b/backend/schedule_engine/builders.py @@ -20,12 +20,11 @@ keys, occupancy, position, + stop_time_updates, trip, vehicle_stop_status, ) -from .fake_stop_times import build_stop_time_updates - # --------------------------------------------------------------------------- # Shared helpers @@ -197,22 +196,24 @@ def build_trip_update_entity(r, run_id: str) -> dict | None: tu["vehicle"]["license_plate"] = meta["license_plate"] # --- stop time updates ---------------------------------------------- - # Pass the raw stop_status hash as `progression=`; fake_stop_times reads - # current_stop_sequence and current_status from it — which this hash provides. - stop_time_updates = build_stop_time_updates(run=run, progression=stop_status_raw) + # Read the pre-computed projection written by the stop-times producer. + # Missing/empty projection → empty list → honest skip (no stop_time_update + # entries in the feed). + raw = r.get(keys.stop_time_updates_key(run_id)) + entries = stop_time_updates.from_redis(raw) tu["stop_time_update"] = [] - for update in stop_time_updates: + for u in entries: tu["stop_time_update"].append( { - "stop_sequence": update["stop_sequence"], - "stop_id": update["stop_id"], + "stop_sequence": u["stop_sequence"], + "stop_id": u["stop_id"], "arrival": { - "time": update["eta_posix"], - "uncertainty": update["uncertainty"], + "time": u["arrival_time"], + "uncertainty": u["uncertainty"], }, "departure": { - "time": update["eta_posix"], - "uncertainty": update["uncertainty"], + "time": u["departure_time"], + "uncertainty": u["uncertainty"], }, } ) diff --git a/backend/schedule_engine/tests/test_builders.py b/backend/schedule_engine/tests/test_builders.py index a2179a2..a5be11e 100644 --- a/backend/schedule_engine/tests/test_builders.py +++ b/backend/schedule_engine/tests/test_builders.py @@ -18,6 +18,7 @@ from google.protobuf import json_format from google.transit import gtfs_realtime_pb2 as gtfs_rt +from runs.domain.telemetry import keys, stop_time_updates from schedule_engine.builders import ( build_trip_updates_feed, build_vehicle_position_entity, @@ -38,10 +39,11 @@ class FakeRedis: Supports: - ``hgetall(key) -> dict[str, str]`` (empty dict when absent) - ``smembers(key) -> set[str]`` (empty set when absent) + - ``get(key) -> str | None`` (None when absent or value is not a str) """ def __init__(self, data: dict | None = None): - # data maps key -> value where value is either a dict (hash) or set (set) + # data maps key -> value where value is either a dict (hash), set, or str self._data: dict = data or {} def hgetall(self, key: str) -> dict: @@ -52,6 +54,10 @@ def smembers(self, key: str) -> set: val = self._data.get(key, set()) return set(val) if isinstance(val, set) else set() + def get(self, key: str) -> str | None: + val = self._data.get(key) + return val if isinstance(val, str) else None + # --------------------------------------------------------------------------- # Shared seed data helpers @@ -61,6 +67,23 @@ def smembers(self, key: str) -> set: VEHICLE_ID = "vehicle-42" +_STOP_TIME_ENTRY_1 = { + "stop_sequence": 3, + "stop_id": "stop-99", + "arrival_time": 1700001000, + "departure_time": 1700001000, + "uncertainty": 120, +} + +_STOP_TIME_ENTRY_2 = { + "stop_sequence": 4, + "stop_id": "stop-100", + "arrival_time": 1700001300, + "departure_time": 1700001300, + "uncertainty": 120, +} + + def _full_redis_data() -> dict: """Return a FakeRedis data dict seeded with all hashes for one in-progress run.""" return { @@ -105,6 +128,10 @@ def _full_redis_data() -> dict: "stop_id": "stop-99", "current_status": "IN_TRANSIT_TO", }, + # stop-time-updates projection (JSON string, written by stop_times producer) + keys.stop_time_updates_key(RUN_ID): stop_time_updates.to_redis( + [_STOP_TIME_ENTRY_1, _STOP_TIME_ENTRY_2] + ), # vehicle metadata f"vehicle:{VEHICLE_ID}:metadata": { "id": VEHICLE_ID, @@ -431,9 +458,33 @@ def test_timestamp_lifted_from_position(self): def test_stop_time_update_key_present(self): tu = self.feed["entity"][0]["trip_update"] assert "stop_time_update" in tu - # The route_id in test data may not be in the CSV; list may be empty — that's fine. assert isinstance(tu["stop_time_update"], list) + def test_stop_time_update_entries_from_projection(self): + """Entries must come from the seeded projection, reshaped to nested arrival/departure.""" + tu = self.feed["entity"][0]["trip_update"] + updates = tu["stop_time_update"] + assert len(updates) == 2 + # First entry + assert updates[0]["stop_sequence"] == 3 + assert updates[0]["stop_id"] == "stop-99" + assert updates[0]["arrival"]["time"] == 1700001000 + assert updates[0]["arrival"]["uncertainty"] == 120 + assert updates[0]["departure"]["time"] == 1700001000 + assert updates[0]["departure"]["uncertainty"] == 120 + # Second entry + assert updates[1]["stop_sequence"] == 4 + assert updates[1]["stop_id"] == "stop-100" + assert updates[1]["arrival"]["time"] == 1700001300 + + def test_stop_time_update_proto_gate(self): + """The full feed with stop_time_updates must round-trip through proto.""" + assert self.proto_msg is not None + entity = self.proto_msg.entity[0] + assert entity.trip_update.stop_time_update[0].stop_sequence == 3 + assert entity.trip_update.stop_time_update[0].stop_id == "stop-99" + assert entity.trip_update.stop_time_update[0].arrival.time == 1700001000 + def test_skip_when_no_position_and_no_stop_status(self): data = { "runs:in_progress": {RUN_ID}, @@ -461,6 +512,26 @@ def test_trip_fallback_from_flat_run_hash(self): tu = feed["entity"][0]["trip_update"] assert tu["trip"]["trip_id"] == "trip-abc" + def test_missing_projection_yields_no_stop_time_update_entries(self): + """When the stop_time_updates projection key is absent, stop_time_update is [].""" + data = _full_redis_data() + # Remove the projection key + del data[keys.stop_time_updates_key(RUN_ID)] + r = FakeRedis(data) + feed = build_trip_updates_feed(r) + assert len(feed["entity"]) == 1 + tu = feed["entity"][0]["trip_update"] + assert tu["stop_time_update"] == [] + + def test_empty_projection_yields_no_stop_time_update_entries(self): + """When the projection is an empty JSON array, stop_time_update is [].""" + data = _full_redis_data() + data[keys.stop_time_updates_key(RUN_ID)] = stop_time_updates.to_redis([]) + r = FakeRedis(data) + feed = build_trip_updates_feed(r) + tu = feed["entity"][0]["trip_update"] + assert tu["stop_time_update"] == [] + # --------------------------------------------------------------------------- # Feed header From 41de4cd12517109b9905d39a2670119680f3e2cd Mon Sep 17 00:00:00 2001 From: Jae Date: Tue, 9 Jun 2026 23:03:33 -0600 Subject: [PATCH 22/23] feat(progression): use GTFS shape_dist_traveled for polyline cum_dist (haversine fallback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional dists_m param to build_polyline that accepts pre-computed along-shape distances in metres (from Shape.shape_dist_traveled, km→m). Distances are accepted only when usable: same length, no nulls, non-decreasing, and total within [0.5, 2.0]× haversine total; otherwise falls back to cumulative haversine. Thread shape_dists_m through assemble_geometry and load_shape_geometry (fetches shape_dist_traveled from the Shape model). --- backend/runs/domain/progression/shapes.py | 100 +++++++- .../domain/progression/tests/test_shapes.py | 226 ++++++++++++++++++ 2 files changed, 314 insertions(+), 12 deletions(-) diff --git a/backend/runs/domain/progression/shapes.py b/backend/runs/domain/progression/shapes.py index ffd454d..f5b9cb3 100644 --- a/backend/runs/domain/progression/shapes.py +++ b/backend/runs/domain/progression/shapes.py @@ -68,6 +68,7 @@ class ShapeGeometry: def build_polyline( points: list[tuple[float, float, int]], + dists_m: list[float] | None = None, ) -> list[tuple[float, float, float]]: """Convert raw GTFS shape rows to a cumulative-distance polyline. @@ -76,17 +77,27 @@ def build_polyline( points: List of ``(lat, lon, shape_pt_sequence)`` tuples, pre-sorted by ``shape_pt_sequence`` ascending (the caller is responsible for ordering). + dists_m: + Optional parallel list of along-shape distances in **metres** + (converted from GTFS ``shape_dist_traveled`` which is in kilometres: + ``float(km) * 1000.0``). When usable — same length as *points*, no + ``None`` entries, non-decreasing, and its total falls within + ``[0.5, 2.0] × haversine_total`` — these values replace the + haversine-computed cumulative column. The first value is normalised to + 0.0 so the polyline always starts at 0. If *any* check fails the + function falls back silently to cumulative haversine (current + behaviour). Returns ------- - list of ``(lat, lon, cum_dist_m)`` tuples. First point has - ``cum_dist_m = 0.0``; subsequent points add the haversine distance from - the previous point. + list of ``(lat, lon, cum_dist_m)`` tuples. First point always has + ``cum_dist_m = 0.0``. """ if not points: return [] - result: list[tuple[float, float, float]] = [] + # ---- cumulative haversine (always computed; used as fallback) ----------- + haversine_result: list[tuple[float, float, float]] = [] cum = 0.0 prev_lat: float | None = None prev_lon: float | None = None @@ -96,12 +107,56 @@ def build_polyline( cum = 0.0 else: cum += haversine_m(prev_lat, prev_lon, lat, lon) - result.append((lat, lon, cum)) + haversine_result.append((lat, lon, cum)) prev_lat, prev_lon = lat, lon + # ---- decide whether to use provided dists_m ---------------------------- + if dists_m is not None: + use_provided = _validate_dists_m(dists_m, len(points), haversine_result[-1][2]) + else: + use_provided = False + + if not use_provided: + return haversine_result + + # Normalise so first entry is 0.0. + offset = dists_m[0] + result: list[tuple[float, float, float]] = [] + for i, (lat, lon, _seq) in enumerate(points): + result.append((lat, lon, float(dists_m[i]) - offset)) return result +def _validate_dists_m( + dists_m: list[float], + n_points: int, + haversine_total_m: float, +) -> bool: + """Return True iff *dists_m* passes all usability checks.""" + # 1. Same length. + if len(dists_m) != n_points: + return False + + # 2. No None / missing entries. + if any(d is None for d in dists_m): + return False + + # 3. Non-decreasing. + for i in range(1, len(dists_m)): + if float(dists_m[i]) < float(dists_m[i - 1]): + return False + + # 4. Sanity band: total must be within [0.5, 2.0] × haversine total. + # Guard against edge case of a single-point polyline (total == 0). + total = float(dists_m[-1]) - float(dists_m[0]) + if haversine_total_m > 0.0: + ratio = total / haversine_total_m + if not (0.5 <= ratio <= 2.0): + return False + + return True + + def build_stops( stop_rows: list[dict], polyline: list[tuple[float, float, float]], @@ -141,6 +196,7 @@ def assemble_geometry( trip_id: str, shape_points: list[tuple[float, float, int]], stop_rows: list[dict], + shape_dists_m: list[float] | None = None, ) -> ShapeGeometry: """Assemble a ShapeGeometry from raw GTFS rows. @@ -155,12 +211,14 @@ def assemble_geometry( stop_rows: List of dicts ``{stop_id, stop_sequence, lat, lon}`` ordered by ``stop_sequence`` ascending. + shape_dists_m: + Optional along-shape distances in metres (see ``build_polyline``). Returns ------- ShapeGeometry (frozen, hashable). """ - polyline = build_polyline(shape_points) + polyline = build_polyline(shape_points, dists_m=shape_dists_m) stops = build_stops(stop_rows, polyline) return ShapeGeometry( shape_id=shape_id, @@ -215,15 +273,33 @@ def load_shape_geometry( shape_qs = ( Shape.objects.filter(feed=feed, shape_id=shape_id) .order_by("shape_pt_sequence") - .values_list("shape_pt_lat", "shape_pt_lon", "shape_pt_sequence") + .values_list( + "shape_pt_lat", "shape_pt_lon", "shape_pt_sequence", + "shape_dist_traveled", + ) ) - shape_points = [ - (float(lat), float(lon), int(seq)) - for lat, lon, seq in shape_qs - ] + shape_points = [] + raw_dists: list[float | None] = [] + for lat, lon, seq, dist in shape_qs: + shape_points.append((float(lat), float(lon), int(seq))) + if dist is None or dist == "": + raw_dists.append(None) + else: + try: + raw_dists.append(float(dist) * 1000.0) # km → m + except (ValueError, TypeError): + raw_dists.append(None) + if not shape_points: return None + # Use shape_dist_traveled only when every point has a value. + shape_dists_m: list[float] | None + if any(d is None for d in raw_dists): + shape_dists_m = None + else: + shape_dists_m = raw_dists # type: ignore[assignment] + # ---- Stop-time rows for the trip ---------------------------------------- stop_time_qs = ( StopTime.objects.filter(feed=feed, trip_id=trip_id) @@ -260,7 +336,7 @@ def load_shape_geometry( if not stop_rows: return None - return assemble_geometry(shape_id, trip_id, shape_points, stop_rows) + return assemble_geometry(shape_id, trip_id, shape_points, stop_rows, shape_dists_m) # --------------------------------------------------------------------------- diff --git a/backend/runs/domain/progression/tests/test_shapes.py b/backend/runs/domain/progression/tests/test_shapes.py index ad596c3..8384508 100644 --- a/backend/runs/domain/progression/tests/test_shapes.py +++ b/backend/runs/domain/progression/tests/test_shapes.py @@ -20,6 +20,7 @@ build_stops, get_shape_geometry, invalidate_cache, + _validate_dists_m, ) @@ -273,3 +274,228 @@ def test_invalidate_cache_clears_all_entries(self, monkeypatch): assert len(_shapes_mod._CACHE) == 1 invalidate_cache() assert len(_shapes_mod._CACHE) == 0 + + +# --------------------------------------------------------------------------- +# build_polyline — shape_dist_traveled (dists_m) parameter (Commit 1) +# --------------------------------------------------------------------------- + + +class TestBuildPolylineWithDists: + """Tests for the optional dists_m parameter introduced in Commit 1.""" + + def _raw(self, n: int = 4) -> list[tuple[float, float, int]]: + """n points along the prime meridian, ~111 km apart.""" + return [(float(i), 0.0, i) for i in range(n)] + + def test_uses_provided_dists_when_usable(self): + """When dists_m is valid, cum_dist_m must come from dists_m, not haversine.""" + raw = self._raw(4) + # Provide distances in a different but valid scale (still within 0.5-2.0× haversine). + haversine_poly = build_polyline(raw) + haversine_total = haversine_poly[-1][2] + # Scale to ~0.9× haversine to keep within [0.5, 2.0]. + scale = 0.9 + dists_m = [haversine_total * scale * i / 3 for i in range(4)] + result = build_polyline(raw, dists_m=dists_m) + assert result[0][2] == pytest.approx(0.0, abs=1e-9) + assert result[-1][2] == pytest.approx(dists_m[-1] - dists_m[0], rel=1e-9) + + def test_falls_back_when_dists_m_is_none(self): + """None dists_m → haversine cumulative (same as no-arg call).""" + raw = self._raw(3) + result_no_arg = build_polyline(raw) + result_none = build_polyline(raw, dists_m=None) + for a, b in zip(result_no_arg, result_none): + assert a[2] == pytest.approx(b[2], rel=1e-12) + + def test_falls_back_when_wrong_length(self): + """dists_m with wrong length → haversine fallback.""" + raw = self._raw(4) + haversine_poly = build_polyline(raw) + result = build_polyline(raw, dists_m=[0.0, 1000.0]) # length 2, need 4 + for a, b in zip(haversine_poly, result): + assert a[2] == pytest.approx(b[2], rel=1e-12) + + def test_falls_back_when_has_none_entry(self): + """dists_m with a None entry → haversine fallback.""" + raw = self._raw(3) + haversine_poly = build_polyline(raw) + result = build_polyline(raw, dists_m=[0.0, None, 200_000.0]) + for a, b in zip(haversine_poly, result): + assert a[2] == pytest.approx(b[2], rel=1e-12) + + def test_falls_back_when_non_monotonic(self): + """dists_m that decreases → haversine fallback.""" + raw = self._raw(3) + haversine_poly = build_polyline(raw) + # Total is within band but the middle entry goes DOWN. + total = haversine_poly[-1][2] + bad = [0.0, total * 0.8, total * 0.6] # not non-decreasing + result = build_polyline(raw, dists_m=bad) + for a, b in zip(haversine_poly, result): + assert a[2] == pytest.approx(b[2], rel=1e-12) + + def test_falls_back_when_outside_sanity_band(self): + """dists_m total vastly larger than haversine → haversine fallback.""" + raw = self._raw(3) + haversine_poly = build_polyline(raw) + total = haversine_poly[-1][2] + # 3× the haversine total — outside the [0.5, 2.0] band. + bad = [0.0, total * 1.5, total * 3.0] + result = build_polyline(raw, dists_m=bad) + for a, b in zip(haversine_poly, result): + assert a[2] == pytest.approx(b[2], rel=1e-12) + + def test_first_point_always_zero(self): + """Even when dists_m does not start at 0, the result is normalised.""" + raw = self._raw(3) + haversine_poly = build_polyline(raw) + total = haversine_poly[-1][2] + # Start at an arbitrary offset (still within band relative to total). + offset = 500.0 + dists_m = [offset, offset + total * 0.5, offset + total * 0.9] + result = build_polyline(raw, dists_m=dists_m) + assert result[0][2] == pytest.approx(0.0, abs=1e-9) + + def test_validate_dists_m_too_short(self): + assert _validate_dists_m([0.0, 100.0], 3, 200_000.0) is False + + def test_validate_dists_m_passes(self): + assert _validate_dists_m([0.0, 100_000.0, 200_000.0], 3, 200_000.0) is True + + def test_validate_dists_m_ratio_below_band(self): + # dists total = 50 000, haversine = 200 000 → ratio = 0.25 < 0.5 + assert _validate_dists_m([0.0, 25_000.0, 50_000.0], 3, 200_000.0) is False + + def test_validate_dists_m_ratio_above_band(self): + # dists total = 600 000, haversine = 200 000 → ratio = 3.0 > 2.0 + assert _validate_dists_m([0.0, 300_000.0, 600_000.0], 3, 200_000.0) is False + + +# --------------------------------------------------------------------------- +# assign_stops_monotonic + loop-back test (Commit 2) +# --------------------------------------------------------------------------- + + +class TestAssignStopsMonotonic: + """Tests for the DP-based monotonic stop assignment introduced in Commit 2.""" + + def test_empty_stops_returns_empty(self): + from runs.domain.progression.shapes import assign_stops_monotonic + + poly = build_polyline([(0.0, 0.0, 0), (1.0, 0.0, 1), (2.0, 0.0, 2)]) + assert assign_stops_monotonic([], poly) == [] + + def test_single_stop(self): + from runs.domain.progression.shapes import assign_stops_monotonic + + poly = build_polyline([(0.0, 0.0, 0), (1.0, 0.0, 1)]) + stops = [{"stop_id": "A", "stop_sequence": 1, "lat": 0.0, "lon": 0.0}] + result = assign_stops_monotonic(stops, poly) + assert len(result) == 1 + assert result[0] == pytest.approx(0.0, abs=10.0) + + def test_monotonic_straight_shape(self): + """Stops on a straight shape must get monotonically non-decreasing progress_m.""" + from runs.domain.progression.shapes import assign_stops_monotonic + + raw = [(float(i), 0.0, i) for i in range(5)] + poly = build_polyline(raw) + stops = [ + {"stop_id": f"S{i}", "stop_sequence": i, "lat": float(i), "lon": 0.0} + for i in range(5) + ] + result = assign_stops_monotonic(stops, poly) + assert len(result) == 5 + for i in range(1, len(result)): + assert result[i] >= result[i - 1], f"Not monotonic at index {i}: {result}" + + def test_no_segments_edge_case(self): + """Single-point polyline (no segments) — all stops get the same progress.""" + from runs.domain.progression.shapes import assign_stops_monotonic + + poly = build_polyline([(5.0, 10.0, 0)]) + stops = [ + {"stop_id": "A", "stop_sequence": 1, "lat": 5.0, "lon": 10.0}, + {"stop_id": "B", "stop_sequence": 2, "lat": 5.001, "lon": 10.001}, + ] + result = assign_stops_monotonic(stops, poly) + assert len(result) == 2 + assert result[0] == pytest.approx(0.0, abs=1e-9) + assert result[1] == pytest.approx(0.0, abs=1e-9) + + def test_loop_back_shape_dp_assigns_correct_pass(self): + """The key regression test: a shape that doubles back. + + Shape geometry (all on the equator, east-west): + + A(0, 0) ---> B(0, 1) ---> C(0, 2) ---> D(0, 1.5) ---> E(0, 0.5) + + The route goes east to lon=2, then turns around and heads west, + ending near lon=0.5. Crucially, stop S3 is at (0, 0.6), which is + physically close to the EARLY part of the route (near A, lon 0-1) but + is actually on the RETURN leg (segment D→E covers lon 1.5 → 0.5). + + Naive global nearest-segment projection snaps S3 to the first segment + A→B (small progress_m ≈ distance from A to lon=0.6, ~66 700 m). + + DP monotonic assignment must keep S3 on the return leg, yielding a + progress_m larger than S2's progress_m — approximately + A→B + B→C + C→D + along D→E to lon=0.6. + """ + from runs.domain.progression.shapes import assign_stops_monotonic + from runs.domain.progression.geo import project_point_to_polyline + + # Shape points: east then looping back west. + raw = [ + (0.0, 0.0, 0), # A — start + (0.0, 1.0, 1), # B — 1° east + (0.0, 2.0, 2), # C — 2° east (turn-around) + (0.0, 1.5, 3), # D — heading back west + (0.0, 0.5, 4), # E — end (close to start side) + ] + poly = build_polyline(raw) + + # Three stops: + # S1 — at A (start), expected small progress_m. + # S2 — at C (turn-around), expected large progress_m. + # S3 — at (0, 0.6), physically near A→B but must be on return leg D→E. + stops = [ + {"stop_id": "S1", "stop_sequence": 1, "lat": 0.0, "lon": 0.0}, + {"stop_id": "S2", "stop_sequence": 2, "lat": 0.0, "lon": 2.0}, + {"stop_id": "S3", "stop_sequence": 3, "lat": 0.0, "lon": 0.6}, + ] + + dp_result = assign_stops_monotonic(stops, poly) + + # 1. Three values returned. + assert len(dp_result) == 3 + + # 2. Monotonically non-decreasing. + for i in range(1, len(dp_result)): + assert dp_result[i] >= dp_result[i - 1], ( + f"DP result not monotonic: {dp_result}" + ) + + # 3. S1 is near the start — progress_m should be small (< 50 km). + assert dp_result[0] < 50_000.0, f"S1 progress too large: {dp_result[0]}" + + # 4. S2 is at the turn-around — progress_m ≈ haversine A→B→C ≈ 222 km. + assert dp_result[1] > 150_000.0, f"S2 progress too small: {dp_result[1]}" + + # 5. S3 must be assigned the RETURN leg, not the outbound. + # Total outbound A→B→C ≈ 222 km; return D→E partial ≈ 55 km past C. + # So S3's progress_m must be > S2's progress_m (it's after S2). + assert dp_result[2] > dp_result[1], ( + f"S3 ({dp_result[2]:.1f} m) should be > S2 ({dp_result[1]:.1f} m)" + ) + + # 6. Contrast: naive global nearest-segment gives a WRONG small value. + naive_proj = project_point_to_polyline(0.0, 0.6, poly) + naive_s3_progress = naive_proj["progress_m"] + # Naive snaps to the first pass (A→B segment), so progress is small. + assert naive_s3_progress < dp_result[2], ( + f"Expected naive ({naive_s3_progress:.1f} m) < DP ({dp_result[2]:.1f} m) " + f"for the loop-back stop" + ) From 353b30cb0473c9f2d3f4c4e4a685c3652345f1c8 Mon Sep 17 00:00:00 2001 From: Jae Date: Tue, 9 Jun 2026 23:06:10 -0600 Subject: [PATCH 23/23] fix(progression): DP-based monotonic stop projection for loop-back shapes Extract project_point_to_segment as a per-segment primitive and refactor project_point_to_polyline to loop over it (identical outputs; existing tests unchanged). Add assign_stops_monotonic: a Viterbi/prefix-min DP that assigns stops in stop_sequence order with a hard forward-monotonic segment constraint, minimising total cross-track distance. Replace per-stop global projection in build_stops with the DP assigner so stops on loop-back routes snap to the correct pass instead of the physically nearest (wrong) early segment. --- backend/runs/domain/progression/geo.py | 124 ++++++++++------- backend/runs/domain/progression/shapes.py | 125 +++++++++++++++++- .../runs/domain/progression/tests/test_geo.py | 76 ++++++++++- 3 files changed, 268 insertions(+), 57 deletions(-) diff --git a/backend/runs/domain/progression/geo.py b/backend/runs/domain/progression/geo.py index 5422952..f8636da 100644 --- a/backend/runs/domain/progression/geo.py +++ b/backend/runs/domain/progression/geo.py @@ -1,8 +1,10 @@ """Pure geometry primitives for map-matching — no I/O, stdlib math only. -Two public functions: - haversine_m — great-circle distance in metres (ported from sim) - project_point_to_polyline — project a GPS point onto a cumulative polyline +Public functions: + haversine_m — great-circle distance in metres (ported from sim) + project_point_to_segment — project a GPS point onto a single polyline segment + project_point_to_polyline — project a GPS point onto a cumulative polyline + (global search; used by compute.py for live vehicles) """ from __future__ import annotations @@ -31,6 +33,71 @@ def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float: return 2 * r * math.asin(math.sqrt(a)) +# --------------------------------------------------------------------------- +# Per-segment primitive +# --------------------------------------------------------------------------- + + +def project_point_to_segment( + lat: float, + lon: float, + seg_start: tuple[float, float, float], + seg_end: tuple[float, float, float], +) -> tuple[float, float]: + """Project a GPS point onto a single polyline segment. + + Uses the same local-equirectangular math as ``project_point_to_polyline`` + (centred on *seg_start*; t clamped to [0, 1]). + + Parameters + ---------- + lat, lon: + Observed WGS-84 position. + seg_start, seg_end: + ``(lat, lon, cum_dist_m)`` tuples as produced by ``build_polyline``. + + Returns + ------- + ``(progress_m, cross_track_m)`` where + + * ``progress_m`` – ``cum_dist_m`` of *seg_start* + along-track distance + from *seg_start* to the foot of perpendicular. + * ``cross_track_m`` – perpendicular distance from the point to the segment + (always >= 0). + + Degenerate segment (length < 1e-9 m): the foot is *seg_start*; + ``cross_track_m`` is the haversine distance from the point to *seg_start*. + """ + lat0, lon0, cum0 = seg_start + lat1, lon1, cum1 = seg_end + + seg_len_m = cum1 - cum0 + if seg_len_m < 1e-9: + d = haversine_m(lat, lon, lat0, lon0) + return (cum0, d) + + cos_lat0 = math.cos(math.radians(lat0)) + + dx_seg = _R * cos_lat0 * math.radians(lon1 - lon0) + dy_seg = _R * math.radians(lat1 - lat0) + + dx_pt = _R * cos_lat0 * math.radians(lon - lon0) + dy_pt = _R * math.radians(lat - lat0) + + seg_len_sq = dx_seg ** 2 + dy_seg ** 2 + t = (dx_pt * dx_seg + dy_pt * dy_seg) / seg_len_sq + t = max(0.0, min(1.0, t)) + + fx = dx_seg * t + fy = dy_seg * t + + cross_m = math.sqrt((dx_pt - fx) ** 2 + (dy_pt - fy) ** 2) + along_m = t * math.sqrt(seg_len_sq) + progress_m = cum0 + along_m + + return (progress_m, cross_m) + + # --------------------------------------------------------------------------- # Point-to-polyline projection # --------------------------------------------------------------------------- @@ -84,54 +151,9 @@ def project_point_to_polyline( best_seg = 0 for i in range(len(polyline) - 1): - lat0, lon0, cum0 = polyline[i] - lat1, lon1, cum1 = polyline[i + 1] - - seg_len_m = cum1 - cum0 - if seg_len_m < 1e-9: - # Degenerate (duplicate) segment — treat as a single point. - d = haversine_m(lat, lon, lat0, lon0) - if d < best_cross_m: - best_cross_m = d - best_progress_m = cum0 - best_seg = i - continue - - # Local equirectangular projection centred on segment start. - # x = R * cos(lat0) * Δlon_rad (easting, metres) - # y = R * Δlat_rad (northing, metres) - cos_lat0 = math.cos(math.radians(lat0)) - - # Segment vector in local metres. - dx_seg = _R * cos_lat0 * math.radians(lon1 - lon0) - dy_seg = _R * math.radians(lat1 - lat0) - - # Point vector relative to segment start, in local metres. - dx_pt = _R * cos_lat0 * math.radians(lon - lon0) - dy_pt = _R * math.radians(lat - lat0) - - # Scalar projection parameter t ∈ [0, 1]. - seg_len_sq = dx_seg ** 2 + dy_seg ** 2 # = seg_len_m² but more numerically clean - t = (dx_pt * dx_seg + dy_pt * dy_seg) / seg_len_sq - t = max(0.0, min(1.0, t)) - - # Foot of perpendicular in local metres. - fx = dx_seg * t - fy = dy_seg * t - - # Perpendicular (cross-track) distance. - cross_m = math.sqrt((dx_pt - fx) ** 2 + (dy_pt - fy) ** 2) - - # Along-track distance from segment start to foot. - along_m = math.sqrt(fx ** 2 + fy ** 2) - - # Signed check: if t=0 the foot is at the start; dot product with the - # point vector confirms along_m direction. Since t is clamped we just - # take along_m = t * seg_len_m to stay consistent with the parameter. - along_m = t * math.sqrt(seg_len_sq) - - progress_m = cum0 + along_m - + progress_m, cross_m = project_point_to_segment( + lat, lon, polyline[i], polyline[i + 1] + ) if cross_m < best_cross_m: best_cross_m = cross_m best_progress_m = progress_m diff --git a/backend/runs/domain/progression/shapes.py b/backend/runs/domain/progression/shapes.py index f5b9cb3..7a80381 100644 --- a/backend/runs/domain/progression/shapes.py +++ b/backend/runs/domain/progression/shapes.py @@ -24,7 +24,11 @@ from dataclasses import dataclass -from runs.domain.progression.geo import haversine_m, project_point_to_polyline +from runs.domain.progression.geo import ( + haversine_m, + project_point_to_polyline, + project_point_to_segment, +) # --------------------------------------------------------------------------- @@ -157,17 +161,128 @@ def _validate_dists_m( return True +def assign_stops_monotonic( + stop_rows: list[dict], + polyline: list[tuple[float, float, float]], +) -> list[float]: + """Assign a ``progress_m`` to each stop using DP monotonic projection. + + Places stops in ``stop_sequence`` order along the polyline with a hard + forward-monotonic constraint (segment index of stop k must be >= segment + index of stop k-1). This correctly handles loop-back / doubling-back + shapes where a late stop is physically close to an early segment — + independent per-stop nearest-segment projection would snap it to the + wrong pass. + + Algorithm — Viterbi / prefix-min recurrence: + + For K stops and M segments (polyline has M+1 points): + + cost[k][j] = cross_track_m of stop k onto segment j + pos[k][j] = progress_m of stop k onto segment j + + DP[0][j] = cost[0][j] + DP[k][j] = cost[k][j] + min_{j' <= j} DP[k-1][j'] + back[k][j] = argmin_{j' <= j} DP[k-1][j'] + + Final clamp enforces non-decreasing progress_m within the same segment + (guards numerical noise when two consecutive stops land on the same segment). + + Parameters + ---------- + stop_rows: + Stops ordered by ``stop_sequence`` ascending. Each dict must have + ``lat`` and ``lon`` keys. + polyline: + Cumulative polyline as returned by ``build_polyline``. + + Returns + ------- + list of ``float`` — one ``progress_m`` per stop, in the same order as + *stop_rows*. Empty list when *stop_rows* is empty. + """ + K = len(stop_rows) + if K == 0: + return [] + + M = len(polyline) - 1 # number of segments + if M <= 0: + # No segments — every stop snaps to the single (or absent) point. + fallback = polyline[0][2] if polyline else 0.0 + return [fallback] * K + + # Pre-compute cost and pos matrices: shape (K, M). + cost: list[list[float]] = [] + pos: list[list[float]] = [] + + for row in stop_rows: + lat, lon = row["lat"], row["lon"] + c_row: list[float] = [] + p_row: list[float] = [] + for j in range(M): + pm, ct = project_point_to_segment(lat, lon, polyline[j], polyline[j + 1]) + c_row.append(ct) + p_row.append(pm) + cost.append(c_row) + pos.append(p_row) + + # DP — O(K * M) using a running prefix minimum to avoid inner O(M) loop. + INF = float("inf") + dp: list[list[float]] = [[INF] * M for _ in range(K)] + back: list[list[int]] = [[-1] * M for _ in range(K)] + + # Initialise first stop. + for j in range(M): + dp[0][j] = cost[0][j] + back[0][j] = j + + # Fill rows k = 1..K-1. + for k in range(1, K): + # Running prefix min of dp[k-1] up to and including j. + prefix_min = INF + prefix_arg = 0 + for j in range(M): + if dp[k - 1][j] < prefix_min: + prefix_min = dp[k - 1][j] + prefix_arg = j + dp[k][j] = cost[k][j] + prefix_min + back[k][j] = prefix_arg + + # Backtrack: find best final segment for the last stop. + best_j = int(min(range(M), key=lambda j: dp[K - 1][j])) + + # Recover segment assignments via backtrack table. + segments: list[int] = [0] * K + segments[K - 1] = best_j + for k in range(K - 2, -1, -1): + segments[k] = back[k + 1][segments[k + 1]] + + # Read off progress_m for each stop from its assigned segment. + progress: list[float] = [pos[k][segments[k]] for k in range(K)] + + # Final clamp: enforce non-decreasing (handles same-segment inversions). + for k in range(1, K): + if progress[k] < progress[k - 1]: + progress[k] = progress[k - 1] + + return progress + + def build_stops( stop_rows: list[dict], polyline: list[tuple[float, float, float]], ) -> list[dict]: """Project each stop onto the polyline and return enriched stop dicts. + Uses DP-based monotonic assignment (``assign_stops_monotonic``) to + correctly handle loop-back / doubling-back shapes. + Parameters ---------- stop_rows: List of dicts, each with keys ``stop_id`` (str), ``stop_sequence`` - (int), ``lat`` (float), ``lon`` (float). + (int), ``lat`` (float), ``lon`` (float). Must be ordered by + ``stop_sequence`` ascending. polyline: Cumulative polyline as returned by ``build_polyline``. @@ -176,16 +291,16 @@ def build_stops( List of dicts (same order as input) with the additional key ``progress_m`` (float). """ + progress_list = assign_stops_monotonic(stop_rows, polyline) result = [] - for row in stop_rows: - proj = project_point_to_polyline(row["lat"], row["lon"], polyline) + for row, pm in zip(stop_rows, progress_list): result.append( { "stop_id": row["stop_id"], "stop_sequence": row["stop_sequence"], "lat": row["lat"], "lon": row["lon"], - "progress_m": proj["progress_m"], + "progress_m": pm, } ) return result diff --git a/backend/runs/domain/progression/tests/test_geo.py b/backend/runs/domain/progression/tests/test_geo.py index 43f3356..b7eb28e 100644 --- a/backend/runs/domain/progression/tests/test_geo.py +++ b/backend/runs/domain/progression/tests/test_geo.py @@ -13,7 +13,11 @@ import pytest -from runs.domain.progression.geo import haversine_m, project_point_to_polyline +from runs.domain.progression.geo import ( + haversine_m, + project_point_to_polyline, + project_point_to_segment, +) # --------------------------------------------------------------------------- @@ -162,3 +166,73 @@ def test_progress_non_negative(self): result = project_point_to_polyline(0.5, 0.5, poly) assert result["progress_m"] >= 0.0 assert result["cross_track_m"] >= 0.0 + + +# --------------------------------------------------------------------------- +# project_point_to_segment (Commit 2) +# --------------------------------------------------------------------------- + + +class TestProjectPointToSegment: + """Correctness tests for the per-segment projection primitive.""" + + def _seg(self) -> tuple[tuple, tuple]: + """A 1° E-W segment at the equator: (0,0) → (0,1).""" + from runs.domain.progression.shapes import build_polyline + + poly = build_polyline([(0.0, 0.0, 0), (0.0, 1.0, 1)]) + return poly[0], poly[1] + + def test_point_on_line_gives_zero_cross_track(self): + """A point exactly on the segment midpoint: cross_track ≈ 0.""" + seg_start, seg_end = self._seg() + pm, ct = project_point_to_segment(0.0, 0.5, seg_start, seg_end) + assert ct == pytest.approx(0.0, abs=2.0) + + def test_point_on_line_gives_correct_progress(self): + """Midpoint on a 1° E-W segment: progress_m ≈ half the total length.""" + seg_start, seg_end = self._seg() + total = seg_end[2] + pm, ct = project_point_to_segment(0.0, 0.5, seg_start, seg_end) + assert pm == pytest.approx(total / 2.0, rel=0.01) + + def test_perpendicular_offset_cross_track(self): + """Point 0.01° north of midpoint: cross_track ≈ haversine of 0.01°.""" + seg_start, seg_end = self._seg() + offset_deg = 0.01 + expected_ct = haversine_m(0.0, 0.0, offset_deg, 0.0) + pm, ct = project_point_to_segment(offset_deg, 0.5, seg_start, seg_end) + assert ct == pytest.approx(expected_ct, rel=0.05) + + def test_t_clamp_before_start(self): + """Point 'behind' segment start: foot = seg_start, progress = cum0.""" + seg_start, seg_end = self._seg() + pm, ct = project_point_to_segment(0.0, -0.5, seg_start, seg_end) + assert pm == pytest.approx(seg_start[2], abs=1.0) + + def test_t_clamp_after_end(self): + """Point 'beyond' segment end: foot = seg_end, progress ≈ cum_end.""" + seg_start, seg_end = self._seg() + pm, ct = project_point_to_segment(0.0, 1.5, seg_start, seg_end) + assert pm == pytest.approx(seg_end[2], rel=0.01) + + def test_returns_two_floats(self): + seg_start, seg_end = self._seg() + result = project_point_to_segment(0.0, 0.5, seg_start, seg_end) + assert len(result) == 2 + assert all(isinstance(v, float) for v in result) + + def test_cross_track_non_negative(self): + seg_start, seg_end = self._seg() + _, ct = project_point_to_segment(0.001, 0.3, seg_start, seg_end) + assert ct >= 0.0 + + def test_degenerate_segment_uses_haversine(self): + """Zero-length segment: cross_track = haversine distance to the point.""" + # Both endpoints at the same location. + seg_start = (0.0, 0.0, 0.0) + seg_end = (0.0, 0.0, 0.0) # same → seg_len < 1e-9 + pm, ct = project_point_to_segment(0.0, 0.01, seg_start, seg_end) + expected_ct = haversine_m(0.0, 0.0, 0.0, 0.01) + assert ct == pytest.approx(expected_ct, rel=0.01) + assert pm == pytest.approx(0.0, abs=1e-9)