From 138bc1c31e6f6a1288b429632658c16c01c3d5fb Mon Sep 17 00:00:00 2001 From: Roman Pszonka Date: Wed, 22 Apr 2026 00:47:20 +0100 Subject: [PATCH 1/5] dockerize in a single image --- .env.example | 43 +- Dockerfile.gui | 115 ++++ docker-compose.gui.yml | 182 ++++++ pyproject.toml | 1 + .../core/execution/config_models.py | 13 +- src/openutm_verification/server/main.py | 80 +++ src/openutm_verification/server/runner.py | 20 + tests/test_suite_scenario_merge.py | 11 +- uv.lock | 518 +++++++++--------- web-editor/src/App.tsx | 15 +- web-editor/src/components/ConfigScreen.tsx | 391 +++++++++++++ web-editor/src/components/ScenarioEditor.tsx | 40 +- .../ScenarioEditor/ConfigEditor.tsx | 7 +- .../src/components/ScenarioEditor/Header.tsx | 11 +- .../ScenarioEditor/PropertiesPanel.tsx | 52 +- .../ScenarioEditor/ScenarioInfoPanel.tsx | 8 +- .../hooks/__tests__/useScenarioRunner.test.ts | 25 +- web-editor/src/hooks/useScenarioRunner.ts | 14 +- 18 files changed, 1201 insertions(+), 345 deletions(-) create mode 100644 Dockerfile.gui create mode 100644 docker-compose.gui.yml create mode 100644 web-editor/src/components/ConfigScreen.tsx diff --git a/.env.example b/.env.example index 4e4bec8e..b82f38b5 100644 --- a/.env.example +++ b/.env.example @@ -1,21 +1,52 @@ # OpenUTM Verification Environment Configuration # Copy this file to .env and customize the values for your environment +# Use with: docker compose -f docker-compose.gui.yml --env-file .env up +# ============================================================================= +# Configuration +# ============================================================================= +# Path to YAML config file inside the container +OPENUTM_CONFIG_PATH=/app/config/default.yaml + +# ============================================================================= # Flight Blender Configuration -FLIGHT_BLENDER_URL=http://localhost:8000 +# ============================================================================= +# Flight Blender endpoint URL +# - Local docker: http://host.docker.internal:8000 (macOS/Windows) +# - Local native: http://localhost:8000 +# - Remote: https://blender.example.com +FLIGHT_BLENDER_URL=http://host.docker.internal:8000 +# ============================================================================= # Logging Configuration +# ============================================================================= +# Log level: DEBUG, INFO, WARNING, ERROR LOG_LEVEL=INFO -# Environment -ENVIRONMENT=development - +# ============================================================================= # Docker Configuration +# ============================================================================= +# User ID mapping (for proper file permissions on mounted volumes) +HOST_UID=1000 +HOST_GID=1000 + +# Compose project name COMPOSE_PROJECT_NAME=openutm-verification -# Build Configuration +# BuildKit for faster builds DOCKER_BUILDKIT=1 -UV_COMPILE_BYTECODE=1 # Set to 1 for production builds, 0 for development + +# ============================================================================= +# Build Configuration +# ============================================================================= +# UV settings for Python dependency manager +UV_COMPILE_BYTECODE=1 # Set to 0 for faster builds during development UV_LINK_MODE=copy +# ============================================================================= # Development Configuration +# ============================================================================= +# For development mode (--profile dev): +# Vite backend URL (internal Docker network hostname) +# Usually doesn't need to change +# VITE_BACKEND_URL=http://verification-backend-dev:8989 diff --git a/Dockerfile.gui b/Dockerfile.gui new file mode 100644 index 00000000..33047440 --- /dev/null +++ b/Dockerfile.gui @@ -0,0 +1,115 @@ +# Multi-stage Dockerfile for OpenUTM Verification GUI Mode +# Runs both backend (FastAPI on 8989) and frontend (served from backend) + +# --- UI Builder Stage --- +FROM node:25-slim AS ui-builder +WORKDIR /app/web-editor + +# Install dependencies first for better layer caching +COPY web-editor/package.json web-editor/package-lock.json ./ +RUN npm ci + +# Copy source and build +COPY web-editor/ . +RUN npm run build + +# --- Backend Builder Stage --- +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder + +ARG UV_COMPILE_BYTECODE=1 +ARG UV_LINK_MODE=copy + +# Install build dependencies +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + git \ + pkg-config \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Configure UV +ENV UV_COMPILE_BYTECODE=${UV_COMPILE_BYTECODE} +ENV UV_LINK_MODE=${UV_LINK_MODE} +ENV PYTHONUNBUFFERED=1 + +# Install dependencies first for better caching +COPY pyproject.toml uv.lock ./ +COPY docs ./docs +COPY scenarios ./scenarios + +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-install-project --no-dev + +# Copy application source and install +COPY LICENSE README.md ./ +COPY src ./src +COPY tests ./tests + +RUN uv pip install --no-deps . && rm -f LICENSE + +# --- Production Runtime Stage --- +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS production + +ARG APP_USER=appuser +ARG APP_GROUP=appgrp +ARG UID=1000 +ARG GID=1000 + +# Install minimal runtime dependencies +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + curl \ + tzdata \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Configure environment for GUI server mode +ENV PYTHONUNBUFFERED=1 +ENV TZ=UTC +ENV PATH="/app/.venv/bin:$PATH" +ENV WEB_EDITOR_PATH=/app/web-editor +ENV SCENARIOS_PATH=/app/scenarios +ENV DOCS_PATH=/app/docs +# Configuration and reports paths (typically mounted as volumes) +ENV OPENUTM_CONFIG_PATH=config/default.yaml +ENV REPORTS_DIR=reports + +# Create non-root user +RUN (getent group "${GID}" || groupadd -g "${GID}" "${APP_GROUP}") \ + && useradd -u "${UID}" -g "${GID}" -s /bin/sh -m "${APP_USER}" + +# Copy application from builder +COPY --chown=${UID}:${GID} --from=builder /app /app + +# Copy built UI from ui-builder +COPY --chown=${UID}:${GID} --from=ui-builder /app/web-editor/dist /app/web-editor/dist + +WORKDIR /app + +# Create necessary directories +RUN mkdir -p /app/config /app/reports \ + && chown -R ${UID}:${GID} /app/config /app/reports + +USER ${UID}:${GID} + +# Expose the server port (backend serves both API and frontend) +EXPOSE 8989 + +# Health check for the API +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8989/health || exit 1 + +# Start the server in GUI mode +# The backend serves the built frontend at / (via StaticFiles mount) and API at: +# - /health, /operations, /session/*, /run-scenario*, /stop-scenario +# - /reports (static mounted directory for report access) +# +# Environment variables: +# OPENUTM_CONFIG_PATH: Path to YAML config (default: config/default.yaml) +# FLIGHT_BLENDER_URL: Override Flight Blender endpoint +# LOG_LEVEL: Logging verbosity (DEBUG, INFO, WARNING, ERROR) +ENTRYPOINT ["python", "-m", "uvicorn", "openutm_verification.server.main:app"] +CMD ["--host", "0.0.0.0", "--port", "8989"] diff --git a/docker-compose.gui.yml b/docker-compose.gui.yml new file mode 100644 index 00000000..d6e589e0 --- /dev/null +++ b/docker-compose.gui.yml @@ -0,0 +1,182 @@ +# Docker Compose for OpenUTM Verification GUI Mode +# +# Usage: +# Production mode (built frontend): +# docker compose -f docker-compose.gui.yml up --build +# +# Development mode (hot-reload for both frontend and backend): +# docker compose -f docker-compose.gui.yml --profile dev up --build +# +# Access the GUI at http://localhost:8989 (production) or http://localhost:5173 (dev) + +services: + # ============================================================================= + # Production Service - Single container serving both backend and frontend + # ============================================================================= + verification-gui: + image: openutm/verification-gui:latest + container_name: openutm-verification-gui + profiles: ["", "prod"] + build: + context: . + dockerfile: Dockerfile.gui + args: + UV_COMPILE_BYTECODE: 1 + UV_LINK_MODE: copy + APP_USER: appuser + APP_GROUP: appgrp + UID: ${HOST_UID:-1000} + GID: ${HOST_GID:-1000} + environment: + # Config & Logging + - OPENUTM_CONFIG_PATH=${OPENUTM_CONFIG_PATH:-/app/config/default.yaml} + - PYTHONUNBUFFERED=1 + - TZ=UTC + - LOG_LEVEL=${LOG_LEVEL:-INFO} + # Flight Blender (override endpoint if needed) + - FLIGHT_BLENDER_URL=${FLIGHT_BLENDER_URL:-http://host.docker.internal:8000} + ports: + - "8989:8989" + # Volume mounts: + # - config: YAML config files (read-write so the GUI Settings screen + # can persist edits via PUT /api/config back to default.yaml) + # - reports: Generated reports (read-write, persists across restarts) + # - scenarios: YAML scenario definitions (read-write so users can add/edit scenarios) + # + # For Kubernetes: Use ConfigMap for config, PersistentVolumeClaim for reports + volumes: + - ./config:/app/config:rw + - ./reports:/app/reports:rw + - ./scenarios:/app/scenarios:rw + extra_hosts: + - "host.docker.internal:host-gateway" + deploy: + resources: + limits: + memory: 1G + cpus: '1.0' + reservations: + memory: 256M + cpus: '0.5' + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8989/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + restart: unless-stopped + labels: + - "project=openutm-verification" + - "component=verification-gui" + - "mode=production" + # Access the GUI at http://localhost:8989 + # Reports available at http://localhost:8989/reports/{timestamp}/report.html + + # ============================================================================= + # Development Services - Separate backend and frontend with hot-reload + # ============================================================================= + + # Backend API server with hot-reload (Python) + # Watches src/ for changes and automatically reloads + # Mounts config files for configuration management + # Reports directory is writable so scenario runs persist data + verification-backend-dev: + image: openutm/verification-backend-dev:latest + container_name: openutm-verification-backend-dev + profiles: ["dev"] + build: + context: . + dockerfile: Dockerfile.dev + args: + APP_USER: appuser + APP_GROUP: appgrp + UID: ${HOST_UID:-1000} + GID: ${HOST_GID:-1000} + environment: + # Config & Logging + - OPENUTM_CONFIG_PATH=${OPENUTM_CONFIG_PATH:-/app/config/default.yaml} + - PYTHONUNBUFFERED=1 + - TZ=UTC + - LOG_LEVEL=${LOG_LEVEL:-DEBUG} + # Flight Blender + - FLIGHT_BLENDER_URL=${FLIGHT_BLENDER_URL:-http://host.docker.internal:8000} + # Hot-reload flag + - UVICORN_RELOAD=true + ports: + - "8989:8989" + # Volume mounts for development: + # - src: Python source code (hot-reload via uvicorn --reload) + # - config: YAML config files (read-write so the Settings screen + # can persist edits via PUT /api/config) + # - reports: Generated reports (writable, persists) + # - scenarios: Scenario YAML files (read-write so devs can add/edit scenarios without rebuilding) + # - docs: Documentation (mounted for reference) + volumes: + - ./src:/app/src:ro + - ./config:/app/config:rw + - ./reports:/app/reports:rw + - ./scenarios:/app/scenarios:rw + - ./docs:/app/docs:ro + extra_hosts: + - "host.docker.internal:host-gateway" + command: > + python -m uvicorn openutm_verification.server.main:app + --host 0.0.0.0 --port 8989 --reload + --reload-dir /app/src + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8989/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + restart: unless-stopped + labels: + - "project=openutm-verification" + - "component=verification-backend" + - "mode=development" + # Backend API at http://localhost:8989 + # Reports at http://localhost:8989/reports/{timestamp}/report.html + + # Frontend dev server with hot-reload (React + Vite) + # Proxies all API calls to verification-backend-dev:8989 + # Hot-reloads React components as you edit them + # VITE_BACKEND_URL controls where API requests are proxied to (container networking) + verification-frontend-dev: + image: node:20-slim + container_name: openutm-verification-frontend-dev + profiles: ["dev"] + working_dir: /app + environment: + # Dev environment + - NODE_ENV=development + # Backend URL for Vite proxy (container hostname:port) + - VITE_BACKEND_URL=http://verification-backend-dev:8989 + ports: + - "5173:5173" + # Volume mounts: + # - web-editor: Source code (mutable, hot-reload via Vite) + # - web-editor-node-modules: Persistent node_modules (avoids reinstall) + volumes: + - ./web-editor:/app:rw + - web-editor-node-modules:/app/node_modules + command: sh -c "npm install && npm run dev -- --host 0.0.0.0" + depends_on: + verification-backend-dev: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5173"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + restart: unless-stopped + labels: + - "project=openutm-verification" + - "component=verification-frontend" + - "mode=development" + # Access Vite dev server at http://localhost:5173 + # API requests proxied to backend at http://verification-backend-dev:8989 + +volumes: + web-editor-node-modules: + name: openutm-web-editor-node-modules diff --git a/pyproject.toml b/pyproject.toml index a01f7666..d9c32678 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ dependencies = [ "bluesky-simulator==1.1.0", "rtree==1.4.1", "cam-track-gen @ git+https://github.com/openutm-labs/Canadian-Airspace-Models.git", + "ruamel-yaml>=0.18.6", ] [project.scripts] diff --git a/src/openutm_verification/core/execution/config_models.py b/src/openutm_verification/core/execution/config_models.py index 044c2004..0f0b4733 100644 --- a/src/openutm_verification/core/execution/config_models.py +++ b/src/openutm_verification/core/execution/config_models.py @@ -193,10 +193,21 @@ class AppConfig(StrictBaseModel): def resolve_paths(self, config_file_path: Path) -> None: """Resolve all relative paths in the configuration to absolute paths. + This method accepts either: + - a config file path, e.g. ``/app/config/default.yaml`` + - a project root path, e.g. ``/app`` + Also merges default data_files into each scenario's SuiteScenario, so scenarios have complete paths without needing runtime override logic. """ - base_path = config_file_path.parent + # Historical callers pass project root (runner/cli), while docstrings + # and some uses pass a config file path. Support both to avoid + # incorrect path resolution in containerized runs. + if config_file_path.is_file(): + base_path = config_file_path.parent.parent + else: + base_path = config_file_path + self.data_files.resolve_paths(base_path) # Merge defaults into each scenario BEFORE resolving their paths diff --git a/src/openutm_verification/server/main.py b/src/openutm_verification/server/main.py index af59bea3..54b820c4 100644 --- a/src/openutm_verification/server/main.py +++ b/src/openutm_verification/server/main.py @@ -126,6 +126,86 @@ async def get_operations(runner: SessionManager = Depends(get_session_manager)): return runner.get_available_operations() +@app.get("/api/config") +async def get_config(runner: SessionManager = Depends(get_session_manager)): + """Return the full server config the GUI manages. + + Re-reads from disk first so the GUI's Reload button reflects any + out-of-band edits to the YAML file (e.g. someone editing in an editor + while the server is running). Falls back to the in-memory config if the + reload fails so a transient parse error doesn't break the screen. + """ + try: + await runner.reload_config() + except Exception: # noqa: BLE001 + logger.exception("reload_config during GET /api/config failed; serving in-memory copy") + cfg = runner.config + return { + "version": cfg.version, + "run_id": cfg.run_id, + "config_path": str(runner.config_path), + "flight_blender": cfg.flight_blender.model_dump(), + "opensky": cfg.opensky.model_dump(), + "amqp": cfg.amqp.model_dump() if cfg.amqp else None, + "data_files": cfg.data_files.model_dump(), + "air_traffic_simulator_settings": (cfg.air_traffic_simulator_settings.model_dump() if cfg.air_traffic_simulator_settings else None), + } + + +# Editable top-level keys via PUT /api/config. Other keys (suites, reporting) +# stay read-only because they have non-trivial side effects on path resolution +# and report layout that aren't worth exposing through the GUI right now. +_EDITABLE_CONFIG_KEYS = ( + "flight_blender", + "opensky", + "amqp", + "air_traffic_simulator_settings", + "data_files", +) + + +@app.put("/api/config") +async def put_config( + payload: dict = Body(...), + runner: SessionManager = Depends(get_session_manager), +): + """Persist edited config sections back to the loaded YAML file and reload. + + Only the keys in ``_EDITABLE_CONFIG_KEYS`` are honored. Comments and + formatting in the file are preserved via ruamel.yaml round-trip mode. + """ + from ruamel.yaml import YAML + + config_path = runner.config_path + if not config_path.exists(): + return {"status": "error", "message": f"Config file not found: {config_path}"} + + yaml_rt = YAML() + yaml_rt.preserve_quotes = True + yaml_rt.indent(mapping=2, sequence=4, offset=2) + + with open(config_path, "r", encoding="utf-8") as f: + doc = yaml_rt.load(f) + + applied: list[str] = [] + for key in _EDITABLE_CONFIG_KEYS: + if key in payload and payload[key] is not None: + doc[key] = payload[key] + applied.append(key) + + with open(config_path, "w", encoding="utf-8") as f: + yaml_rt.dump(doc, f) + + logger.info(f"Wrote {applied} to {config_path}; reloading config") + try: + await runner.reload_config() + except Exception as exc: # noqa: BLE001 + logger.exception("Reload after config save failed") + return {"status": "saved_but_reload_failed", "applied": applied, "error": str(exc)} + + return {"status": "saved", "applied": applied, "config_path": str(config_path)} + + @app.post("/session/reset") async def reset_session( config: SessionResetRequest = Body(default_factory=SessionResetRequest, embed=True), diff --git a/src/openutm_verification/server/runner.py b/src/openutm_verification/server/runner.py index cc17e5ea..5fa394f5 100644 --- a/src/openutm_verification/server/runner.py +++ b/src/openutm_verification/server/runner.py @@ -349,6 +349,21 @@ def _load_config(self) -> AppConfig: return config + async def reload_config(self) -> AppConfig: + """Re-read the config file from disk and re-initialize the session. + + Used after the GUI writes new values via PUT /api/config so the + running server picks them up without a restart. + """ + await self.close_session() + self.current_output_dir = None + self.current_timestamp_str = None + self.current_start_time = None + self.config = self._load_config() + ConfigProxy.override(self.config) + await self.initialize_session() + return self.config + def _generate_data(self, data_files: DataFiles): flight_declaration = None telemetry_states = None @@ -783,6 +798,11 @@ async def run_scenario(self, scenario: ScenarioDefinition) -> List[StepResult]: break else: await self.execute_single_step(step) + # An empty scenario (or one whose every step was skipped before any + # `with self.session_context:` block ran) leaves session_context.state + # unset; return an empty list rather than crashing. + if not self.session_context or not self.session_context.state: + return [] return self.session_context.state.steps async def _execute_loop_for_group(self, step: StepDefinition, scenario: ScenarioDefinition) -> List[StepResult]: diff --git a/tests/test_suite_scenario_merge.py b/tests/test_suite_scenario_merge.py index 206deca3..e585fd21 100644 --- a/tests/test_suite_scenario_merge.py +++ b/tests/test_suite_scenario_merge.py @@ -158,7 +158,12 @@ def test_config_loading_merges_defaults(self, tmp_path): "reporting": {"output_dir": "reports"}, } - config_file = tmp_path / "config.yaml" + # Mirror the real layout: project/config/default.yaml referencing + # files at the project root. AppConfig.resolve_paths uses parent.parent + # of the config file as the base for relative data-file paths. + config_dir = tmp_path / "config" + config_dir.mkdir() + config_file = config_dir / "config.yaml" config_file.write_text(yaml.dump(config_data)) config = AppConfig(**config_data) @@ -221,7 +226,9 @@ def test_daa_scenario_config_example(self, tmp_path): "reporting": {"output_dir": "reports"}, } - config_file = tmp_path / "config.yaml" + config_dir = tmp_path / "config" + config_dir.mkdir() + config_file = config_dir / "config.yaml" config_file.write_text(yaml.dump(config_data)) config = AppConfig(**config_data) diff --git a/uv.lock b/uv.lock index ebae1064..b9c474a9 100644 --- a/uv.lock +++ b/uv.lock @@ -2,9 +2,12 @@ version = 1 revision = 3 requires-python = ">=3.12" resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version < '3.14' and sys_platform == 'emscripten'", "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -584,7 +587,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.135.3" +version = "0.136.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -593,18 +596,18 @@ 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/4e/d9/e66315807e41e69e7f6a1b42a162dada2f249c5f06ad3f1a95f84ab336ef/fastapi-0.136.0.tar.gz", hash = "sha256:cf08e067cc66e106e102d9ba659463abfac245200752f8a5b7b1e813de4ff73e", size = 396607, upload-time = "2026-04-16T11:47:13.623Z" } 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/26/a3/0bd5f0cdb0bbc92650e8dc457e9250358411ee5d1b65e42b6632387daf81/fastapi-0.136.0-py3-none-any.whl", hash = "sha256:8793d44ec7378e2be07f8a013cf7f7aa47d6327d0dfe9804862688ec4541a6b4", size = 117556, upload-time = "2026-04-16T11:47:11.922Z" }, ] [[package]] name = "filelock" -version = "3.25.2" +version = "3.29.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, ] [[package]] @@ -766,20 +769,20 @@ wheels = [ [[package]] name = "identify" -version = "2.6.18" +version = "2.6.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" }, + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, ] [[package]] name = "idna" -version = "3.11" +version = "3.12" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/22/12/2948fbe5513d062169bd91f7d7b1cd97bc8894f32946b71fa39f6e63ca0c/idna-3.12.tar.gz", hash = "sha256:724e9952cc9e2bd7550ea784adb098d837ab5267ef67a1ab9cf7846bdbdd8254", size = 194350, upload-time = "2026-04-21T13:32:48.916Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/53/b2/acc33950394b3becb2b664741a0c0889c7ef9f9ffbfa8d47eddb53a50abd/idna-3.12-py3-none-any.whl", hash = "sha256:60ffaa1858fac94c9c124728c24fcde8160f3fb4a7f79aa8cdd33a9d1af60a67", size = 68634, upload-time = "2026-04-21T13:32:47.403Z" }, ] [[package]] @@ -1049,62 +1052,62 @@ wheels = [ [[package]] name = "librt" -version = "0.8.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, - { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, - { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, - { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, - { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, - { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, - { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, - { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, - { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, - { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, - { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, - { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, - { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, - { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, - { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, - { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, - { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, - { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, - { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, - { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, - { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, - { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, - { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, - { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, - { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, - { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, - { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, - { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, - { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, - { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, - { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, - { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, - { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, - { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, - { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, - { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, - { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, +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/bf/90/89ddba8e1c20b0922783cd93ed8e64f34dc05ab59c38a9c7e313632e20ff/librt-0.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b3e3bc363f71bda1639a4ee593cb78f7fbfeacc73411ec0d4c92f00730010a4", size = 68332, upload-time = "2026-04-09T16:05:00.09Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/7aa4da1fb08bdeeb540cb07bfc8207cb32c5c41642f2594dbd0098a0662d/librt-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a09c2f5869649101738653a9b7ab70cf045a1105ac66cbb8f4055e61df78f2d", size = 70581, upload-time = "2026-04-09T16:05:01.213Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/73a2187e1031041e93b7e3a25aae37aa6f13b838c550f7e0f06f66766212/librt-0.9.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ca8e133d799c948db2ab1afc081c333a825b5540475164726dcbf73537e5c2f", size = 203984, upload-time = "2026-04-09T16:05:02.542Z" }, + { url = "https://files.pythonhosted.org/packages/5e/3d/23460d571e9cbddb405b017681df04c142fb1b04cbfce77c54b08e28b108/librt-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:603138ee838ee1583f1b960b62d5d0007845c5c423feb68e44648b1359014e27", size = 215762, upload-time = "2026-04-09T16:05:04.127Z" }, + { url = "https://files.pythonhosted.org/packages/de/1e/42dc7f8ab63e65b20640d058e63e97fd3e482c1edbda3570d813b4d0b927/librt-0.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4003f70c56a5addd6aa0897f200dd59afd3bf7bcd5b3cce46dd21f925743bc2", size = 230288, upload-time = "2026-04-09T16:05:05.883Z" }, + { url = "https://files.pythonhosted.org/packages/dc/08/ca812b6d8259ad9ece703397f8ad5c03af5b5fedfce64279693d3ce4087c/librt-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78042f6facfd98ecb25e9829c7e37cce23363d9d7c83bc5f72702c5059eb082b", size = 224103, upload-time = "2026-04-09T16:05:07.148Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3f/620490fb2fa66ffd44e7f900254bc110ebec8dac6c1b7514d64662570e6f/librt-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a361c9434a64d70a7dbb771d1de302c0cc9f13c0bffe1cf7e642152814b35265", size = 232122, upload-time = "2026-04-09T16:05:08.386Z" }, + { url = "https://files.pythonhosted.org/packages/e9/83/12864700a1b6a8be458cf5d05db209b0d8e94ae281e7ec261dbe616597b4/librt-0.9.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:dd2c7e082b0b92e1baa4da28163a808672485617bc855cc22a2fd06978fa9084", size = 225045, upload-time = "2026-04-09T16:05:09.707Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1b/845d339c29dc7dbc87a2e992a1ba8d28d25d0e0372f9a0a2ecebde298186/librt-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7e6274fd33fc5b2a14d41c9119629d3ff395849d8bcbc80cf637d9e8d2034da8", size = 227372, upload-time = "2026-04-09T16:05:10.942Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fe/277985610269d926a64c606f761d58d3db67b956dbbf40024921e95e7fcb/librt-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5093043afb226ecfa1400120d1ebd4442b4f99977783e4f4f7248879009b227f", size = 248224, upload-time = "2026-04-09T16:05:12.254Z" }, + { url = "https://files.pythonhosted.org/packages/92/1b/ee486d244b8de6b8b5dbaefabe6bfdd4a72e08f6353edf7d16d27114da8d/librt-0.9.0-cp312-cp312-win32.whl", hash = "sha256:9edcc35d1cae9fd5320171b1a838c7da8a5c968af31e82ecc3dff30b4be0957f", size = 55986, upload-time = "2026-04-09T16:05:13.529Z" }, + { url = "https://files.pythonhosted.org/packages/89/7a/ba1737012308c17dc6d5516143b5dce9a2c7ba3474afd54e11f44a4d1ef3/librt-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc2917258e131ae5f958a4d872e07555b51cb7466a43433218061c74ef33745", size = 63260, upload-time = "2026-04-09T16:05:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/e4/01752c113da15127f18f7bf11142f5640038f062407a611c059d0036c6aa/librt-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:90e6d5420fc8a300518d4d2288154ff45005e920425c22cbbfe8330f3f754bd9", size = 53694, upload-time = "2026-04-09T16:05:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d7/1b3e26fffde1452d82f5666164858a81c26ebe808e7ae8c9c88628981540/librt-0.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29b68cd9714531672db62cc54f6e8ff981900f824d13fa0e00749189e13778e", size = 68367, upload-time = "2026-04-09T16:05:17.243Z" }, + { url = "https://files.pythonhosted.org/packages/a5/5b/c61b043ad2e091fbe1f2d35d14795e545d0b56b03edaa390fa1dcee3d160/librt-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d5c8a5929ac325729f6119802070b561f4db793dffc45e9ac750992a4ed4d22", size = 70595, upload-time = "2026-04-09T16:05:18.471Z" }, + { url = "https://files.pythonhosted.org/packages/a3/22/2448471196d8a73370aa2f23445455dc42712c21404081fcd7a03b9e0749/librt-0.9.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:756775d25ec8345b837ab52effee3ad2f3b2dfd6bbee3e3f029c517bd5d8f05a", size = 204354, upload-time = "2026-04-09T16:05:19.593Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5e/39fc4b153c78cfd2c8a2dcb32700f2d41d2312aa1050513183be4540930d/librt-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8f5d00b49818f4e2b1667db994488b045835e0ac16fe2f924f3871bd2b8ac5", size = 216238, upload-time = "2026-04-09T16:05:20.868Z" }, + { url = "https://files.pythonhosted.org/packages/d7/42/bc2d02d0fa7badfa63aa8d6dcd8793a9f7ef5a94396801684a51ed8d8287/librt-0.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c81aef782380f0f13ead670aae01825eb653b44b046aa0e5ebbb79f76ed4aa11", size = 230589, upload-time = "2026-04-09T16:05:22.305Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7b/e2d95cc513866373692aa5edf98080d5602dd07cabfb9e5d2f70df2f25f7/librt-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66b58fed90a545328e80d575467244de3741e088c1af928f0b489ebec3ef3858", size = 224610, upload-time = "2026-04-09T16:05:23.647Z" }, + { url = "https://files.pythonhosted.org/packages/31/d5/6cec4607e998eaba57564d06a1295c21b0a0c8de76e4e74d699e627bd98c/librt-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e78fb7419e07d98c2af4b8567b72b3eaf8cb05caad642e9963465569c8b2d87e", size = 232558, upload-time = "2026-04-09T16:05:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/95/8c/27f1d8d3aaf079d3eb26439bf0b32f1482340c3552e324f7db9dca858671/librt-0.9.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c3786f0f4490a5cd87f1ed6cefae833ad6b1060d52044ce0434a2e85893afd0", size = 225521, upload-time = "2026-04-09T16:05:26.311Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d8/1e0d43b1c329b416017619469b3c3801a25a6a4ef4a1c68332aeaa6f72ca/librt-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8494cfc61e03542f2d381e71804990b3931175a29b9278fdb4a5459948778dc2", size = 227789, upload-time = "2026-04-09T16:05:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/2c/b4/d3d842e88610fcd4c8eec7067b0c23ef2d7d3bff31496eded6a83b0f99be/librt-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07cf11f769831186eeac424376e6189f20ace4f7263e2134bdb9757340d84d4d", size = 248616, upload-time = "2026-04-09T16:05:29.181Z" }, + { url = "https://files.pythonhosted.org/packages/ec/28/527df8ad0d1eb6c8bdfa82fc190f1f7c4cca5a1b6d7b36aeabf95b52d74d/librt-0.9.0-cp313-cp313-win32.whl", hash = "sha256:850d6d03177e52700af605fd60db7f37dcb89782049a149674d1a9649c2138fd", size = 56039, upload-time = "2026-04-09T16:05:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a7/413652ad0d92273ee5e30c000fc494b361171177c83e57c060ecd3c21538/librt-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:a5af136bfba820d592f86c67affcef9b3ff4d4360ac3255e341e964489b48519", size = 63264, upload-time = "2026-04-09T16:05:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0a/92c244309b774e290ddb15e93363846ae7aa753d9586b8aad511c5e6145b/librt-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:4c4d0440a3a8e31d962340c3e1cc3fc9ee7febd34c8d8f770d06adb947779ea5", size = 53728, upload-time = "2026-04-09T16:05:33.31Z" }, + { 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]] @@ -1313,7 +1316,7 @@ wheels = [ [[package]] name = "mypy" -version = "1.20.0" +version = "1.20.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, @@ -1321,37 +1324,37 @@ dependencies = [ { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/b0089fe7fef0a994ae5ee07029ced0526082c6cfaaa4c10d40a10e33b097/mypy-1.20.0.tar.gz", hash = "sha256:eb96c84efcc33f0b5e0e04beacf00129dd963b67226b01c00b9dfc8affb464c3", size = 3815028, upload-time = "2026-03-31T16:55:14.959Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/dd/3afa29b58c2e57c79116ed55d700721c3c3b15955e2b6251dd165d377c0e/mypy-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:002b613ae19f4ac7d18b7e168ffe1cb9013b37c57f7411984abbd3b817b0a214", size = 14509525, upload-time = "2026-03-31T16:55:01.824Z" }, - { url = "https://files.pythonhosted.org/packages/54/eb/227b516ab8cad9f2a13c5e7a98d28cd6aa75e9c83e82776ae6c1c4c046c7/mypy-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9336b5e6712f4adaf5afc3203a99a40b379049104349d747eb3e5a3aa23ac2e", size = 13326469, upload-time = "2026-03-31T16:51:41.23Z" }, - { url = "https://files.pythonhosted.org/packages/57/d4/1ddb799860c1b5ac6117ec307b965f65deeb47044395ff01ab793248a591/mypy-1.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f13b3e41bce9d257eded794c0f12878af3129d80aacd8a3ee0dee51f3a978651", size = 13705953, upload-time = "2026-03-31T16:48:55.69Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b7/54a720f565a87b893182a2a393370289ae7149e4715859e10e1c05e49154/mypy-1.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9804c3ad27f78e54e58b32e7cb532d128b43dbfb9f3f9f06262b821a0f6bd3f5", size = 14710363, upload-time = "2026-03-31T16:53:26.948Z" }, - { url = "https://files.pythonhosted.org/packages/b2/2a/74810274848d061f8a8ea4ac23aaad43bd3d8c1882457999c2e568341c57/mypy-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:697f102c5c1d526bdd761a69f17c6070f9892eebcb94b1a5963d679288c09e78", size = 14947005, upload-time = "2026-03-31T16:50:17.591Z" }, - { url = "https://files.pythonhosted.org/packages/77/91/21b8ba75f958bcda75690951ce6fa6b7138b03471618959529d74b8544e2/mypy-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ecd63f75fdd30327e4ad8b5704bd6d91fc6c1b2e029f8ee14705e1207212489", size = 10880616, upload-time = "2026-03-31T16:52:19.986Z" }, - { url = "https://files.pythonhosted.org/packages/8a/15/3d8198ef97c1ca03aea010cce4f1d4f3bc5d9849e8c0140111ca2ead9fdd/mypy-1.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:f194db59657c58593a3c47c6dfd7bad4ef4ac12dbc94d01b3a95521f78177e33", size = 9813091, upload-time = "2026-03-31T16:53:44.385Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a7/f64ea7bd592fa431cb597418b6dec4a47f7d0c36325fec7ac67bc8402b94/mypy-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b20c8b0fd5877abdf402e79a3af987053de07e6fb208c18df6659f708b535134", size = 14485344, upload-time = "2026-03-31T16:49:16.78Z" }, - { url = "https://files.pythonhosted.org/packages/bb/72/8927d84cfc90c6abea6e96663576e2e417589347eb538749a464c4c218a0/mypy-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:367e5c993ba34d5054d11937d0485ad6dfc60ba760fa326c01090fc256adf15c", size = 13327400, upload-time = "2026-03-31T16:53:08.02Z" }, - { url = "https://files.pythonhosted.org/packages/ab/4a/11ab99f9afa41aa350178d24a7d2da17043228ea10f6456523f64b5a6cf6/mypy-1.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f799d9db89fc00446f03281f84a221e50018fc40113a3ba9864b132895619ebe", size = 13706384, upload-time = "2026-03-31T16:52:28.577Z" }, - { url = "https://files.pythonhosted.org/packages/42/79/694ca73979cfb3535ebfe78733844cd5aff2e63304f59bf90585110d975a/mypy-1.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555658c611099455b2da507582ea20d2043dfdfe7f5ad0add472b1c6238b433f", size = 14700378, upload-time = "2026-03-31T16:48:45.527Z" }, - { url = "https://files.pythonhosted.org/packages/84/24/a022ccab3a46e3d2cdf2e0e260648633640eb396c7e75d5a42818a8d3971/mypy-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:efe8d70949c3023698c3fca1e94527e7e790a361ab8116f90d11221421cd8726", size = 14932170, upload-time = "2026-03-31T16:49:36.038Z" }, - { url = "https://files.pythonhosted.org/packages/d8/9b/549228d88f574d04117e736f55958bd4908f980f9f5700a07aeb85df005b/mypy-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:f49590891d2c2f8a9de15614e32e459a794bcba84693c2394291a2038bbaaa69", size = 10888526, upload-time = "2026-03-31T16:50:59.827Z" }, - { url = "https://files.pythonhosted.org/packages/91/17/15095c0e54a8bc04d22d4ff06b2139d5f142c2e87520b4e39010c4862771/mypy-1.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:76a70bf840495729be47510856b978f1b0ec7d08f257ca38c9d932720bf6b43e", size = 9816456, upload-time = "2026-03-31T16:49:59.537Z" }, - { url = "https://files.pythonhosted.org/packages/4e/0e/6ca4a84cbed9e62384bc0b2974c90395ece5ed672393e553996501625fc5/mypy-1.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0f42dfaab7ec1baff3b383ad7af562ab0de573c5f6edb44b2dab016082b89948", size = 14483331, upload-time = "2026-03-31T16:52:57.999Z" }, - { url = "https://files.pythonhosted.org/packages/7d/c5/5fe9d8a729dd9605064691816243ae6c49fde0bd28f6e5e17f6a24203c43/mypy-1.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:31b5dbb55293c1bd27c0fc813a0d2bb5ceef9d65ac5afa2e58f829dab7921fd5", size = 13342047, upload-time = "2026-03-31T16:54:21.555Z" }, - { url = "https://files.pythonhosted.org/packages/4c/33/e18bcfa338ca4e6b2771c85d4c5203e627d0c69d9de5c1a2cf2ba13320ba/mypy-1.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d11c6f573a5a08f77fad13faff2139f6d0730ebed2cfa9b3d2702671dd7188", size = 13719585, upload-time = "2026-03-31T16:51:53.89Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8d/93491ff7b79419edc7eabf95cb3b3f7490e2e574b2855c7c7e7394ff933f/mypy-1.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d3243c406773185144527f83be0e0aefc7bf4601b0b2b956665608bf7c98a83", size = 14685075, upload-time = "2026-03-31T16:54:04.464Z" }, - { url = "https://files.pythonhosted.org/packages/b5/9d/d924b38a4923f8d164bf2b4ec98bf13beaf6e10a5348b4b137eadae40a6e/mypy-1.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a79c1eba7ac4209f2d850f0edd0a2f8bba88cbfdfefe6fb76a19e9d4fe5e71a2", size = 14919141, upload-time = "2026-03-31T16:54:51.785Z" }, - { url = "https://files.pythonhosted.org/packages/59/98/1da9977016678c0b99d43afe52ed00bb3c1a0c4c995d3e6acca1a6ebb9b4/mypy-1.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:00e047c74d3ec6e71a2eb88e9ea551a2edb90c21f993aefa9e0d2a898e0bb732", size = 11050925, upload-time = "2026-03-31T16:51:30.758Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e3/ba0b7a3143e49a9c4f5967dde6ea4bf8e0b10ecbbcca69af84027160ee89/mypy-1.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:931a7630bba591593dcf6e97224a21ff80fb357e7982628d25e3c618e7f598ef", size = 10001089, upload-time = "2026-03-31T16:49:43.632Z" }, - { url = "https://files.pythonhosted.org/packages/12/28/e617e67b3be9d213cda7277913269c874eb26472489f95d09d89765ce2d8/mypy-1.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:26c8b52627b6552f47ff11adb4e1509605f094e29815323e487fc0053ebe93d1", size = 15534710, upload-time = "2026-03-31T16:52:12.506Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0c/3b5f2d3e45dc7169b811adce8451679d9430399d03b168f9b0489f43adaa/mypy-1.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39362cdb4ba5f916e7976fccecaab1ba3a83e35f60fa68b64e9a70e221bb2436", size = 14393013, upload-time = "2026-03-31T16:54:41.186Z" }, - { url = "https://files.pythonhosted.org/packages/a3/49/edc8b0aa145cc09c1c74f7ce2858eead9329931dcbbb26e2ad40906daa4e/mypy-1.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34506397dbf40c15dc567635d18a21d33827e9ab29014fb83d292a8f4f8953b6", size = 15047240, upload-time = "2026-03-31T16:54:31.955Z" }, - { url = "https://files.pythonhosted.org/packages/42/37/a946bb416e37a57fa752b3100fd5ede0e28df94f92366d1716555d47c454/mypy-1.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555493c44a4f5a1b58d611a43333e71a9981c6dbe26270377b6f8174126a0526", size = 15858565, upload-time = "2026-03-31T16:53:36.997Z" }, - { url = "https://files.pythonhosted.org/packages/2f/99/7690b5b5b552db1bd4ff362e4c0eb3107b98d680835e65823fbe888c8b78/mypy-1.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2721f0ce49cb74a38f00c50da67cb7d36317b5eda38877a49614dc018e91c787", size = 16087874, upload-time = "2026-03-31T16:52:48.313Z" }, - { url = "https://files.pythonhosted.org/packages/aa/76/53e893a498138066acd28192b77495c9357e5a58cc4be753182846b43315/mypy-1.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:47781555a7aa5fedcc2d16bcd72e0dc83eb272c10dd657f9fb3f9cc08e2e6abb", size = 12572380, upload-time = "2026-03-31T16:49:52.454Z" }, - { url = "https://files.pythonhosted.org/packages/76/9c/6dbdae21f01b7aacddc2c0bbf3c5557aa547827fdf271770fe1e521e7093/mypy-1.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c70380fe5d64010f79fb863b9081c7004dd65225d2277333c219d93a10dad4dd", size = 10381174, upload-time = "2026-03-31T16:51:20.179Z" }, - { url = "https://files.pythonhosted.org/packages/21/66/4d734961ce167f0fd8380769b3b7c06dbdd6ff54c2190f3f2ecd22528158/mypy-1.20.0-py3-none-any.whl", hash = "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e", size = 2636365, upload-time = "2026-03-31T16:51:44.911Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" }, + { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" }, + { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" }, + { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" }, + { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" }, + { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" }, + { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" }, + { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" }, + { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" }, + { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, ] [[package]] @@ -1496,6 +1499,7 @@ dependencies = [ { name = "pyyaml" }, { name = "redis" }, { name = "rtree" }, + { name = "ruamel-yaml" }, { name = "shapely" }, { name = "uas-standards" }, { name = "uvicorn", extra = ["standard"] }, @@ -1556,6 +1560,7 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0.2" }, { name = "redis", specifier = "==6.0.0" }, { name = "rtree", specifier = "==1.4.1" }, + { name = "ruamel-yaml", specifier = ">=0.18.6" }, { name = "shapely", specifier = "==2.1.0" }, { name = "uas-standards", specifier = "==4.2.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.38.0" }, @@ -1583,11 +1588,11 @@ dev = [ [[package]] name = "packaging" -version = "26.0" +version = "26.1" 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/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } 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/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, ] [[package]] @@ -1752,11 +1757,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.9.4" +version = "4.9.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, ] [[package]] @@ -1770,7 +1775,7 @@ wheels = [ [[package]] name = "pre-commit" -version = "4.5.1" +version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -1779,9 +1784,9 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] [[package]] @@ -1853,7 +1858,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.12.5" +version = "2.13.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -1861,107 +1866,111 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, ] [[package]] name = "pydantic-core" -version = "2.41.5" +version = "2.46.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" }, + { url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" }, + { url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" }, + { url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, + { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, + { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, + { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, + { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, + { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, + { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, + { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, + { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, + { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, + { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, + { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, + { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, + { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, + { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, + { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, + { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, + { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, + { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, ] [[package]] name = "pydantic-settings" -version = "2.13.1" +version = "2.14.0" 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/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" } 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/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" }, ] [[package]] name = "pydeck" -version = "0.9.1" +version = "0.9.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/ca/40e14e196864a0f61a92abb14d09b3d3da98f94ccb03b49cf51688140dab/pydeck-0.9.1.tar.gz", hash = "sha256:f74475ae637951d63f2ee58326757f8d4f9cd9f2a457cf42950715003e2cb605", size = 3832240, upload-time = "2024-05-10T15:36:21.153Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/df/4e9e7f20f8034a37c6571c93809f6d22388c39978c98d174d656c1a18fd2/pydeck-0.9.2.tar.gz", hash = "sha256:c10d9035e81ead6385264cac8d19402471f6866a15ca1f7df1400f52142bcf87", size = 5849672, upload-time = "2026-04-16T18:30:30.089Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/4c/b888e6cf58bd9db9c93f40d1c6be8283ff49d88919231afe93a6bcf61626/pydeck-0.9.1-py2.py3-none-any.whl", hash = "sha256:b3f75ba0d273fc917094fa61224f3f6076ca8752b93d46faf3bcfd9f9d59b038", size = 6900403, upload-time = "2024-05-10T15:36:17.36Z" }, + { url = "https://files.pythonhosted.org/packages/88/24/b30ee7d723100fd822de1bb4c0adea62f3419884a75a536f35f355d1e7c0/pydeck-0.9.2-py2.py3-none-any.whl", hash = "sha256:8213dfeacc5f6bfe6825f61c8ee34e3850e8a31fc43924379ec98edb34a75b25", size = 11305615, upload-time = "2026-04-16T18:30:28.133Z" }, ] [[package]] @@ -2042,7 +2051,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -2051,9 +2060,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] @@ -2097,15 +2106,15 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/88/815e53084c5079a59df912825a279f41dd2e0df82281770eadc732f5352c/python_discovery-1.2.1.tar.gz", hash = "sha256:180c4d114bff1c32462537eac5d6a332b768242b76b69c0259c7d14b1b680c9e", size = 58457, upload-time = "2026-03-26T22:30:44.496Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/0f/019d3949a40280f6193b62bc010177d4ce702d0fce424322286488569cd3/python_discovery-1.2.1-py3-none-any.whl", hash = "sha256:b6a957b24c1cd79252484d3566d1b49527581d46e789aaf43181005e56201502", size = 31674, upload-time = "2026-03-26T22:30:43.396Z" }, + { 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" }, ] [[package]] @@ -2365,29 +2374,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/50/0a9e7e7afe7339bd5e36911f0ceb15fed51945836ed803ae5afd661057fd/rtree-1.4.1-py3-none-win_arm64.whl", hash = "sha256:3d46f55729b28138e897ffef32f7ce93ac335cb67f9120125ad3742a220800f0", size = 355253, upload-time = "2025-08-13T19:32:00.296Z" }, ] +[[package]] +name = "ruamel-yaml" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, +] + [[package]] name = "ruff" -version = "0.15.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, - { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, - { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, - { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, - { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, - { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, - { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, - { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, - { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, - { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, - { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, - { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, - { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, - { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, - { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, - { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, +version = "0.15.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, + { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, ] [[package]] @@ -2563,38 +2581,38 @@ wheels = [ [[package]] name = "ty" -version = "0.0.29" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/d5/853561de49fae38c519e905b2d8da9c531219608f1fccc47a0fc2c896980/ty-0.0.29.tar.gz", hash = "sha256:e7936cca2f691eeda631876c92809688dbbab68687c3473f526cd83b6a9228d8", size = 5469221, upload-time = "2026-04-05T15:01:21.328Z" } +sdist = { url = "https://files.pythonhosted.org/packages/85/7e/2aa791c9ae7b8cd5024cd4122e92267f664ca954cea3def3211919fa3c1f/ty-0.0.32.tar.gz", hash = "sha256:8743174c5f920f6700a4a0c9de140109189192ba16226884cd50095b43b8a45c", size = 5522294, upload-time = "2026-04-20T19:29:01.626Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/b7/911f9962115acfa24e3b2ec9d4992dd994c38e8769e1b1d7680bb4d28a51/ty-0.0.29-py3-none-linux_armv6l.whl", hash = "sha256:b8a40955f7660d3eaceb0d964affc81b790c0765e7052921a5f861ff8a471c30", size = 10568206, upload-time = "2026-04-05T15:01:19.165Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c3/fcae2167d4c77a97269f92f11d1b43b03617f81de1283d5d05b43432110c/ty-0.0.29-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6b6849adae15b00bbe2d3c5b078967dcb62eba37d38936b8eeb4c81a82d2e3b8", size = 10442530, upload-time = "2026-04-05T15:01:28.471Z" }, - { url = "https://files.pythonhosted.org/packages/97/33/5a6bfa240cfcb9c36046ae2459fa9ea23238d20130d8656ff5ac4d6c012a/ty-0.0.29-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dcdd9b17209788152f7b7ea815eda07989152325052fe690013537cc7904ce49", size = 9915735, upload-time = "2026-04-05T15:01:10.365Z" }, - { url = "https://files.pythonhosted.org/packages/b3/1e/318f45fae232118e81a6306c30f50de42c509c412128d5bd231eab699ffb/ty-0.0.29-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d8ed4789bae78ffaf94462c0d25589a734cab0366b86f2bbcb1bb90e1a7a169", size = 10419748, upload-time = "2026-04-05T15:01:32.375Z" }, - { url = "https://files.pythonhosted.org/packages/a9/a8/5687872e2ab5a0f7dd4fd8456eac31e9381ad4dc74961f6f29965ad4dd91/ty-0.0.29-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:91ec374b8565e0ad0900011c24641ebbef2da51adbd4fb69ff3280c8a7eceb02", size = 10394738, upload-time = "2026-04-05T15:01:06.473Z" }, - { url = "https://files.pythonhosted.org/packages/de/68/015d118097eeb95e6a44c4abce4c0a28b7b9dfb3085b7f0ee48e4f099633/ty-0.0.29-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:298a8d5faa2502d3810bbbb47a030b9455495b9921594206043c785dd61548cf", size = 10910613, upload-time = "2026-04-05T15:01:17.17Z" }, - { url = "https://files.pythonhosted.org/packages/1c/01/47ce3c6c53e0670eadbe80756b167bf80ed6681d1ba57cfde2e8065a13d1/ty-0.0.29-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c8fba1a3524c6109d1e020d92301c79d41bf442fa8d335b9fa366239339cb70", size = 11475750, upload-time = "2026-04-05T15:01:30.461Z" }, - { url = "https://files.pythonhosted.org/packages/c4/cf/e361845b1081c9264ad5b7c963231bab03f2666865a9f2a115c4233f2137/ty-0.0.29-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c48adf88a70d264128c39ee922ed14a947817fced1e93c08c1a89c9244edcde", size = 11190055, upload-time = "2026-04-05T15:01:12.369Z" }, - { url = "https://files.pythonhosted.org/packages/79/12/0fb0857e9a62cb11586e9a712103877bbf717f5fb570d16634408cfdefee/ty-0.0.29-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ce0a7a0e96bc7b42518cd3a1a6a6298ef64ff40ca4614355c1aa807059b5c6f", size = 11020539, upload-time = "2026-04-05T15:01:37.022Z" }, - { url = "https://files.pythonhosted.org/packages/20/36/5a26753802083f80cd125db6c4348ad42b3c982ec36e718e0bf4c18f75e5/ty-0.0.29-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6ac86a05b4a3731d45365ab97780acc7b8146fa62fccb3cbe94fe6546c67a97", size = 10396399, upload-time = "2026-04-05T15:01:26.167Z" }, - { url = "https://files.pythonhosted.org/packages/00/e6/b4e75b5752239ab3ab400f19faef4dbef81d05aab5d3419fda0c062a3765/ty-0.0.29-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6bbbf53141af0f3150bf288d716263f1a3550054e4b3551ca866d38192ba9891", size = 10421461, upload-time = "2026-04-05T15:01:08.367Z" }, - { url = "https://files.pythonhosted.org/packages/c0/21/1084b5b609f9abed62070ec0b31c283a403832a6310c8bbc208bd45ee1e6/ty-0.0.29-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1c9e06b770c1d0ff5efc51e34312390db31d53fcf3088163f413030b42b74f84", size = 10599187, upload-time = "2026-04-05T15:01:23.52Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a1/ce19a2ca717bbcc1ee11378aba52ef70b6ce5b87245162a729d9fdc2360f/ty-0.0.29-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0307fe37e3f000ef1a4ae230bbaf511508a78d24a5e51b40902a21b09d5e6037", size = 11121198, upload-time = "2026-04-05T15:01:15.22Z" }, - { url = "https://files.pythonhosted.org/packages/6b/6b/f1430b279af704321566ce7ec2725d3d8258c2f815ebd93e474c64cd4543/ty-0.0.29-py3-none-win32.whl", hash = "sha256:7a2a898217960a825f8bc0087e1fdbaf379606175e98f9807187221d53a4a8ed", size = 9995331, upload-time = "2026-04-05T15:01:01.32Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ef/3ef01c17785ff9a69378465c7d0faccd48a07b163554db0995e5d65a5a23/ty-0.0.29-py3-none-win_amd64.whl", hash = "sha256:fc1294200226b91615acbf34e0a9ad81caf98c081e9c6a912a31b0a7b603bc3f", size = 11023644, upload-time = "2026-04-05T15:01:04.432Z" }, - { url = "https://files.pythonhosted.org/packages/2c/55/87280a994d6a2d2647c65e12abbc997ed49835794366153c04c4d9304d76/ty-0.0.29-py3-none-win_arm64.whl", hash = "sha256:f9794bbd1bb3ce13f78c191d0c89ae4c63f52c12b6daa0c6fe220b90d019d12c", size = 10428165, upload-time = "2026-04-05T15:01:34.665Z" }, + { url = "https://files.pythonhosted.org/packages/62/eb/1075dc6a49d7acbe2584ae4d5b410c41b1f177a5adcc567e09eca4c69000/ty-0.0.32-py3-none-linux_armv6l.whl", hash = "sha256:dacbc2f6cd698d488ae7436838ff929570455bf94bfa4d9fe57a630c552aff83", size = 10902959, upload-time = "2026-04-20T19:28:31.907Z" }, + { url = "https://files.pythonhosted.org/packages/33/d2/c35fc8bc66e98d1ee9b0f8ed319bf743e450e1f1e997574b178fab75670f/ty-0.0.32-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914bbc4f605ce2a9e2a78982e28fae1d3359a169d141f9dc3b4c7749cd5eca81", size = 10726172, upload-time = "2026-04-20T19:28:44.765Z" }, + { url = "https://files.pythonhosted.org/packages/96/32/c827da3ca480456fb02d8cea68a2609273b6c220fea0be9a4c8d8470b86e/ty-0.0.32-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4787ac9fe1f86b1f3133f5c6732adbe2df5668b50c679ac6e2d98cd284da812f", size = 10163701, upload-time = "2026-04-20T19:28:27.005Z" }, + { url = "https://files.pythonhosted.org/packages/ba/9e/2734478fbdb90c160cb2813a3916a16a2af5c1e231f87d635f6131d781fb/ty-0.0.32-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8ea0a728af99fe40dd744cba6441a2404f80b7f4bde17aa6da393810af5ea57", size = 10656220, upload-time = "2026-04-20T19:29:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/44/9f/0007da2d35e424debe7e9f86ffbc1ab7f60983cfbc5f0411324ab2de5292/ty-0.0.32-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2850561f9b018ae33d7e5bbfa0ac414d3c518513edcffe43877dc9801446b9c5", size = 10696086, upload-time = "2026-04-20T19:28:46.829Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5e/ce5fd4ec803222ae3e69a76d2a2db2eed55e19f5b131702b9789ef45f93d/ty-0.0.32-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b5fa2fb3c614349ee211d36476b49d88c5ef79a687cdb91b2872ad023b94d2f8", size = 11184800, upload-time = "2026-04-20T19:28:42.57Z" }, + { url = "https://files.pythonhosted.org/packages/6c/46/ebcf67a5999421331214aac51a7464db42de2be15bbe929c612a3ed0b039/ty-0.0.32-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b89969307ab2417d41c9be8059dd79feea577234e1e10d35132f5495e0d42c6", size = 11718718, upload-time = "2026-04-20T19:28:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/18/2c/2141c86ed0ce0962b45cefb658a95e734f59759d47f20afdcd9c732910a1/ty-0.0.32-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b59868ede9b1d69a088f0d695df52a0061f95fa7baa1d5e0dc6fc9cf06e1334", size = 11346369, upload-time = "2026-04-20T19:28:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/ed6f772339cf29bd9a46def9d6db5084689eb574ee4d150ff704224c1ed8/ty-0.0.32-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8300caf35345498e9b9b03e550bba03cee8f5f5f8ab4c83c3b1ff1b7403b7d3a", size = 11280714, upload-time = "2026-04-20T19:28:51.516Z" }, + { url = "https://files.pythonhosted.org/packages/da/9b/c6813987edf4816a40e0c8e408b555f97d3f267c7b3a1688c8bbdf65609c/ty-0.0.32-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:583c7094f4574b02f724db924f98b804d1387a0bd9405ecb5e078cc0f47fbcfb", size = 10638806, upload-time = "2026-04-20T19:28:29.651Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d4/0cefcbd2ad0f3d51762ccf58e652ec7da146eb6ae34f87228f6254bbb8be/ty-0.0.32-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e44ebe1bb4143a5628bc4db67ac0dfebe14594af671e4ee66f6f2e983da56501", size = 10726106, upload-time = "2026-04-20T19:29:06.3Z" }, + { url = "https://files.pythonhosted.org/packages/32/ad/2c8a97f91f06311f4367400f7d13534bbda2522c73c99a3e4c0757dff9b8/ty-0.0.32-py3-none-musllinux_1_2_i686.whl", hash = "sha256:06f17ada3e069cba6148342ef88e9929156beca8473e8d4f101b68f66c75643e", size = 10872951, upload-time = "2026-04-20T19:28:34.077Z" }, + { url = "https://files.pythonhosted.org/packages/ba/68/42293f9248106dd51875120971a5cc6ea315c2c4dcfb8e59aa063aa0af26/ty-0.0.32-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e96e60fa556cec04f15d7ea62d2ceee5982bd389233e961ab9fd42304e278175", size = 11363334, upload-time = "2026-04-20T19:28:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/df/92/be9abf4d3e589ad5023e2ea965b93e204ec856420d46adf73c5c36c04678/ty-0.0.32-py3-none-win32.whl", hash = "sha256:2ff2ebb4986b24aebcf1444db7db5ca41b36086040e95eea9f8fb851c11e805c", size = 10260689, upload-time = "2026-04-20T19:28:56.541Z" }, + { url = "https://files.pythonhosted.org/packages/14/61/dc86acea899349d2579cb8419aecedd83dc504d7d6a10df65eef546c8300/ty-0.0.32-py3-none-win_amd64.whl", hash = "sha256:ba7284a4a954b598c1b31500352b3ec1f89bff533825592b5958848226fdc7ee", size = 11255371, upload-time = "2026-04-20T19:28:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/43/01/beffec56d71ca25b343ede63adb076456b5b3e211f1c066452a44cd120b3/ty-0.0.32-py3-none-win_arm64.whl", hash = "sha256:7e10aadbdbda989a7d567ee6a37f8b98d4d542e31e3b190a2879fd581f75d658", size = 10658087, upload-time = "2026-04-20T19:28:59.286Z" }, ] [[package]] name = "types-cffi" -version = "2.0.0.20260402" +version = "2.0.0.20260408" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/85/3896bfcb4e7c32904f762c36ff0afa96d3e39bfce5a95a41635af79c8761/types_cffi-2.0.0.20260402.tar.gz", hash = "sha256:47e1320c009f630c59c55c8e3d2b8c501e280babf52e92f6109cbfb0864ba367", size = 17476, upload-time = "2026-04-02T04:21:09.332Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/67/eb4ef3408fdc0b4e5af38b30c0e6ad4663b41bdae9fb85a9f09a8db61a99/types_cffi-2.0.0.20260408.tar.gz", hash = "sha256:aa8b9c456ab715c079fc655929811f21f331bfb940f4a821987c581bf4e36230", size = 17541, upload-time = "2026-04-08T04:36:03.918Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/26/aacfef05841e31c65f889ae4225c6bce6b84cd5d3882c42a3661030f29ee/types_cffi-2.0.0.20260402-py3-none-any.whl", hash = "sha256:f647a400fba0a31d603479169d82ee5359db79bd1136e41dc7e6489296e3a2b2", size = 20103, upload-time = "2026-04-02T04:21:08.199Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a3/7fbd93ededcc7c77e9e5948b9794161733ebdbf618a27965b1bea0e728a4/types_cffi-2.0.0.20260408-py3-none-any.whl", hash = "sha256:68bd296742b4ff7c0afe3547f50bd0acc55416ecf322ffefd2b7344ef6388a42", size = 20101, upload-time = "2026-04-08T04:36:02.995Z" }, ] [[package]] @@ -2612,20 +2630,20 @@ wheels = [ [[package]] name = "types-python-dateutil" -version = "2.9.0.20260402" +version = "2.9.0.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/30/c5d9efbff5422b20c9551dc5af237d1ab0c3d33729a9b3239a876ca47dd4/types_python_dateutil-2.9.0.20260402.tar.gz", hash = "sha256:a980142b9966713acb382c467e35c5cc4208a2f91b10b8d785a0ae6765df6c0b", size = 16941, upload-time = "2026-04-02T04:18:35.834Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/f3/2427775f80cd5e19a0a71ba8e5ab7645a01a852f43a5fd0ffc24f66338e0/types_python_dateutil-2.9.0.20260408.tar.gz", hash = "sha256:8b056ec01568674235f64ecbcef928972a5fac412f5aab09c516dfa2acfbb582", size = 16981, upload-time = "2026-04-08T04:28:10.995Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/d7/fe753bf8329c8c3c1addcba1d2bf716c33898216757abb24f8b80f82d040/types_python_dateutil-2.9.0.20260402-py3-none-any.whl", hash = "sha256:7827e6a9c93587cc18e766944254d1351a2396262e4abe1510cbbd7601c5e01f", size = 18436, upload-time = "2026-04-02T04:18:34.806Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c6/eeba37bfee282a6a97f889faef9352d6172c6a5088eb9a4daf570d9d748d/types_python_dateutil-2.9.0.20260408-py3-none-any.whl", hash = "sha256:473139d514a71c9d1fbd8bb328974bedcb1cc3dba57aad04ffa4157f483c216f", size = 18437, upload-time = "2026-04-08T04:28:10.095Z" }, ] [[package]] name = "types-pyyaml" -version = "6.0.12.20250915" +version = "6.0.12.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" }, + { 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" }, ] [[package]] @@ -2643,23 +2661,23 @@ wheels = [ [[package]] name = "types-requests" -version = "2.33.0.20260402" +version = "2.33.0.20260408" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/7b/a06527d20af1441d813360b8e0ce152a75b7d8e4aab7c7d0a156f405d7ec/types_requests-2.33.0.20260402.tar.gz", hash = "sha256:1bdd3ada9b869741c5c4b887d2c8b4e38284a1449751823b5ebbccba3eefd9da", size = 23851, upload-time = "2026-04-02T04:19:55.942Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/6a/749dc53a54a3f35842c1f8197b3ca6b54af6d7458a1bfc75f6629b6da666/types_requests-2.33.0.20260408.tar.gz", hash = "sha256:95b9a86376807a216b2fb412b47617b202091c3ea7c078f47cc358d5528ccb7b", size = 23882, upload-time = "2026-04-08T04:34:49.33Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/65/3853bb6bac5ae789dc7e28781154705c27859eccc8e46282c3f36780f5f5/types_requests-2.33.0.20260402-py3-none-any.whl", hash = "sha256:c98372d7124dd5d10af815ee25c013897592ff92af27b27e22c98984102c3254", size = 20739, upload-time = "2026-04-02T04:19:54.955Z" }, + { url = "https://files.pythonhosted.org/packages/90/b8/78fd6c037de4788c040fdd323b3369804400351b7827473920f6c1d03c10/types_requests-2.33.0.20260408-py3-none-any.whl", hash = "sha256:81f31d5ea4acb39f03be7bc8bed569ba6d5a9c5d97e89f45ac43d819b68ca50f", size = 20739, upload-time = "2026-04-08T04:34:48.325Z" }, ] [[package]] name = "types-setuptools" -version = "82.0.0.20260402" +version = "82.0.0.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e9/f8/74f8a76b4311e70772c0df8f2d432040a3b0facd7bcce6b72b0b26e1746b/types_setuptools-82.0.0.20260402.tar.gz", hash = "sha256:63d2b10ba7958396ad79bbc24d2f6311484e452daad4637ffd40407983a27069", size = 44805, upload-time = "2026-04-02T04:17:49.229Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/12/3464b410c50420dd4674fa5fe9d3880711c1dbe1a06f5fe4960ee9067b9e/types_setuptools-82.0.0.20260408.tar.gz", hash = "sha256:036c68caf7e672a699f5ebbf914708d40644c14e05298bc49f7272be91cf43d3", size = 44861, upload-time = "2026-04-08T04:29:33.292Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/e9/22451997f70ac2c5f18dc5f988750c986011fb049d9021767277119e63fa/types_setuptools-82.0.0.20260402-py3-none-any.whl", hash = "sha256:4b9a9f6c3c4c65107a3956ad6a6acbccec38e398ff6d5f78d5df7f103dadb8d6", size = 68429, upload-time = "2026-04-02T04:17:48.11Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e1/46a4fc3ef03aabf5d18bac9df5cf37c6b02c3bddf3e05c3533f4b4588331/types_setuptools-82.0.0.20260408-py3-none-any.whl", hash = "sha256:ece0a215cdfa6463a65fd6f68bd940f39e455729300ddfe61cab1147ed1d2462", size = 68428, upload-time = "2026-04-08T04:29:32.175Z" }, ] [[package]] @@ -2715,15 +2733,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.44.0" +version = "0.45.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/eb/2e/62b0d9a2cfc8b4de6771322dae30f2db76c66dae9ec32e94e176a44ad563/uvicorn-0.45.0.tar.gz", hash = "sha256:3fe650df136c5bd2b9b06efc5980636344a2fbb840e9ddd86437d53144fa335d", size = 87818, upload-time = "2026-04-21T10:43:46.815Z" } 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/c1/88/d0f7512465b166a4e931ccf7e77792be60fb88466a43964c7566cbaff752/uvicorn-0.45.0-py3-none-any.whl", hash = "sha256:2db26f588131aeec7439de00f2dd52d5f210710c1f01e407a52c90b880d1fd4f", size = 69838, upload-time = "2026-04-21T10:43:45.029Z" }, ] [package.optional-dependencies] @@ -2771,7 +2789,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.2.0" +version = "21.2.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -2779,9 +2797,9 @@ dependencies = [ { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" }, + { 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" }, ] [[package]] diff --git a/web-editor/src/App.tsx b/web-editor/src/App.tsx index c4e547d4..6cdb7f12 100644 --- a/web-editor/src/App.tsx +++ b/web-editor/src/App.tsx @@ -1,9 +1,22 @@ +import { useEffect, useState } from 'react' import ScenarioEditor from './components/ScenarioEditor' +import ConfigScreen from './components/ConfigScreen' + +const readRoute = () => + (typeof globalThis.location !== 'undefined' && globalThis.location.hash === '#/config' ? 'config' : 'editor') function App() { + const [route, setRoute] = useState<'editor' | 'config'>(readRoute) + + useEffect(() => { + const handler = () => setRoute(readRoute()) + globalThis.addEventListener('hashchange', handler) + return () => globalThis.removeEventListener('hashchange', handler) + }, []) + return (
- + {route === 'config' ? : }
) } diff --git a/web-editor/src/components/ConfigScreen.tsx b/web-editor/src/components/ConfigScreen.tsx new file mode 100644 index 00000000..74c616b1 --- /dev/null +++ b/web-editor/src/components/ConfigScreen.tsx @@ -0,0 +1,391 @@ +import { useEffect, useState } from 'react'; +import { ArrowLeft, Save, RefreshCw, Loader2, AlertCircle, CheckCircle2 } from 'lucide-react'; +import btnStyles from '../styles/Button.module.css'; + +// Schema mirrors the JSON returned by GET /api/config and the editable subset +// accepted by PUT /api/config. Optional sections may be null on the wire. +interface FlightBlenderAuth { + type: string; + client_id?: string; + client_secret?: string; + audience?: string; + scopes?: string[]; + token_endpoint?: string; + passport_base_url?: string; +} +interface FlightBlender { url: string; auth: FlightBlenderAuth; } +interface OpenSky { auth: FlightBlenderAuth; } +interface AMQP { + url: string; + exchange_name: string; + exchange_type: string; + routing_key: string; + queue_name: string; +} +interface AirTrafficSim { + number_of_aircraft: number; + simulation_duration: number; + single_or_multiple_sensors: 'single' | 'multiple'; + sensor_ids: string[]; + session_ids: string[]; +} +interface DataFiles { + trajectory?: string | null; + simulation?: string | null; + flight_declaration?: string | null; + flight_declaration_via_operational_intent?: string | null; + geo_fence?: string | null; +} +interface FullConfig { + version: string; + run_id: string; + config_path: string; + flight_blender: FlightBlender; + opensky: OpenSky; + amqp: AMQP | null; + air_traffic_simulator_settings: AirTrafficSim | null; + data_files: DataFiles; +} + +const navigateBack = () => { + globalThis.location.hash = ''; +}; + +const labelStyle: React.CSSProperties = { + display: 'block', + fontSize: '12px', + fontWeight: 500, + marginBottom: '4px', + color: 'var(--text-secondary)', +}; + +const inputStyle: React.CSSProperties = { + width: '100%', + padding: '6px 8px', + background: 'var(--bg-tertiary)', + border: '1px solid var(--border-color)', + borderRadius: '4px', + color: 'var(--text-primary)', + fontSize: '13px', + fontFamily: 'inherit', + boxSizing: 'border-box', +}; + +const sectionStyle: React.CSSProperties = { + background: 'var(--bg-secondary)', + border: '1px solid var(--border-color)', + borderRadius: '6px', + padding: '16px 20px', + marginBottom: '16px', +}; + +const sectionTitleStyle: React.CSSProperties = { + fontSize: '14px', + fontWeight: 600, + color: 'var(--text-primary)', + marginTop: 0, + marginBottom: '12px', + paddingBottom: '8px', + borderBottom: '1px solid var(--border-color)', +}; + +const gridTwoCol: React.CSSProperties = { + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: '12px', +}; + +interface FieldProps { + label: string; + value: string | number | undefined | null; + onChange: (v: string) => void; + type?: string; + placeholder?: string; +} +const Field = ({ label, value, onChange, type = 'text', placeholder }: FieldProps) => ( +
+ + onChange(e.target.value)} + /> +
+); + +export default function ConfigScreen() { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null); + + const load = async () => { + setLoading(true); + setMessage(null); + try { + const res = await fetch('/api/config'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data: FullConfig = await res.json(); + setConfig(data); + } catch (err) { + setMessage({ kind: 'err', text: `Failed to load config: ${err}` }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { load(); }, []); + + const save = async () => { + if (!config) return; + setSaving(true); + setMessage(null); + try { + const payload = { + flight_blender: config.flight_blender, + opensky: config.opensky, + amqp: config.amqp, + air_traffic_simulator_settings: config.air_traffic_simulator_settings, + data_files: config.data_files, + }; + const res = await fetch('/api/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const body = await res.json(); + if (!res.ok || body.status === 'error') { + throw new Error(body.message || body.error || `HTTP ${res.status}`); + } + setMessage({ + kind: 'ok', + text: body.status === 'saved' + ? `Saved to ${body.config_path} and reloaded.` + : `Saved but reload failed: ${body.error}`, + }); + } catch (err) { + setMessage({ kind: 'err', text: `Save failed: ${err}` }); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( +
+ Loading server config… +
+ ); + } + + if (!config) { + return ( +
+
{message?.text || 'No config available.'}
+ +
+ ); + } + + const updateFB = (patch: Partial) => + setConfig({ ...config, flight_blender: { ...config.flight_blender, ...patch } }); + const updateFBAuth = (patch: Partial) => + setConfig({ ...config, flight_blender: { ...config.flight_blender, auth: { ...config.flight_blender.auth, ...patch } } }); + const updateOpenSkyAuth = (patch: Partial) => + setConfig({ ...config, opensky: { auth: { ...config.opensky.auth, ...patch } } }); + const updateAMQP = (patch: Partial) => + setConfig({ ...config, amqp: { ...(config.amqp ?? { url: '', exchange_name: '', exchange_type: 'topic', routing_key: '#', queue_name: '' }), ...patch } }); + const updateATS = (patch: Partial) => + setConfig({ + ...config, + air_traffic_simulator_settings: { + ...(config.air_traffic_simulator_settings ?? { + number_of_aircraft: 0, + simulation_duration: 0, + single_or_multiple_sensors: 'single', + sensor_ids: [], + session_ids: [], + }), + ...patch, + }, + }); + const updateDF = (patch: Partial) => + setConfig({ ...config, data_files: { ...config.data_files, ...patch } }); + + const fb = config.flight_blender; + const os = config.opensky; + const amqp = config.amqp; + const ats = config.air_traffic_simulator_settings; + const df = config.data_files; + + return ( +
+
+ +

Server Configuration

+ + {config.config_path} + +
+ {message && ( + + {message.kind === 'ok' ? : } + {message.text} + + )} + + +
+
+ +
+

+ Edits here are written back to the YAML file shown above (comments preserved) and applied + to the running server immediately. Suites and reporting settings are intentionally not + editable from the GUI. +

+ + {/* Flight Blender */} +
+

Flight Blender

+ updateFB({ url: v })} + placeholder="http://host.docker.internal:8000" /> +
+
+ + +
+ updateFBAuth({ audience: v })} /> +
+
+ updateFBAuth({ scopes: v.split(',').map(s => s.trim()).filter(Boolean) })} /> +
+ {fb.auth.type !== 'none' && ( +
+ updateFBAuth({ client_id: v })} /> + updateFBAuth({ client_secret: v })} /> + updateFBAuth({ token_endpoint: v })} /> + updateFBAuth({ passport_base_url: v })} /> +
+ )} +
+ + {/* AMQP */} +
+

AMQP / RabbitMQ

+ updateAMQP({ url: v })} + placeholder="amqp://guest:guest@host.docker.internal:5672/" /> +
+ updateAMQP({ exchange_name: v })} /> + updateAMQP({ exchange_type: v })} /> + updateAMQP({ routing_key: v })} /> + updateAMQP({ queue_name: v })} /> +
+
+ + {/* OpenSky */} +
+

OpenSky Network

+
+
+ + +
+
+ updateOpenSkyAuth({ client_id: v })} /> + updateOpenSkyAuth({ client_secret: v })} /> +
+
+ + {/* Air Traffic Simulator */} +
+

Air Traffic Simulator (defaults)

+
+ updateATS({ number_of_aircraft: Number.parseInt(v, 10) || 0 })} /> + updateATS({ simulation_duration: Number.parseInt(v, 10) || 0 })} /> +
+ + +
+
+ updateATS({ sensor_ids: v.split(',').map(s => s.trim()).filter(Boolean) })} /> + updateATS({ session_ids: v.split(',').map(s => s.trim()).filter(Boolean) })} /> +
+
+ + {/* Data Files */} +
+

Data Files

+ updateDF({ trajectory: v || null })} /> +
+ updateDF({ simulation: v || null })} /> +
+
+ updateDF({ flight_declaration: v || null })} /> +
+
+ updateDF({ flight_declaration_via_operational_intent: v || null })} /> +
+
+ updateDF({ geo_fence: v || null })} /> +
+
+ +
+ Read-only: version {config.version} · run_id {config.run_id} +
+
+
+ ); +} diff --git a/web-editor/src/components/ScenarioEditor.tsx b/web-editor/src/components/ScenarioEditor.tsx index 841b259a..dcb1a3d7 100644 --- a/web-editor/src/components/ScenarioEditor.tsx +++ b/web-editor/src/components/ScenarioEditor.tsx @@ -50,34 +50,29 @@ const MemoizedBottomPanel = React.memo(BottomPanel); const MemoizedHeader = React.memo(Header); const ScenarioEditorContent = () => { - // Default configuration - const getDefaultConfig = (): ScenarioConfig => ({ + // Empty config skeleton. Real values are hydrated from the server's + // /api/config endpoint (which reflects the YAML config). We deliberately + // avoid hard-coded URLs / file paths / sensor IDs here so the GUI never + // ships invented defaults that could overwrite the operator's config on + // /session/reset. + const getEmptyConfig = (): ScenarioConfig => ({ flight_blender: { - url: "http://localhost:8000", + url: "", auth: { type: "none", - audience: "testflight.flightblender.com", - scopes: ["flightblender.write", "flightblender.read"] + audience: "", + scopes: [] } }, - data_files: { - trajectory: "config/bern/trajectory_f1.json", - flight_declaration: "config/bern/flight_declaration.json", - flight_declaration_via_operational_intent: "config/bern/flight_declaration_via_operational_intent.json" - }, - air_traffic_simulator_settings: { - number_of_aircraft: 3, - simulation_duration: 10, - single_or_multiple_sensors: "single", - sensor_ids: ["a0b7d47e5eac45dc8cbaf47e6fe0e558"] - } + data_files: {}, + air_traffic_simulator_settings: {} }); const groupPadding = 40; // Synchronously read autosave state for lazy initialization const [initialState] = useState(() => { - if (typeof window === 'undefined') return { nodes: [], edges: [], desc: "", config: getDefaultConfig(), groups: {}, isDirty: false, isRestored: false }; + if (typeof window === 'undefined') return { nodes: [], edges: [], desc: "", config: getEmptyConfig(), groups: {}, isDirty: false, isRestored: false }; const savedIsDirty = sessionStorage.getItem('editor-is-dirty') === 'true'; const savedScenarioName = sessionStorage.getItem('editor-autosave-scenario-name'); @@ -88,14 +83,14 @@ const ScenarioEditorContent = () => { const nodes = JSON.parse(sessionStorage.getItem('editor-autosave-nodes') || '[]'); const edges = JSON.parse(sessionStorage.getItem('editor-autosave-edges') || '[]'); const desc = sessionStorage.getItem('editor-autosave-description') || ""; - const config = JSON.parse(sessionStorage.getItem('editor-autosave-config') || JSON.stringify(getDefaultConfig())); + const config = JSON.parse(sessionStorage.getItem('editor-autosave-config') || JSON.stringify(getEmptyConfig())); const groups = JSON.parse(sessionStorage.getItem('editor-autosave-groups') || '{}'); return { nodes, edges, desc, config, groups, isDirty: true, isRestored: true }; } catch (e) { console.error("Failed to parse autosave data", e); } } - return { nodes: [], edges: [], desc: "", config: getDefaultConfig(), groups: {}, isDirty: false, isRestored: false }; + return { nodes: [], edges: [], desc: "", config: getEmptyConfig(), groups: {}, isDirty: false, isRestored: false }; }); const reactFlowWrapper = useRef(null); @@ -865,7 +860,6 @@ const ScenarioEditorContent = () => { currentScenarioName || "Interactive Session", onStepComplete, onStepStart, - currentScenarioConfig, operations, currentScenarioGroups, currentScenarioDescription @@ -873,7 +867,6 @@ const ScenarioEditorContent = () => { }, [ runScenario, currentScenarioName, - currentScenarioConfig, currentScenarioGroups, currentScenarioDescription, operations, @@ -1239,7 +1232,6 @@ const ScenarioEditorContent = () => { { setCurrentScenarioName(name); setIsDirty(true); @@ -1248,10 +1240,6 @@ const ScenarioEditorContent = () => { setCurrentScenarioDescription(desc); setIsDirty(true); }} - onUpdateConfig={(config) => { - setCurrentScenarioConfig(config); - setIsDirty(true); - }} onOpenReport={handleOpenReport} /> )} diff --git a/web-editor/src/components/ScenarioEditor/ConfigEditor.tsx b/web-editor/src/components/ScenarioEditor/ConfigEditor.tsx index 625e8ce0..7e9b7b26 100644 --- a/web-editor/src/components/ScenarioEditor/ConfigEditor.tsx +++ b/web-editor/src/components/ScenarioEditor/ConfigEditor.tsx @@ -361,7 +361,7 @@ export const ConfigEditor: React.FC = ({ config, onUpdateConf updateAirTrafficSettings('number_of_aircraft', parseInt(e.target.value))} min="1" max="100" @@ -373,7 +373,7 @@ export const ConfigEditor: React.FC = ({ config, onUpdateConf updateAirTrafficSettings('simulation_duration', parseInt(e.target.value))} min="1" max="3600" @@ -384,9 +384,10 @@ export const ConfigEditor: React.FC = ({ config, onUpdateConf diff --git a/web-editor/src/components/ScenarioEditor/Header.tsx b/web-editor/src/components/ScenarioEditor/Header.tsx index 1a3df910..147ef5a2 100644 --- a/web-editor/src/components/ScenarioEditor/Header.tsx +++ b/web-editor/src/components/ScenarioEditor/Header.tsx @@ -1,4 +1,4 @@ -import { Activity, Moon, Sun, Layout, FilePlus, Save, Play, Loader2, Copy, Square, Layers } from 'lucide-react'; +import { Activity, Moon, Sun, Layout, FilePlus, Save, Play, Loader2, Copy, Square, Layers, Settings } from 'lucide-react'; import styles from '../../styles/Header.module.css'; import btnStyles from '../../styles/Button.module.css'; @@ -74,6 +74,15 @@ export const Header = ({ Stop )} +
); diff --git a/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx b/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx index 2e532cdf..af6120ce 100644 --- a/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx +++ b/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx @@ -498,26 +498,38 @@ export const PropertiesPanel = ({ selectedNode, connectedNodes, allNodes, onClos
) : ( param.isEnum && param.options ? ( - + (() => { + // YAML may store enums by name (e.g. "ACTIVATED") or by value + // (e.g. 2); normalise to the option's value for the { + const inputValue = e.target.value; + let value: unknown = undefined; + if (inputValue !== '') { + const numValue = Number(inputValue); + value = Number.isNaN(numValue) ? inputValue : numValue; + } + onUpdateParameter(selectedNode.id, param.name, value); + }} + > + + {param.options!.map(opt => ( + + ))} + + ); + })() ) : ( void; onUpdateDescription: (description: string) => void; - onUpdateConfig: (config: ScenarioConfig) => void; onOpenReport: () => void; onClose?: () => void; } @@ -24,7 +20,7 @@ const DEFAULT_DOCS_HEIGHT = Math.floor(window.innerHeight * 0.45); const MIN_DOCS_HEIGHT = 80; const MAX_DOCS_HEIGHT_RATIO = 0.8; -export const ScenarioInfoPanel = ({ name, description, config, onUpdateName, onUpdateDescription, onUpdateConfig, onOpenReport, onClose }: ScenarioInfoPanelProps) => { +export const ScenarioInfoPanel = ({ name, description, onUpdateName, onUpdateDescription, onOpenReport, onClose }: ScenarioInfoPanelProps) => { const { sidebarWidth: width, isResizing: isWidthResizing, startResizing: startWidthResize } = useSidebarResize(DEFAULT_WIDTH, 300, 800); const [docsHeight, setDocsHeight] = useState(DEFAULT_DOCS_HEIGHT); @@ -223,8 +219,6 @@ export const ScenarioInfoPanel = ({ name, description, config, onUpdateName, onU placeholder="Describe what this scenario tests..." /> - - )} diff --git a/web-editor/src/hooks/__tests__/useScenarioRunner.test.ts b/web-editor/src/hooks/__tests__/useScenarioRunner.test.ts index 13cb7d2f..3ee925fd 100644 --- a/web-editor/src/hooks/__tests__/useScenarioRunner.test.ts +++ b/web-editor/src/hooks/__tests__/useScenarioRunner.test.ts @@ -2,7 +2,7 @@ import { renderHook, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; import { useScenarioRunner } from '../useScenarioRunner'; import type { Node, Edge } from '@xyflow/react'; -import type { NodeData, ScenarioConfig } from '../../types/scenario'; +import type { NodeData } from '../../types/scenario'; describe('useScenarioRunner', () => { beforeEach(() => { @@ -113,7 +113,7 @@ describe('useScenarioRunner', () => { expect(result.current.isRunning).toBe(false); }); - it('runs scenario with config', async () => { + it('does not send per-scenario config in /session/reset (server config is source of truth)', async () => { const mockEventSource = { onmessage: null as ((event: MessageEvent) => void) | null, onerror: null as ((event: Event) => void) | null, @@ -152,28 +152,15 @@ describe('useScenarioRunner', () => { { id: '1', position: { x: 0, y: 0 }, data: { label: 'Node 1', operationId: 'Class.method', className: 'Class', functionName: 'method', parameters: [] } } ]; const edges: Edge[] = []; - const config: ScenarioConfig = { - flight_blender: { - url: "http://localhost:8000", - auth: { type: "none", audience: "test", scopes: [] } - }, - data_files: {}, - air_traffic_simulator_settings: {} - }; await act(async () => { - await result.current.runScenario(nodes, edges, 'Test Scenario', undefined, undefined, config); + await result.current.runScenario(nodes, edges, 'Test Scenario'); }); - expect(globalThis.fetch).toHaveBeenCalledWith('/session/reset', expect.objectContaining({ - method: 'POST', - headers: { 'Content-Type': 'application/json' } - })); const resetCall = (globalThis.fetch as Mock).mock.calls.find(call => call[0] === '/session/reset'); - if (resetCall) { - const body = JSON.parse(resetCall[1].body); - expect(body.config).toEqual(config); - } + expect(resetCall).toBeDefined(); + const body = JSON.parse(resetCall![1].body); + expect(body).toEqual({}); expect(result.current.isRunning).toBe(false); }); diff --git a/web-editor/src/hooks/useScenarioRunner.ts b/web-editor/src/hooks/useScenarioRunner.ts index 772a483b..7dfb6cad 100644 --- a/web-editor/src/hooks/useScenarioRunner.ts +++ b/web-editor/src/hooks/useScenarioRunner.ts @@ -1,6 +1,6 @@ import { useState, useCallback, useRef } from 'react'; import type { Node, Edge } from '@xyflow/react'; -import type { GroupDefinition, NodeData, Operation, ScenarioConfig } from '../types/scenario'; +import type { GroupDefinition, NodeData, Operation } from '../types/scenario'; import { convertGraphToYaml } from '../utils/scenarioConversion'; export const useScenarioRunner = () => { @@ -35,7 +35,6 @@ export const useScenarioRunner = () => { scenarioName: string, onStepComplete?: (result: { id: string; status: 'success' | 'failure' | 'error' | 'skipped' | 'running' | 'waiting'; result?: unknown }) => void, onStepStart?: (nodeId: string) => void, - config?: ScenarioConfig, operations: Operation[] = [], groups?: Record, description: string = 'Run from Web UI' @@ -78,16 +77,13 @@ export const useScenarioRunner = () => { const runnableNodes = sortedNodes.filter(node => node.data.operationId); // Filter out nodes without operationId try { - // 1. Reset Session and apply configuration - const resetPayload: { config?: ScenarioConfig } = {}; - if (config) { - resetPayload.config = config; - } - + // Reset session — server-side YAML config is the source of truth + // (edited via the Settings screen / PUT /api/config), so we no + // longer send any per-scenario overrides here. const resetResponse = await fetch('/session/reset', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(resetPayload) + body: JSON.stringify({}) }); if (!resetResponse.ok) { From e02f32f63d292871746ec8fd2f297d4c051a369c Mon Sep 17 00:00:00 2001 From: Roman Pszonka Date: Thu, 23 Apr 2026 22:15:04 +0100 Subject: [PATCH 2/5] Refactor cleanup and run scripts for OpenUTM Verification - Simplified cleanup script to focus on stopping containers and removing resources. - Updated run script to streamline options and improve usage clarity. - Enhanced error handling and validation in configuration management. - Improved handling of YAML configuration file updates and validation. - Updated dependencies in lock file to latest versions. - Added utility function to handle numeric input parsing in the ConfigEditor component. Co-authored-by: Copilot --- .env.example | 10 +- Dockerfile | 76 +-- Dockerfile.dev | 74 --- Dockerfile.gui | 115 ---- GUI_QUICKSTART.md | 158 ++++++ Makefile | 76 +++ README.md | 505 ++++-------------- docker-compose.gui.yml | 182 ------- docker-compose.override.yml | 36 -- docker-compose.yml | 116 ++-- pyproject.toml | 3 +- scripts/build.sh | 201 ++----- scripts/cleanup.sh | 276 ++-------- scripts/run.sh | 253 ++------- .../core/execution/config_models.py | 8 +- src/openutm_verification/server/main.py | 58 +- src/openutm_verification/server/runner.py | 7 +- uv.lock | 38 +- .../ScenarioEditor/ConfigEditor.tsx | 15 +- 19 files changed, 626 insertions(+), 1581 deletions(-) delete mode 100644 Dockerfile.dev delete mode 100644 Dockerfile.gui create mode 100644 GUI_QUICKSTART.md create mode 100644 Makefile delete mode 100644 docker-compose.gui.yml delete mode 100644 docker-compose.override.yml diff --git a/.env.example b/.env.example index b82f38b5..194b587f 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ # OpenUTM Verification Environment Configuration # Copy this file to .env and customize the values for your environment -# Use with: docker compose -f docker-compose.gui.yml --env-file .env up +# Use with: docker compose --env-file .env up # ============================================================================= # Configuration @@ -42,11 +42,3 @@ DOCKER_BUILDKIT=1 # UV settings for Python dependency manager UV_COMPILE_BYTECODE=1 # Set to 0 for faster builds during development UV_LINK_MODE=copy - -# ============================================================================= -# Development Configuration -# ============================================================================= -# For development mode (--profile dev): -# Vite backend URL (internal Docker network hostname) -# Usually doesn't need to change -# VITE_BACKEND_URL=http://verification-backend-dev:8989 diff --git a/Dockerfile b/Dockerfile index bcc9d8fd..eff4272b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,61 +1,62 @@ +# Multi-stage Dockerfile for OpenUTM Verification +# Single image: FastAPI backend on :8989 also serves the built React frontend. +# +# For local development, skip Docker and run two processes directly: +# 1. uv run uvicorn openutm_verification.server.main:app --reload --port 8989 +# 2. cd web-editor && npm run dev # Vite on :5173, proxies API to :8989 + # --- UI Builder Stage --- -FROM node:20-slim AS ui-builder +FROM node:25-slim AS ui-builder WORKDIR /app/web-editor + +# Install dependencies first for better layer caching COPY web-editor/package.json web-editor/package-lock.json ./ RUN npm ci + +# Copy source and build COPY web-editor/ . RUN npm run build -# --- Builder Stage --- +# --- Backend Builder Stage --- FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder -# Build arguments ARG UV_COMPILE_BYTECODE=1 ARG UV_LINK_MODE=copy -# Install necessary build-time dependencies for compiling Python extensions +# Install build dependencies RUN apt-get update \ && apt-get install -y --no-install-recommends \ build-essential \ + git \ pkg-config \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* -# Set working directory for the application in the builder image WORKDIR /app -# Configure UV for optimal Docker layer caching and performance +# Configure UV ENV UV_COMPILE_BYTECODE=${UV_COMPILE_BYTECODE} ENV UV_LINK_MODE=${UV_LINK_MODE} ENV PYTHONUNBUFFERED=1 -# --- Dependency Installation Layer --- -# Copy dependency files first for better layer caching +# Install dependencies first for better caching COPY pyproject.toml uv.lock ./ COPY docs ./docs COPY scenarios ./scenarios -# Install project dependencies using uv sync with cache mount for faster builds -# --frozen: ensures reproducible builds from uv.lock -# --no-install-project: skips installing the project itself (we do this later) -# --no-dev: excludes development dependencies for production builds RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen --no-install-project --no-dev -# --- Application Installation Layer --- -# Copy the rest of the application source code +# Copy application source and install COPY LICENSE README.md ./ COPY src ./src COPY tests ./tests -# Install the project itself in the builder stage -# --no-deps: Dependencies are already installed, skip resolution RUN uv pip install --no-deps . && rm -f LICENSE -# --- Production Stage --- +# --- Production Runtime Stage --- FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS production -# Build arguments for production stage ARG APP_USER=appuser ARG APP_GROUP=appgrp ARG UID=1000 @@ -69,43 +70,50 @@ RUN apt-get update \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* -# Configure environment +# Configure environment for GUI server mode ENV PYTHONUNBUFFERED=1 ENV TZ=UTC ENV PATH="/app/.venv/bin:$PATH" ENV WEB_EDITOR_PATH=/app/web-editor ENV SCENARIOS_PATH=/app/scenarios ENV DOCS_PATH=/app/docs +# Configuration and reports paths (typically mounted as volumes) +ENV OPENUTM_CONFIG_PATH=config/default.yaml +ENV REPORTS_DIR=reports -# Create non-root user and group for enhanced security +# Create non-root user RUN (getent group "${GID}" || groupadd -g "${GID}" "${APP_GROUP}") \ && useradd -u "${UID}" -g "${GID}" -s /bin/sh -m "${APP_USER}" -# Copy application artifacts from builder stage +# Copy application from builder COPY --chown=${UID}:${GID} --from=builder /app /app -# Copy UI artifacts from ui-builder stage +# Copy built UI from ui-builder COPY --chown=${UID}:${GID} --from=ui-builder /app/web-editor/dist /app/web-editor/dist -# Set working directory WORKDIR /app -# Create necessary directories with proper permissions +# Create necessary directories RUN mkdir -p /app/config /app/reports \ && chown -R ${UID}:${GID} /app/config /app/reports -# Switch to non-root user USER ${UID}:${GID} -# Expose the server port +# Expose the server port (backend serves both API and frontend) EXPOSE 8989 -# Health check +# Health check for the API HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD python -c "import sys; print('OK'); sys.exit(0)" || exit 1 - -# Define the entrypoint for the container -ENTRYPOINT ["openutm-verify"] - -# Set the default command with arguments -CMD ["--config", "config/default.yaml"] + CMD curl -f http://localhost:8989/health || exit 1 + +# Start the server in GUI mode +# The backend serves the built frontend at / (via StaticFiles mount) and API at: +# - /health, /operations, /session/*, /run-scenario*, /stop-scenario +# - /reports (static mounted directory for report access) +# +# Environment variables: +# OPENUTM_CONFIG_PATH: Path to YAML config (default: config/default.yaml) +# FLIGHT_BLENDER_URL: Override Flight Blender endpoint +# LOG_LEVEL: Logging verbosity (DEBUG, INFO, WARNING, ERROR) +ENTRYPOINT ["python", "-m", "uvicorn", "openutm_verification.server.main:app"] +CMD ["--host", "0.0.0.0", "--port", "8989"] diff --git a/Dockerfile.dev b/Dockerfile.dev deleted file mode 100644 index 435d4b99..00000000 --- a/Dockerfile.dev +++ /dev/null @@ -1,74 +0,0 @@ -# --- UI Builder Stage --- -FROM node:20-slim AS ui-builder -WORKDIR /app/web-editor -COPY web-editor/package.json web-editor/package-lock.json ./ -RUN npm ci -COPY web-editor/ . -RUN npm run build - -# Development Dockerfile for hot reload and debugging -FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS development - -ARG APP_USER=appuser -ARG APP_GROUP=appgrp -ARG UID=1000 -ARG GID=1000 - -# Install development dependencies -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - build-essential \ - curl \ - git \ - htop \ - pkg-config \ - vim \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -# Create non-root user and group -RUN (getent group "${GID}" || groupadd -g "${GID}" "${APP_GROUP}") \ - && useradd -u "${UID}" -g "${GID}" -s /bin/sh -m "${APP_USER}" - -# Set working directory -WORKDIR /app - -# Configure UV for development -ENV UV_COMPILE_BYTECODE=0 -ENV UV_LINK_MODE=copy -ENV PYTHONUNBUFFERED=1 -ENV PYTHONPATH=/app/src -ENV WEB_EDITOR_PATH=/app/web-editor -ENV SCENARIOS_PATH=/app/scenarios -ENV DOCS_PATH=/app/docs - -# Copy dependency files -COPY --chown=${UID}:${GID} LICENSE README.md pyproject.toml uv.lock ./ -COPY --chown=${UID}:${GID} docs ./docs - -# Install all dependencies including dev dependencies -RUN --mount=type=cache,target=/home/${APP_USER}/.cache/uv,uid=${UID},gid=${GID} \ - uv sync --frozen \ - && chown -R ${UID}:${GID} .venv - -# Copy source code -COPY --chown=${UID}:${GID} . . - -# Copy UI artifacts from ui-builder stage -COPY --chown=${UID}:${GID} --from=ui-builder /app/web-editor/dist /app/web-editor/dist - -# Create directories -RUN mkdir -p /app/reports /app/config \ - && chown -R ${UID}:${GID} /app/reports /app/config - -# Expose the server port -EXPOSE 8989 - -# Switch to non-root user -USER ${UID}:${GID} - -# Set executable path -ENV PATH="/app/.venv/bin:$PATH" - -# Default command for development -CMD ["openutm-verify", "--config", "config/default.yaml"] diff --git a/Dockerfile.gui b/Dockerfile.gui deleted file mode 100644 index 33047440..00000000 --- a/Dockerfile.gui +++ /dev/null @@ -1,115 +0,0 @@ -# Multi-stage Dockerfile for OpenUTM Verification GUI Mode -# Runs both backend (FastAPI on 8989) and frontend (served from backend) - -# --- UI Builder Stage --- -FROM node:25-slim AS ui-builder -WORKDIR /app/web-editor - -# Install dependencies first for better layer caching -COPY web-editor/package.json web-editor/package-lock.json ./ -RUN npm ci - -# Copy source and build -COPY web-editor/ . -RUN npm run build - -# --- Backend Builder Stage --- -FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder - -ARG UV_COMPILE_BYTECODE=1 -ARG UV_LINK_MODE=copy - -# Install build dependencies -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - build-essential \ - git \ - pkg-config \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /app - -# Configure UV -ENV UV_COMPILE_BYTECODE=${UV_COMPILE_BYTECODE} -ENV UV_LINK_MODE=${UV_LINK_MODE} -ENV PYTHONUNBUFFERED=1 - -# Install dependencies first for better caching -COPY pyproject.toml uv.lock ./ -COPY docs ./docs -COPY scenarios ./scenarios - -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --frozen --no-install-project --no-dev - -# Copy application source and install -COPY LICENSE README.md ./ -COPY src ./src -COPY tests ./tests - -RUN uv pip install --no-deps . && rm -f LICENSE - -# --- Production Runtime Stage --- -FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS production - -ARG APP_USER=appuser -ARG APP_GROUP=appgrp -ARG UID=1000 -ARG GID=1000 - -# Install minimal runtime dependencies -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - curl \ - tzdata \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -# Configure environment for GUI server mode -ENV PYTHONUNBUFFERED=1 -ENV TZ=UTC -ENV PATH="/app/.venv/bin:$PATH" -ENV WEB_EDITOR_PATH=/app/web-editor -ENV SCENARIOS_PATH=/app/scenarios -ENV DOCS_PATH=/app/docs -# Configuration and reports paths (typically mounted as volumes) -ENV OPENUTM_CONFIG_PATH=config/default.yaml -ENV REPORTS_DIR=reports - -# Create non-root user -RUN (getent group "${GID}" || groupadd -g "${GID}" "${APP_GROUP}") \ - && useradd -u "${UID}" -g "${GID}" -s /bin/sh -m "${APP_USER}" - -# Copy application from builder -COPY --chown=${UID}:${GID} --from=builder /app /app - -# Copy built UI from ui-builder -COPY --chown=${UID}:${GID} --from=ui-builder /app/web-editor/dist /app/web-editor/dist - -WORKDIR /app - -# Create necessary directories -RUN mkdir -p /app/config /app/reports \ - && chown -R ${UID}:${GID} /app/config /app/reports - -USER ${UID}:${GID} - -# Expose the server port (backend serves both API and frontend) -EXPOSE 8989 - -# Health check for the API -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:8989/health || exit 1 - -# Start the server in GUI mode -# The backend serves the built frontend at / (via StaticFiles mount) and API at: -# - /health, /operations, /session/*, /run-scenario*, /stop-scenario -# - /reports (static mounted directory for report access) -# -# Environment variables: -# OPENUTM_CONFIG_PATH: Path to YAML config (default: config/default.yaml) -# FLIGHT_BLENDER_URL: Override Flight Blender endpoint -# LOG_LEVEL: Logging verbosity (DEBUG, INFO, WARNING, ERROR) -ENTRYPOINT ["python", "-m", "uvicorn", "openutm_verification.server.main:app"] -CMD ["--host", "0.0.0.0", "--port", "8989"] diff --git a/GUI_QUICKSTART.md b/GUI_QUICKSTART.md new file mode 100644 index 00000000..513fad94 --- /dev/null +++ b/GUI_QUICKSTART.md @@ -0,0 +1,158 @@ +# GUI Mode Quick Start + +Run the OpenUTM Verification tool with a web-based UI. + +## TL;DR + +```bash +make gui # Single-image Docker GUI on http://localhost:8989 +make gui-stop # Stop the container + +# For active development on the UI / backend (no Docker, two terminals): +make dev-backend # uvicorn --reload on :8989 +make dev-frontend # Vite on :5173, proxies API to :8989 +``` + +## Setup + +### Prerequisites + +- Docker & Docker Compose (for `make gui`) +- Python 3.12 + [uv](https://docs.astral.sh/uv/) and Node.js LTS (for the + local dev workflow) +- 1+ GB free disk for reports + +### Optional: `.env` + +```bash +cp .env.example .env +# Edit FLIGHT_BLENDER_URL, LOG_LEVEL, OPENUTM_CONFIG_PATH, etc. +docker compose --env-file .env up --build +``` + +## Docker (single image) + +`docker-compose.yml` defines one service (`verification`) built from the +single `Dockerfile`. The backend serves both the FastAPI API and the built +React frontend on `:8989`. + +```bash +make gui +# or: docker compose up --build +``` + +- Backend API + frontend served on **:8989** +- Reports written to `./reports/` +- Config loaded from `./config/default.yaml` (mounted **read-write** so the + in-app Settings screen can persist edits) +- Restart: `docker compose restart` + +## Local development (no Docker) + +Hot-reload for both ends, run in two terminals: + +```bash +make dev-backend # uv run uvicorn ... --reload --port 8989 +make dev-frontend # cd web-editor && npm install && npm run dev +``` + +- Frontend: (Vite hot-reload) +- API: (uvicorn `--reload`) +- Edit `src/` → backend reloads; edit `web-editor/src/` → browser reloads. + +The Vite dev server proxies `/api`, `/run-scenario*`, `/session/*`, etc. to +`:8989` (see [web-editor/vite.config.ts](web-editor/vite.config.ts)). + +## Configuration + +`./config/default.yaml` is the single source of truth. Three ways to change it: + +### 1. Settings screen (recommended, no restart) + +Open the GUI → click the gear icon in the header. Edit Flight Blender URL, +auth, AMQP, OpenSky, simulator defaults, and data file paths. **Save & Apply** +writes back to the YAML (comments preserved) and hot-reloads the server. +**Reload** re-reads the file from disk. + +> Note: *Save & Apply* is rejected while a scenario run is active — stop the +> run first. + +### 2. Edit the YAML file directly + +```bash +vim config/default.yaml +# Click Reload in the Settings screen — no restart needed. +# (Or: docker compose restart) +``` + +### 3. Environment variable / `.env` (build- or start-time) + +```bash +FLIGHT_BLENDER_URL=http://my-blender:8000 make gui +``` + +> **Note:** `/session/reset` no longer accepts per-scenario config overrides. +> Use the Settings screen or PUT `/api/config` instead. + +### Programmatic config update + +```bash +curl -X PUT http://localhost:8989/api/config \ + -H "Content-Type: application/json" \ + -d '{"flight_blender": {"url": "https://blender.example.com", + "auth": {"type": "none"}}}' +``` + +## Reports + +Generated reports land in `./reports/{timestamp}/`: + +``` +reports/2026-04-20_10-30-00/ +├── report.json +├── report.html +├── report.log +└── / + ├── flight_declaration.json + ├── telemetry.json + └── air_traffic.json +``` + +View at . + +## Troubleshooting + +**Port already in use** — `lsof -i :8989` (Docker / backend) or `:5173` +(Vite dev). + +**Permission denied on reports** +```bash +sudo chown -R 1000:1000 ./reports +``` + +**Settings screen "Save & Apply" fails with read-only error** — confirm both +`./config` and `./reports` mounts in `docker-compose.yml` are `:rw`. + +**Reload button doesn't show external edits** — GET `/api/config` re-reads +from disk on every call. Rebuild if you're on an older image. + +**Backend unreachable from host** +```bash +docker compose logs verification | tail -30 +``` + +**Clean rebuild** +```bash +docker compose down +docker compose up --build +``` + +## Kubernetes + +See [k8s-deployment.yaml](k8s-deployment.yaml) and +[docs/deployment-guide.md](docs/deployment-guide.md). + +## More + +- Architecture: [docs/gui-docker-architecture.md](docs/gui-docker-architecture.md) +- Default config: [config/default.yaml](config/default.yaml) diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..3da738c6 --- /dev/null +++ b/Makefile @@ -0,0 +1,76 @@ +.PHONY: help install run verify test coverage lint format clean gui gui-stop dev-backend dev-frontend allure-report allure-serve allure-open + +help: + @echo " make help Show this help message" + @echo " make install Install dependencies" + @echo " make run Run the verification tool locally" + @echo " make verify Run the verification tool (alias for run)" + @echo " make test Run pytest for the project" + @echo " make coverage Run pytest with coverage report" + @echo " make lint Run linters and type checker" + @echo " make format Format the codebase" + @echo " make clean Clean up build artifacts and cache" + @echo " make gui Run GUI mode in Docker (single image)" + @echo " make gui-stop Stop the GUI Docker container" + @echo " make dev-backend Run the backend locally with hot-reload (uvicorn)" + @echo " make dev-frontend Run the Vite dev server locally" + +install: + uv sync --dev -U + +run: + ./verify.sh + +verify: run + +test: + uv run pytest tests/ + +lint: + uv run ruff check src/openutm_verification/ tests/ + uv run pylint src/openutm_verification/ + uv run mypy src/openutm_verification/ + +format: + uv run ruff format . + uv run ruff check --select "I" --fix + +coverage: + uv run pytest --cov=src/openutm_verification --cov-report=term-missing --cov-report=html tests/ + +# GUI Mode (Docker, single image: backend serves the built frontend) +gui: + DOCKER_BUILDKIT=1 docker compose up --build -d + +gui-stop: + docker compose down + +# Local development: run these in two terminals (no Docker required) +dev-backend: + uv run uvicorn openutm_verification.server.main:app --reload --port 8989 + +dev-frontend: + cd web-editor && npm install && npm run dev + +# Allure Reporting (requires npx allure — uses Allure 3 "awesome" theme) +allure-report: + npx allure awesome reports/allure-results --output reports/allure-report + +allure-serve: + npx allure awesome reports/allure-results --output reports/allure-report + npx allure open reports/allure-report + +allure-open: + npx allure open reports/allure-report + +clean: + rm -rf __pycache__ + rm -rf .pytest_cache + rm -rf .mypy_cache + rm -rf .ruff_cache + rm -rf htmlcov + rm -rf reports/* + rm -f .coverage + rm -f lcov.info + find . -name "*.pyc" -delete + find . -name "__pycache__" -delete diff --git a/README.md b/README.md index 30b24b31..6876fa93 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ -# OpenUTM Verification Toolkit v0.1.3 +# OpenUTM Verification Toolkit v0.2.0 A repository to host verification tools for Flight Blender and OpenUTM products. ## Overview -This toolkit provides a configuration-driven framework for running automated conformance and integration test scenarios against a Flight Blender instance. It is designed to be run as a standalone tool or within a Docker container. +This toolkit provides a configuration-driven framework for running automated +conformance and integration test scenarios against a Flight Blender instance. +It can be run as a local CLI, as a single-image Docker GUI, or in Kubernetes. ### Key Features @@ -12,480 +14,191 @@ This toolkit provides a configuration-driven framework for running automated con * **Automatic Cleanup**: Scenarios automatically clean up created resources (flight declarations) after execution * **Multiple Authentication Methods**: Support for dummy authentication (development) and OAuth2/Passport (production) * **Comprehensive Reporting**: Generate JSON, HTML, and log reports with detailed execution results -* **Docker Integration**: Full containerization support for production and development environments +* **Single Docker Image**: Backend (FastAPI on `:8989`) and built frontend bundled in one container * **Live Data Integration**: Support for OpenSky Network live flight data in test scenarios * **Configuration-Driven**: YAML-based configuration for easy customization and environment management ## Documentation -For detailed information about the verification scenarios, please refer to the [Scenario Documentation](docs/index.md). +For detailed information about the verification scenarios, please refer to the +[Scenario Documentation](docs/index.md). For the GUI quick-start, see +[GUI_QUICKSTART.md](GUI_QUICKSTART.md). For Kubernetes, see +[k8s-deployment.yaml](k8s-deployment.yaml) and +[docs/deployment-guide.md](docs/deployment-guide.md). ## Quick Start ### Prerequisites -* Docker -* Docker Compose +* Docker and Docker Compose (for the GUI image) +* Python 3.12 + [uv](https://docs.astral.sh/uv/) (for local CLI / dev) +* Node.js LTS (for the web editor in dev mode) -### 1. Environment Setup - -Copy the environment template and customize it for your setup: +### Run the GUI in Docker (recommended) ```bash -cp .env.example .env -# Edit .env with your Flight Blender URL and other settings -``` - -### 2. Build the Docker Images - -Build the production and development images: - -```bash -# Build all images (production + development) -./scripts/build.sh - -# Build all images with verbose output -./scripts/build.sh -v - -# Build all images with force rebuild (skip cache) -./scripts/build.sh -f - -# Build only production image -./scripts/build.sh production - -# Build only development image -./scripts/build.sh development +cp .env.example .env # optional — edit FLIGHT_BLENDER_URL etc. +make gui # docker compose up --build +# open http://localhost:8989 ``` -### 3. Run Verification Scenarios +Stop with `make gui-stop` (`docker compose down`). -#### Production Mode (Recommended) +The image is built from the single `Dockerfile` (multi-stage: Vite build → +`uv` install → minimal Python runtime). `docker-compose.yml` mounts +`./config`, `./reports`, and `./scenarios` so edits and reports persist on the +host. -**Run with default configuration:** +### Run the CLI locally ```bash -./scripts/run.sh +make install # uv sync --dev -U +make run # ./verify.sh — runs scenarios from config/default.yaml ``` -**Run with custom configuration:** +### Run scenarios end-to-end without Docker or the GUI -```bash -./scripts/run.sh --config config/custom.yaml -``` - -**Run with debug logging:** +This is the same flow CI uses (see [.github/workflows/main.yml](.github/workflows/main.yml)): +spin up Flight Blender + dependencies via the test compose file, then run the +verification CLI against it. ```bash -./scripts/run.sh --debug -``` - -**Run with debug logging and production settings:** - -```bash -./scripts/run.sh -p --debug -``` +# 1. Install dependencies (one-off) +uv sync --locked --all-extras --dev -**Run with verbose output:** +# 2. Start Flight Blender and its dependencies (Postgres, Redis, RabbitMQ, …) +# The test compose file lives under tests/. +docker compose --env-file tests/.env.tests -f tests/docker-compose.fb.yml up -d --wait -```bash -./scripts/run.sh -v -``` - -**Build and run in production mode:** +# 3. Run the PR scenario suite against it (writes reports/ on success or failure) +uv run openutm-verify --debug --config config/pull_request.yaml -```bash -./scripts/run.sh -b +# 4. Tear down when done +docker compose --env-file tests/.env.tests -f tests/docker-compose.fb.yml down ``` -#### Development Mode +What the flags do: -**Run in development mode with hot reload:** +* `--debug` — verbose logging (DEBUG level) to stdout and `reports//report.log`. +* `--config ` — pick the config file. Built-in options: + * [config/default.yaml](config/default.yaml) — full daily-conformance suite. + * [config/pull_request.yaml](config/pull_request.yaml) — fast PR smoke + suite (the seven scenarios CI runs). + * Drop your own under `config/local/` and point `--config` at it. -```bash -./scripts/run.sh -d -``` +Reports land in `reports//` (`report.json`, `report.html`, +`report.log`, plus per-scenario artefacts). Open the HTML report in a browser +to inspect results. -**Build and run in development mode:** +If Flight Blender is already running elsewhere (e.g. on `:8000` natively), +skip steps 2 and 4 and just point the config at it via +`flight_blender.url` or `FLIGHT_BLENDER_URL=...`. -```bash -./scripts/run.sh -d -b -``` +### Local development (no Docker) -**Run in development mode with verbose output:** +Run the backend and Vite dev server in two terminals: ```bash -./scripts/run.sh -d -v +make dev-backend # uvicorn --reload on :8989 +make dev-frontend # Vite on :5173, proxies API calls to :8989 ``` -#### Testing Mode +Edit `src/` → backend reloads. Edit `web-editor/src/` → browser reloads. -**Run tests in an isolated environment:** +### Run the test suite ```bash -docker compose --profile test run --rm test-runner +make test # uv run pytest tests/ ``` -This command starts a dedicated container using the `test-runner` service definition, which is configured to execute the `pytest` suite against the codebase. - -## Docker Workflow Details +## Configuration -### Environment Configuration +The single source of truth is `config/default.yaml`. Override the path with +the `OPENUTM_CONFIG_PATH` env var. -The toolkit uses environment variables for configuration. Key variables include: +Three ways to change config at runtime: -* `FLIGHT_BLENDER_URL`: URL of the Flight Blender instance to test -* `LOG_LEVEL`: Logging level (DEBUG, INFO, WARNING, ERROR) -* `ENVIRONMENT`: Environment name for labeling +1. **In-app Settings screen** (recommended) — edit values, click *Save & Apply*. + The backend writes back to the YAML (preserving comments via `ruamel.yaml`) + and hot-reloads. +2. **Edit `config/default.yaml` directly** then click *Reload* in the Settings + screen, or restart the container. +3. **Environment variable** at start time, e.g. + `FLIGHT_BLENDER_URL=http://my-blender:8000 make gui`. -#### Creating a Local Configuration +### Authentication -To set up a local configuration, make a copy of `default.yaml` and place it in the `config/local` folder. +`flight_blender.auth` supports `type: none` (dummy auth, default) and +`type: passport` (OAuth2). For Passport, set `client_id`, `client_secret`, +`token_endpoint`, `passport_base_url`, `audience`, and `scopes`. -#### Authentication Configuration +### Scenario data -For authentication, the following fields can be configured in `config/default.yaml` under `flight_blender.auth`: - -* `audience`: The OAuth audience for token requests. Default: "testflight.flightblender.com" -* `scopes`: The list of scopes for token requests. Default: ["flightblender.write", "flightblender.read"] - -When using `type: "passport"`, also set `client_id`, `client_secret`, and `token_url` as needed. - -#### Scenario Data Configuration - -The verification scenarios are driven by configuration in `config/default.yaml`, which controls the in-memory generation of flight data. This approach eliminates the need for pre-generated data files. - -* **`data_files`**: This section defines the global default paths to the *configuration files* used for generating flight declarations and telemetry. -* **`scenarios`**: This section allows for per-scenario overrides. If a scenario requires different data, you can specify its data configuration paths here. - -The paths point to configuration files, not generated data. For example: - -* `telemetry`: A GeoJSON file defining the flight path for telemetry generation. -* `flight_declaration`: A JSON file defining the bounds and parameters for generating a flight declaration. - -**Example `config/default.yaml` structure:** +Scenarios are driven by config, which references files used to generate flight +declarations and telemetry in memory: ```yaml data_files: - telemetry: "config/bern/trajectory_f1.json" + trajectory: "config/bern/trajectory_f1.json" flight_declaration: "config/bern/flight_declaration.json" -scenarios: - "F1_happy_path": - telemetry: "config/bern/trajectory_f1.json" # Optional override - flight_declaration: "config/bern/flight_declaration.json" # Optional override +suites: + basic_conformance: + scenarios: + - name: F1_happy_path + trajectory: "config/bern/trajectory_f1.json" # optional override ``` -### Docker Compose Services - -#### Production Service (`verification-tool`) - -* Optimized for production use -* Minimal image size with security hardening -* Volume mounts for config and reports -* Host network mode for local Flight Blender access - -#### Development Service (`verification-dev`) - -* Includes development tools and dependencies -* Hot reload capabilities -* Full source code mounting -* Debug logging enabled by default - -#### Testing Service (`test-runner`) - -* Isolated testing environment -* Runs pytest with coverage -* Separate from main application - -### Build Optimization - -The Docker setup includes several optimizations: - -* **Multi-stage builds**: Separate builder and production stages -* **Layer caching**: Optimized for faster rebuilds -* **Security**: Non-root user, minimal attack surface -* **Performance**: UV package manager for fast Python installs - -### Volume Management - -The following directories are mounted as volumes: - -* `./config:/app/config`: Configuration files -* `./reports:/app/reports`: Generated reports -* `./src:/app/src`: Source code (development only) - -### Network Configuration - -The containers use `network_mode: host` to: - -* Access Flight Blender running on `localhost` -* Maintain consistent networking behavior -* Avoid port conflicts - -## Advanced Usage - -### Custom Build Arguments - -Override build arguments for specific needs: - -```bash -docker build \ - --build-arg UV_COMPILE_BYTECODE=0 \ - --build-arg APP_USER=myuser \ - --build-arg UID=1001 \ - -t custom-verification . -``` - -### Development Workflow - -1. **Start development environment:** - - ```bash - ./scripts/run.sh -d -b - ``` - -2. **Run tests:** - - ```bash - docker compose --profile test run --rm test-runner - ``` - -3. **Check logs:** - - ```bash - docker compose logs verification-dev - ``` - ## Web UI (Scenario Editor) -The web-based scenario editor lives in [web-editor](web-editor) and is built with Vite. - -### Dependencies - -* **Node.js** (LTS recommended) -* **npm** (bundled with Node.js) - -### Build +The web editor lives in [web-editor](web-editor) (React + Vite + `@xyflow/react`). +It is built into the Docker image automatically. To work on it locally, see +**Local development** above, or build standalone: ```bash cd web-editor npm install -npm run build +npm run build # outputs to web-editor/dist ``` -### Run in Development Mode - -```bash -cd web-editor -npm install -npm run dev -``` - -### Production Deployment - -1. **Build optimized image:** - - ```bash - ./scripts/build.sh production - ``` - -2. **Build with force rebuild:** - - ```bash - ./scripts/build.sh -f production - ``` - -3. **Run with production settings:** - - ```bash - ./scripts/run.sh -p - ``` - -4. **Run with verbose output:** - - ```bash - ./scripts/run.sh -v - ``` - ## Version Management -This project uses `uv` for dependency management and version control. The `uv version bump` command allows you to easily update the project version in `pyproject.toml`. - -### Basic Usage - -Bump the version to the next patch version (e.g., 1.0.0 → 1.0.1): - -```bash -uv version bump --patch -``` - -Bump the version to the next minor version (e.g., 1.0.0 → 1.1.0): - -```bash -uv version bump --minor -``` - -Bump the version to the next major version (e.g., 1.0.0 → 2.0.0): - -```bash -uv version bump --major -``` - -### Advanced Options - -Bump to a specific version: - -```bash -uv version bump 1.2.3 -``` - -Preview the changes without applying them: +This project uses `uv` for dependency management. Bump the version with: ```bash +uv version bump --patch # 1.0.0 → 1.0.1 +uv version bump --minor # 1.0.0 → 1.1.0 +uv version bump --major # 1.0.0 → 2.0.0 +uv version bump 1.2.3 # explicit uv version bump --patch --dry-run ``` -The version bump will update the `version` field in `pyproject.toml` and ensure consistency across the project. +This updates the `version` field in `pyproject.toml`. ## Maintenance -### Cleanup - -Clean up Docker resources: - -```bash -# Clean all project resources -./scripts/cleanup.sh -a - -# Clean all resources with force (no confirmation) -./scripts/cleanup.sh -f -a - -# Clean all resources with verbose output -./scripts/cleanup.sh -V -a - -# Clean specific resources -./scripts/cleanup.sh -c -i -./scripts/cleanup.sh -d - -# Clean containers only -./scripts/cleanup.sh -c - -# Clean images only -./scripts/cleanup.sh -i - -# Clean volumes only -./scripts/cleanup.sh -v - -# Clean networks only -./scripts/cleanup.sh -n - -# Clean dangling resources only -./scripts/cleanup.sh -d -``` - -### Health Checks - -Monitor container health: - ```bash -# Check container status -docker compose ps - -# View logs -docker compose logs verification-tool - -# Check health status -docker compose exec verification-tool python -c "print('OK')" +docker compose ps # container status +docker compose logs -f verification # follow logs +docker compose exec verification sh # shell into the container +docker compose down -v # stop and remove volumes +make clean # local artefacts (caches, reports/*) ``` ### Troubleshooting -**Common issues:** - -1. **Permission denied**: Ensure proper file permissions on mounted volumes -2. **Network connectivity**: Verify Flight Blender is accessible on specified URL -3. **Build failures**: Check Docker daemon and available disk space - -**Debug commands:** - -```bash -# Enter running container -docker compose exec verification-tool bash - -# View detailed logs -docker compose logs --tail=100 -f verification-tool - -# Check container resource usage -docker stats -``` +* **Port 8989 already in use** — `lsof -i :8989`, then stop the offender. +* **Permission denied on `./reports`** — `sudo chown -R 1000:1000 ./reports`. +* **Backend can't reach Flight Blender from container** — set + `FLIGHT_BLENDER_URL=http://host.docker.internal:8000` (macOS/Windows) or + the appropriate URL for your network. +* **Settings *Save & Apply* fails read-only** — confirm `./config` mount in + `docker-compose.yml` is `:rw`. ## Configuration Files -* `docker-compose.yml`: Main service definitions -* `docker-compose.override.yml`: Development overrides -* `Dockerfile`: Production image definition -* `Dockerfile.dev`: Development image definition -* `.dockerignore`: Files excluded from build context -* `.env.example`: Environment variables template - -Reports will be generated in the `reports/` directory on your local machine. - -## Script Arguments Reference - -All scripts in this project follow a consistent argument structure for better usability: - -### Common Flags - -| Flag | Long Form | Description | Available In | -|------|-----------|-------------|--------------| -| `-h` | `--help` | Show help message | All scripts | -| `-v` | `--verbose` | Enable verbose output | All scripts | -| `-f` | `--force` | Force operation without confirmation | build.sh, cleanup.sh | - -### Script-Specific Flags - -#### `run.sh` - Run Verification Tool - -| Flag | Long Form | Description | -|------|-----------|-------------| -| `-d` | `--dev` | Run in development mode | -| `-p` | `--production` | Run in production mode (default) | -| `-b` | `--build` | Build images before running | -| | `--clean` | Clean up after run | - -#### `build.sh` - Build Docker Images - -| Argument | Description | -|----------|-------------| -| `production` | Build production image only | -| `development` | Build development image only | -| `all` | Build both images (default) | - -#### `cleanup.sh` - Clean Docker Resources - -| Flag | Long Form | Description | -|------|-----------|-------------| -| `-a` | `--all` | Clean all resources | -| `-c` | `--containers` | Clean containers only | -| `-i` | `--images` | Clean images only | -| `-v` | `--volumes` | Clean volumes only | -| `-n` | `--networks` | Clean networks only | -| `-d` | `--dangling` | Clean dangling resources only | - -### Examples - -```bash -# Get help for any script -./scripts/run.sh --help -./scripts/build.sh --help -./scripts/cleanup.sh --help - -# Use verbose output across all scripts -./scripts/run.sh -v -./scripts/build.sh -v production -./scripts/cleanup.sh -V -a - -# Force operations where available -./scripts/build.sh -f production -./scripts/cleanup.sh -f -a -``` +* `Dockerfile` — single multi-stage build (Vite UI + Python backend) +* `docker-compose.yml` — single service definition for local Docker use +* `k8s-deployment.yaml` — example Kubernetes manifests +* `.env.example` — environment variables template +* `config/default.yaml` — default app configuration diff --git a/docker-compose.gui.yml b/docker-compose.gui.yml deleted file mode 100644 index d6e589e0..00000000 --- a/docker-compose.gui.yml +++ /dev/null @@ -1,182 +0,0 @@ -# Docker Compose for OpenUTM Verification GUI Mode -# -# Usage: -# Production mode (built frontend): -# docker compose -f docker-compose.gui.yml up --build -# -# Development mode (hot-reload for both frontend and backend): -# docker compose -f docker-compose.gui.yml --profile dev up --build -# -# Access the GUI at http://localhost:8989 (production) or http://localhost:5173 (dev) - -services: - # ============================================================================= - # Production Service - Single container serving both backend and frontend - # ============================================================================= - verification-gui: - image: openutm/verification-gui:latest - container_name: openutm-verification-gui - profiles: ["", "prod"] - build: - context: . - dockerfile: Dockerfile.gui - args: - UV_COMPILE_BYTECODE: 1 - UV_LINK_MODE: copy - APP_USER: appuser - APP_GROUP: appgrp - UID: ${HOST_UID:-1000} - GID: ${HOST_GID:-1000} - environment: - # Config & Logging - - OPENUTM_CONFIG_PATH=${OPENUTM_CONFIG_PATH:-/app/config/default.yaml} - - PYTHONUNBUFFERED=1 - - TZ=UTC - - LOG_LEVEL=${LOG_LEVEL:-INFO} - # Flight Blender (override endpoint if needed) - - FLIGHT_BLENDER_URL=${FLIGHT_BLENDER_URL:-http://host.docker.internal:8000} - ports: - - "8989:8989" - # Volume mounts: - # - config: YAML config files (read-write so the GUI Settings screen - # can persist edits via PUT /api/config back to default.yaml) - # - reports: Generated reports (read-write, persists across restarts) - # - scenarios: YAML scenario definitions (read-write so users can add/edit scenarios) - # - # For Kubernetes: Use ConfigMap for config, PersistentVolumeClaim for reports - volumes: - - ./config:/app/config:rw - - ./reports:/app/reports:rw - - ./scenarios:/app/scenarios:rw - extra_hosts: - - "host.docker.internal:host-gateway" - deploy: - resources: - limits: - memory: 1G - cpus: '1.0' - reservations: - memory: 256M - cpus: '0.5' - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8989/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 10s - restart: unless-stopped - labels: - - "project=openutm-verification" - - "component=verification-gui" - - "mode=production" - # Access the GUI at http://localhost:8989 - # Reports available at http://localhost:8989/reports/{timestamp}/report.html - - # ============================================================================= - # Development Services - Separate backend and frontend with hot-reload - # ============================================================================= - - # Backend API server with hot-reload (Python) - # Watches src/ for changes and automatically reloads - # Mounts config files for configuration management - # Reports directory is writable so scenario runs persist data - verification-backend-dev: - image: openutm/verification-backend-dev:latest - container_name: openutm-verification-backend-dev - profiles: ["dev"] - build: - context: . - dockerfile: Dockerfile.dev - args: - APP_USER: appuser - APP_GROUP: appgrp - UID: ${HOST_UID:-1000} - GID: ${HOST_GID:-1000} - environment: - # Config & Logging - - OPENUTM_CONFIG_PATH=${OPENUTM_CONFIG_PATH:-/app/config/default.yaml} - - PYTHONUNBUFFERED=1 - - TZ=UTC - - LOG_LEVEL=${LOG_LEVEL:-DEBUG} - # Flight Blender - - FLIGHT_BLENDER_URL=${FLIGHT_BLENDER_URL:-http://host.docker.internal:8000} - # Hot-reload flag - - UVICORN_RELOAD=true - ports: - - "8989:8989" - # Volume mounts for development: - # - src: Python source code (hot-reload via uvicorn --reload) - # - config: YAML config files (read-write so the Settings screen - # can persist edits via PUT /api/config) - # - reports: Generated reports (writable, persists) - # - scenarios: Scenario YAML files (read-write so devs can add/edit scenarios without rebuilding) - # - docs: Documentation (mounted for reference) - volumes: - - ./src:/app/src:ro - - ./config:/app/config:rw - - ./reports:/app/reports:rw - - ./scenarios:/app/scenarios:rw - - ./docs:/app/docs:ro - extra_hosts: - - "host.docker.internal:host-gateway" - command: > - python -m uvicorn openutm_verification.server.main:app - --host 0.0.0.0 --port 8989 --reload - --reload-dir /app/src - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8989/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 10s - restart: unless-stopped - labels: - - "project=openutm-verification" - - "component=verification-backend" - - "mode=development" - # Backend API at http://localhost:8989 - # Reports at http://localhost:8989/reports/{timestamp}/report.html - - # Frontend dev server with hot-reload (React + Vite) - # Proxies all API calls to verification-backend-dev:8989 - # Hot-reloads React components as you edit them - # VITE_BACKEND_URL controls where API requests are proxied to (container networking) - verification-frontend-dev: - image: node:20-slim - container_name: openutm-verification-frontend-dev - profiles: ["dev"] - working_dir: /app - environment: - # Dev environment - - NODE_ENV=development - # Backend URL for Vite proxy (container hostname:port) - - VITE_BACKEND_URL=http://verification-backend-dev:8989 - ports: - - "5173:5173" - # Volume mounts: - # - web-editor: Source code (mutable, hot-reload via Vite) - # - web-editor-node-modules: Persistent node_modules (avoids reinstall) - volumes: - - ./web-editor:/app:rw - - web-editor-node-modules:/app/node_modules - command: sh -c "npm install && npm run dev -- --host 0.0.0.0" - depends_on: - verification-backend-dev: - condition: service_healthy - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:5173"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 30s - restart: unless-stopped - labels: - - "project=openutm-verification" - - "component=verification-frontend" - - "mode=development" - # Access Vite dev server at http://localhost:5173 - # API requests proxied to backend at http://verification-backend-dev:8989 - -volumes: - web-editor-node-modules: - name: openutm-web-editor-node-modules diff --git a/docker-compose.override.yml b/docker-compose.override.yml deleted file mode 100644 index 98bf12ea..00000000 --- a/docker-compose.override.yml +++ /dev/null @@ -1,36 +0,0 @@ -# Development override configuration -# This file is automatically loaded when running docker-compose - -services: - verification-tool: - # Override for development - environment: - - LOG_LEVEL=${LOG_LEVEL:-DEBUG} - - PYTHONPATH=/app/src - # Additional development volumes - volumes: - - ./src:/app/src:Z - - ./tests:/app/tests:Z - # Override command for development - command: ["openutm-verify", "--config", "config/default.yaml", "--debug"] - - # Development service for testing - test-runner: - image: openutm/verification:dev - container_name: openutm-verification-test - build: - context: . - dockerfile: Dockerfile.dev - target: development - environment: - - PYTHONUNBUFFERED=1 - - TZ=UTC - - LOG_LEVEL=${LOG_LEVEL:-DEBUG} - volumes: - - .:/app:Z - - /app/.venv - working_dir: /app - command: ["uv", "run", "pytest", "tests/", "-v", "--tb=short"] - network_mode: host - profiles: - - test diff --git a/docker-compose.yml b/docker-compose.yml index 10c84a65..4edd1df6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,18 @@ +# Docker Compose for OpenUTM Verification (single-image GUI mode) +# +# Usage: +# docker compose up --build +# +# Access the GUI at http://localhost:8989. +# +# For local development, run two processes directly on the host instead: +# uv run uvicorn openutm_verification.server.main:app --reload --port 8989 +# cd web-editor && npm run dev # Vite on :5173, proxies API to :8989 + services: - verification-tool: + verification: image: openutm/verification:latest container_name: openutm-verification - # Build arguments for development build: context: . dockerfile: Dockerfile @@ -13,20 +23,28 @@ services: APP_GROUP: appgrp UID: ${HOST_UID:-1000} GID: ${HOST_GID:-1000} - # Environment variables environment: + - OPENUTM_CONFIG_PATH=${OPENUTM_CONFIG_PATH:-/app/config/default.yaml} - PYTHONUNBUFFERED=1 - TZ=UTC - - FLIGHT_BLENDER_URL=${FLIGHT_BLENDER_URL:-http://localhost:8000} - LOG_LEVEL=${LOG_LEVEL:-INFO} - # Mount local directories for development + - FLIGHT_BLENDER_URL=${FLIGHT_BLENDER_URL:-http://host.docker.internal:8000} + ports: + - "8989:8989" + # Volume mounts: + # - config: YAML config (read-write so the in-app Settings screen can + # persist edits via PUT /api/config back to default.yaml) + # - reports: Generated reports, persisted across restarts + # - scenarios: YAML scenario definitions (read-write so users can edit + # them without rebuilding the image) + # + # For Kubernetes: use ConfigMap for config, PersistentVolumeClaim for reports. volumes: - - ./config:/app/config:Z - - ./reports:/app/reports:Z - - ./scenarios:/app/scenarios:Z - # Network configuration for local Flight Blender access - network_mode: host - # Resource limits + - ./config:/app/config:rw + - ./reports:/app/reports:rw + - ./scenarios:/app/scenarios:rw + extra_hosts: + - "host.docker.internal:host-gateway" deploy: resources: limits: @@ -35,85 +53,13 @@ services: reservations: memory: 256M cpus: '0.5' - # Health check - healthcheck: - test: ["CMD", "openutm-verify", "--help"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s - # Restart policy - restart: unless-stopped - # Labels for organization - labels: - - "project=openutm-verification" - - "component=verification-tool" - - "environment=${ENVIRONMENT:-development}" - - # Optional: Add a development service with hot reload - verification-dev: - image: openutm/verification:dev - container_name: openutm-verification-dev - build: - context: . - dockerfile: Dockerfile.dev - target: development - args: - APP_USER: appuser - APP_GROUP: appgrp - UID: ${HOST_UID:-1000} - GID: ${HOST_GID:-1000} - environment: - - PYTHONUNBUFFERED=1 - - TZ=UTC - - FLIGHT_BLENDER_URL=${FLIGHT_BLENDER_URL:-http://localhost:8000} - - LOG_LEVEL=${LOG_LEVEL:-DEBUG} - volumes: - - .:/app:Z - - /app/.venv - working_dir: /app - network_mode: host - profiles: - - dev - - # Server mode service - verification-server: - image: openutm/verification:latest - container_name: openutm-verification-server - build: - context: . - dockerfile: Dockerfile - args: - UV_COMPILE_BYTECODE: 1 - UV_LINK_MODE: copy - APP_USER: appuser - APP_GROUP: appgrp - UID: ${HOST_UID:-1000} - GID: ${HOST_GID:-1000} - environment: - - PYTHONUNBUFFERED=1 - - TZ=UTC - - FLIGHT_BLENDER_URL=${FLIGHT_BLENDER_URL:-http://host.docker.internal:8000} - - LOG_LEVEL=${LOG_LEVEL:-INFO} - volumes: - - ./config:/app/config:Z - - ./reports:/app/reports:Z - - ./scenarios:/app/scenarios:Z - ports: - - "8989:8989" - extra_hosts: - - "host.docker.internal:host-gateway" - command: ["--server", "--config", "config/default.yaml"] healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8989/health"] interval: 30s timeout: 10s retries: 3 - start_period: 40s + start_period: 10s restart: unless-stopped labels: - "project=openutm-verification" - - "component=verification-server" - - "environment=${ENVIRONMENT:-production}" - profiles: - - server + - "component=verification" diff --git a/pyproject.toml b/pyproject.toml index d9c32678..1df95ffc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,13 @@ [project] name = "openutm-verification" -version = "0.1.3" +version = "0.2.0" description = "Verification tools for Flight Blender and OpenUTM products" readme = "README.md" license = { file = "LICENSE" } requires-python = ">=3.12" classifiers = [ "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", ] diff --git a/scripts/build.sh b/scripts/build.sh index fcafd20a..d3e2c1ea 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,204 +1,63 @@ #!/usr/bin/env bash - -# OpenUTM Verification Docker Build Script -# This script builds the Docker image with proper error handling and logging +# +# Build the single OpenUTM Verification Docker image (backend + built GUI). +# +# Usage: +# ./scripts/build.sh [--force] [--verbose] set -euo pipefail -# Source common functions SCRIPT_DIR="$(dirname "$0")" +# shellcheck source=common.sh source "$SCRIPT_DIR/common.sh" -# Configuration readonly NAMESPACE="${NAMESPACE:-openutm}" readonly APP="${APP:-verification}" -TIMESTAMP="$(date +%s)" -readonly TIMESTAMP -readonly IMAGE="${APP}:${TIMESTAMP}" -readonly IMAGE_LATEST="${APP}:latest" -readonly IMAGE_DEV="${APP}:dev" - -# Cleanup function specific to build script -cleanup_build() { - local exit_code=$? - if [ $exit_code -ne 0 ]; then - log_error "Build failed with exit code $exit_code" - # Optionally cleanup dangling images - # docker image prune -f - fi - exit $exit_code -} - -# Set trap for cleanup -trap cleanup_build EXIT - -# Check if Docker and Docker Compose are available -# Note: check_dependencies is now sourced from common.sh - -# Build production image -build_production() { - log_info "Building production Docker image: ${NAMESPACE}/${IMAGE_LATEST}" - - # Enable Docker BuildKit for better performance - export DOCKER_BUILDKIT=1 - - local build_args=() - if [[ "${force_rebuild}" == "true" ]]; then - build_args+=(--no-cache) - log_info "Force rebuild enabled - skipping cache" - fi - - if [[ "${verbose}" == "true" ]]; then - build_args+=(--progress=plain) - log_info "Verbose output enabled" - fi - - # Build with build args - docker build \ - --target production \ - --build-arg UV_COMPILE_BYTECODE=1 \ - --build-arg UV_LINK_MODE=copy \ - --build-arg UID="${HOST_UID}" \ - --build-arg GID="${HOST_GID}" \ - ${build_args[@]+"${build_args[@]}"} \ - -t "${NAMESPACE}/${IMAGE}" \ - -t "${NAMESPACE}/${IMAGE_LATEST}" \ - . - - log_success "Production image built successfully" -} - -# Build development image -build_development() { - log_info "Building development Docker image: ${NAMESPACE}/${IMAGE_DEV}" - - export DOCKER_BUILDKIT=1 - - local build_args=() - if [[ "${force_rebuild}" == "true" ]]; then - build_args+=(--no-cache) - log_info "Force rebuild enabled - skipping cache" - fi +readonly IMAGE_LATEST="${NAMESPACE}/${APP}:latest" - if [[ "${verbose}" == "true" ]]; then - build_args+=(--progress=plain) - log_info "Verbose output enabled" - fi - - docker build \ - -f Dockerfile.dev \ - --target development \ - --build-arg UV_COMPILE_BYTECODE=0 \ - --build-arg UID="${HOST_UID}" \ - --build-arg GID="${HOST_GID}" \ - ${build_args[@]+"${build_args[@]}"} \ - -t "${NAMESPACE}/${IMAGE_DEV}" \ - . - - log_success "Development image built successfully" -} - -# Show build information -show_build_info() { - log_info "Build completed successfully!" - echo "Production image: ${NAMESPACE}/${IMAGE_LATEST}" - echo "Development image: ${NAMESPACE}/${IMAGE_DEV}" - echo "Timestamp: ${TIMESTAMP}" -} - -# Show usage information show_usage() { cat << EOF -OpenUTM Verification Docker Build Script - -Usage: $0 [OPTIONS] [BUILD_TYPE] - -Build Docker images for OpenUTM Verification +Build the OpenUTM Verification Docker image. -Arguments: - BUILD_TYPE Type of build to perform - production, prod Build production image only - development, dev Build development image only - all Build both images (default) +Usage: $0 [OPTIONS] Options: - -f, --force Force rebuild even if images exist - -v, --verbose Enable verbose output - -h, --help Show this help message - -Examples: - $0 # Build all images - $0 production # Build production image only - $0 --force all # Force rebuild all images - $0 -v development # Build development image with verbose output - + -f, --force Force rebuild without cache + -v, --verbose Verbose build output (--progress=plain) + -h, --help Show this help message EOF } -# Main execution main() { - local build_type="all" local force_rebuild="false" local verbose="false" - local args=() - # Parse options while [[ $# -gt 0 ]]; do case $1 in - -f|--force) - force_rebuild="true" - shift - ;; - -v|--verbose) - verbose="true" - shift - ;; - -h|--help) - show_usage - exit 0 - ;; - -*) - log_error "Unknown option: $1" - show_usage - exit 1 - ;; - *) - # Collect positional arguments - args+=("$1") - shift - ;; + -f|--force) force_rebuild="true"; shift ;; + -v|--verbose) verbose="true"; shift ;; + -h|--help) show_usage; exit 0 ;; + *) log_error "Unknown option: $1"; show_usage; exit 1 ;; esac done - # Set build type from the first positional argument, if provided - if [[ ${#args[@]} -gt 0 ]]; then - build_type="${args[0]}" - fi - - log_info "Starting Docker build process..." check_dependencies - case "${build_type}" in - "production"|"prod") - build_production - ;; - "development"|"dev") - build_development - ;; - "all") - build_production - build_development - ;; - *) - log_error "Invalid build type: '${build_type}'" - echo "Usage: $0 [OPTIONS] [production|development|all]" - echo "Try '$0 --help' for more information." - exit 1 - ;; - esac + local build_args=() + [[ "${force_rebuild}" == "true" ]] && build_args+=(--no-cache) + [[ "${verbose}" == "true" ]] && build_args+=(--progress=plain) + + log_info "Building ${IMAGE_LATEST}" + DOCKER_BUILDKIT=1 docker build \ + --build-arg UV_COMPILE_BYTECODE=1 \ + --build-arg UV_LINK_MODE=copy \ + --build-arg UID="${HOST_UID}" \ + --build-arg GID="${HOST_GID}" \ + ${build_args[@]+"${build_args[@]}"} \ + -t "${IMAGE_LATEST}" \ + . - show_build_info + log_success "Built ${IMAGE_LATEST}" } -# Run main function with all arguments main "$@" diff --git a/scripts/cleanup.sh b/scripts/cleanup.sh index c832f087..08dafcea 100755 --- a/scripts/cleanup.sh +++ b/scripts/cleanup.sh @@ -1,282 +1,84 @@ #!/usr/bin/env bash - -# OpenUTM Verification Docker Cleanup Script -# This script cleans up Docker resources related to the verification tool +# +# Clean up Docker resources for the OpenUTM Verification GUI. +# +# Usage: +# ./scripts/cleanup.sh # stop containers (compose down) +# ./scripts/cleanup.sh --all # also remove the image and volumes +# ./scripts/cleanup.sh --dangling # prune dangling images/volumes/networks set -euo pipefail -# Source common functions SCRIPT_DIR="$(dirname "$0")" +# shellcheck source=common.sh source "$SCRIPT_DIR/common.sh" -# Configuration -readonly PROJECT_NAME="${COMPOSE_PROJECT_NAME:-openutm-verification}" readonly IMAGE_PREFIX="openutm/verification" -# Cleanup function specific to cleanup script -cleanup_cleanup() { - local exit_code=$? - if [ $exit_code -ne 0 ]; then - log_error "Script failed with exit code $exit_code" - fi - exit $exit_code -} - -# Set trap for cleanup -trap cleanup_cleanup EXIT - -# Show usage show_usage() { cat << EOF -OpenUTM Verification Docker Cleanup Script +Clean up OpenUTM Verification Docker resources. Usage: $0 [OPTIONS] Options: - -a, --all Clean all Docker resources (containers, images, volumes, networks) - -c, --containers Clean containers only - -i, --images Clean images only - -v, --volumes Clean volumes only - -n, --networks Clean networks only - -d, --dangling Clean dangling resources only - -f, --force Force cleanup without confirmation - -V, --verbose Enable verbose output + (no flags) Stop and remove the compose stack (docker compose down) + -a, --all Also remove the image and named volumes + -d, --dangling Prune dangling images, unused volumes and networks + -f, --force Skip confirmation prompts -h, --help Show this help message - -Examples: - $0 --all # Clean all resources - $0 --containers # Clean containers only - $0 --dangling # Clean dangling resources - $0 -f --all # Force clean all without confirmation - EOF } -# Confirm action confirm() { local message="$1" - if [[ "${FORCE_CLEANUP:-false}" == "true" ]]; then - return 0 - fi - - read -p "$message (y/N): " -n 1 -r - echo - [[ $REPLY =~ ^[Yy]$ ]] -} - -# Clean containers -clean_containers() { - if [[ "${VERBOSE}" == "true" ]]; then - log_info "Verbose mode enabled - showing detailed container information" - fi - - log_info "Cleaning containers..." - - local containers - containers=$(docker ps -a --filter "label=project=openutm-verification" --format "{{.ID}}") - - if [[ -z "$containers" ]]; then - log_info "No containers found to clean" - return 0 - fi - - if [[ "${VERBOSE}" == "true" ]]; then - log_info "Found containers:" - docker ps -a --filter "label=project=openutm-verification" --format "table {{.ID}}\t{{.Image}}\t{{.Status}}\t{{.Names}}" - else - log_info "Containers found: $containers" - fi - - if confirm "Remove $(echo "$containers" | wc -l) container(s)?"; then - echo "$containers" | xargs docker rm -f - log_success "Containers cleaned" - fi + [[ "${FORCE:-false}" == "true" ]] && return 0 + read -r -p "$message (y/N): " reply + [[ $reply =~ ^[Yy]$ ]] } -# Clean images -clean_images() { - log_info "Cleaning images..." - - local images - images=$(docker images "${IMAGE_PREFIX}" --format "{{.Repository}}:{{.Tag}}") - - if [[ -z "$images" ]]; then - log_info "No images found to clean" - return 0 - fi - - log_info "Images found: $images" - if confirm "Remove $(echo "$images" | wc -l) image(s)?"; then - echo "$images" | xargs docker rmi -f - log_success "Images cleaned" - fi -} - -# Clean volumes -clean_volumes() { - log_info "Cleaning volumes..." - - local volumes - volumes=$(docker volume ls --filter "label=com.docker.compose.project=${PROJECT_NAME}" --format "{{.Name}}") - - if [[ -z "$volumes" ]]; then - log_info "No volumes found to clean" - return 0 - fi - - log_info "Volumes found: $volumes" - if confirm "Remove $(echo "$volumes" | wc -l) volume(s)?"; then - echo "$volumes" | xargs docker volume rm -f - log_success "Volumes cleaned" - fi +stack_down() { + local extra_args=("$@") + log_info "Stopping compose stack..." + docker compose down "${extra_args[@]}" + log_success "Stack stopped" } -# Clean networks -clean_networks() { - log_info "Cleaning networks..." - - local networks - networks=$(docker network ls --filter "label=com.docker.compose.project=${PROJECT_NAME}" --format "{{.Name}}") - - if [[ -z "$networks" ]]; then - log_info "No networks found to clean" - return 0 - fi - - log_info "Networks found: $networks" - if confirm "Remove $(echo "$networks" | wc -l) network(s)?"; then - echo "$networks" | xargs docker network rm - log_success "Networks cleaned" +clean_all() { + if confirm "Remove containers, image (${IMAGE_PREFIX}:*) and named volumes?"; then + stack_down --rmi all --volumes --remove-orphans fi } -# Clean dangling resources clean_dangling() { - log_info "Cleaning dangling resources..." - - # Remove dangling images - local dangling_images - dangling_images=$(docker images -f "dangling=true" -q) - if [[ -n "$dangling_images" ]]; then - echo "$dangling_images" | xargs docker rmi -f - log_success "Dangling images cleaned" - fi - - # Remove unused volumes + log_info "Pruning dangling images, unused volumes and networks..." + docker image prune -f docker volume prune -f - log_success "Unused volumes cleaned" - - # Remove unused networks docker network prune -f - log_success "Unused networks cleaned" -} - -# Clean all resources -clean_all() { - log_info "Cleaning all Docker resources for OpenUTM Verification..." - - if confirm "This will remove ALL containers, images, volumes, and networks for the project. Continue?"; then - # Stop and remove containers - docker compose down --rmi all --volumes --remove-orphans - - # Clean dangling resources - clean_dangling - - log_success "All resources cleaned" - fi + log_success "Dangling resources pruned" } -# Main execution main() { - local CLEAN_CONTAINERS="false" - local CLEAN_IMAGES="false" - local CLEAN_VOLUMES="false" - local CLEAN_NETWORKS="false" - local CLEAN_DANGLING="false" - local FORCE_CLEANUP="false" - local VERBOSE="false" + local mode="default" + FORCE="false" - # Parse options while [[ $# -gt 0 ]]; do case $1 in - -a|--all) - clean_all - exit 0 - ;; - -c|--containers) - CLEAN_CONTAINERS="true" - shift - ;; - -i|--images) - CLEAN_IMAGES="true" - shift - ;; - -v|--volumes) - CLEAN_VOLUMES="true" - shift - ;; - -V|--verbose) - VERBOSE="true" - shift - ;; - -n|--networks) - CLEAN_NETWORKS="true" - shift - ;; - -d|--dangling) - CLEAN_DANGLING="true" - shift - ;; - -f|--force) - FORCE_CLEANUP="true" - shift - ;; - -h|--help) - show_usage - exit 0 - ;; - *) - log_error "Unknown option: $1" - show_usage - exit 1 - ;; + -a|--all) mode="all"; shift ;; + -d|--dangling) mode="dangling"; shift ;; + -f|--force) FORCE="true"; shift ;; + -h|--help) show_usage; exit 0 ;; + *) log_error "Unknown option: $1"; show_usage; exit 1 ;; esac done - # If no specific options provided, show usage - if [[ "$CLEAN_CONTAINERS" == "false" && "$CLEAN_IMAGES" == "false" && \ - "$CLEAN_VOLUMES" == "false" && "$CLEAN_NETWORKS" == "false" && \ - "$CLEAN_DANGLING" == "false" ]]; then - show_usage - exit 0 - fi - - log_info "Starting cleanup process..." check_dependencies - # Execute cleanup actions - if [[ "$CLEAN_CONTAINERS" == "true" ]]; then - clean_containers - fi - - if [[ "$CLEAN_IMAGES" == "true" ]]; then - clean_images - fi - - if [[ "$CLEAN_VOLUMES" == "true" ]]; then - clean_volumes - fi - - if [[ "$CLEAN_NETWORKS" == "true" ]]; then - clean_networks - fi - - if [[ "$CLEAN_DANGLING" == "true" ]]; then - clean_dangling - fi - - log_success "Cleanup completed" + case "${mode}" in + default) stack_down --remove-orphans ;; + all) clean_all ;; + dangling) clean_dangling ;; + esac } -# Run main function with all arguments main "$@" diff --git a/scripts/run.sh b/scripts/run.sh index c72e6920..d0e65456 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -1,239 +1,78 @@ #!/usr/bin/env bash - -# OpenUTM Verification Docker Run Script -# This script runs the verification tool using Docker Compose with enhanced error handling +# +# Start the OpenUTM Verification GUI (backend + built frontend) via Docker +# Compose. Equivalent to `make gui`. +# +# For local development without Docker, use `make dev-backend` and +# `make dev-frontend` instead. +# +# Usage: +# ./scripts/run.sh [--build] [--foreground] [--verbose] set -euo pipefail -# Source common functions SCRIPT_DIR="$(dirname "$0")" +# shellcheck source=common.sh source "$SCRIPT_DIR/common.sh" -# Configuration readonly COMPOSE_FILE="docker-compose.yml" -readonly SERVICE_NAME="verification-tool" -readonly DEV_SERVICE_NAME="verification-dev" -readonly SERVER_SERVICE_NAME="verification-server" - -# Check if Docker and Docker Compose are available -# Note: check_dependencies is now sourced from common.sh -# Check if required files exist -check_files() { - if [[ ! -f "${COMPOSE_FILE}" ]]; then - log_error "Docker Compose file '${COMPOSE_FILE}' not found" - exit 1 - fi - - if [[ ! -f "config/default.yaml" ]]; then - log_warning "Default config file 'config/default.yaml' not found" - fi -} - -# create the reports and logs folder -create_folders() { - for dir in reports; do - if [[ ! -d "$dir" ]]; then - log_info "Directory '$dir' does not exist. Creating..." - mkdir -p "$dir" - fi - done -} - -# Show usage information show_usage() { cat << EOF -OpenUTM Verification Tool Runner +Start the OpenUTM Verification GUI in Docker. -Usage: $0 [OPTIONS] [ARGS...] +Usage: $0 [OPTIONS] Options: - -d, --dev Run in development mode with hot reload - -p, --production Run in production mode (default) - -s, --server Run in server mode (starts API and UI) - -b, --build Build images before running - --clean Clean up containers and images after run - -v, --verbose Enable verbose output - -h, --help Show this help message - -Arguments: - All additional arguments are passed to the verification tool - -Examples: - $0 # Run with default config - $0 --config config/custom.yaml # Run with custom config - $0 --server # Run in server mode - $0 --debug # Run with debug logging - $0 --dev --build # Build and run in development mode - $0 --clean --config config/test.yaml # Clean up after run - $0 -v --build # Run with verbose output and build first + -b, --build Rebuild the image before starting + -f, --foreground Run in the foreground (stream logs); default is detached + -v, --verbose Verbose docker compose output + -h, --help Show this help message +The GUI is served at http://localhost:8989. EOF } -# Run in production mode -run_production() { - log_info "Running verification tool in production mode..." - - local build_opts=() - if [[ "${VERBOSE}" == "true" ]]; then - log_info "Verbose mode enabled - additional logging will be shown" - build_opts+=("-v") - fi - - if [[ "${BUILD_FIRST}" == "true" ]]; then - log_info "Building production image first..." - ./scripts/build.sh ${build_opts[@]+"${build_opts[@]}"} production - fi - - local compose_opts=() - if [[ "${VERBOSE}" == "true" ]]; then - log_info "Starting container with verbose output..." - compose_opts+=("--verbose") - fi - - docker compose ${compose_opts[@]+"${compose_opts[@]}"} --file "${COMPOSE_FILE}" run --rm \ - --user "${HOST_UID}:${HOST_GID}" \ - -e HOST_UID="${HOST_UID}" -e HOST_GID="${HOST_GID}" \ - "${SERVICE_NAME}" "$@" -} - -# Run in development mode -run_development() { - log_info "Running verification tool in development mode..." - - local build_opts=() - if [[ "${VERBOSE}" == "true" ]]; then - log_info "Verbose mode enabled - additional logging will be shown" - build_opts+=("-v") - fi +main() { + local build_first="false" + local foreground="false" + local verbose="false" - if [[ "${BUILD_FIRST}" == "true" ]]; then - log_info "Building development image first..." - ./scripts/build.sh ${build_opts[@]+"${build_opts[@]}"} development - fi + while [[ $# -gt 0 ]]; do + case $1 in + -b|--build) build_first="true"; shift ;; + -f|--foreground) foreground="true"; shift ;; + -v|--verbose) verbose="true"; shift ;; + -h|--help) show_usage; exit 0 ;; + *) log_error "Unknown option: $1"; show_usage; exit 1 ;; + esac + done - local compose_opts=() - if [[ "${VERBOSE}" == "true" ]]; then - compose_opts+=("--verbose") - fi + check_dependencies - docker compose ${compose_opts[@]+"${compose_opts[@]}"} --profile dev run --rm \ - --user "${HOST_UID}:${HOST_GID}" \ - -e HOST_UID="${HOST_UID}" -e HOST_GID="${HOST_GID}" \ - "${DEV_SERVICE_NAME}" "$@" -} -# Run in server mode -run_server() { - log_info "Running verification tool in server mode..." - - local build_opts=() - if [[ "${VERBOSE}" == "true" ]]; then - log_info "Verbose mode enabled - additional logging will be shown" - build_opts+=("-v") + if [[ ! -f "${COMPOSE_FILE}" ]]; then + log_error "Compose file '${COMPOSE_FILE}' not found" + exit 1 fi - if [[ "${BUILD_FIRST}" == "true" ]]; then - log_info "Building production image first..." - ./scripts/build.sh ${build_opts[@]+"${build_opts[@]}"} production - fi + mkdir -p reports local compose_opts=() - if [[ "${VERBOSE}" == "true" ]]; then - log_info "Starting container with verbose output..." - compose_opts+=("--verbose") - fi + [[ "${verbose}" == "true" ]] && compose_opts+=(--verbose) - # For server mode, we use 'up' instead of 'run' to keep the service running - # and we don't use --rm because we might want to inspect logs - docker compose ${compose_opts[@]+"${compose_opts[@]}"} --profile server up \ - "${SERVER_SERVICE_NAME}" -} - -# Cleanup function for run script -run_cleanup() { - if [[ "${CLEANUP_AFTER:-false}" == "true" ]]; then - log_info "Cleaning up containers and images after run..." - ./scripts/cleanup.sh -a -f - fi -} - -# Main execution -main() { - local RUN_MODE="production" - local BUILD_FIRST="false" - local CLEANUP_AFTER="false" - local VERBOSE="false" + local up_opts=() + [[ "${build_first}" == "true" ]] && up_opts+=(--build) + [[ "${foreground}" == "false" ]] && up_opts+=(-d) - # Parse options - while [[ $# -gt 0 ]]; do - case $1 in - -d|--dev) - RUN_MODE="development" - shift - ;; - -p|--production) - RUN_MODE="production" - shift - ;; - -b|--build) - BUILD_FIRST="true" - shift - ;; - --clean) - CLEANUP_AFTER="true" - shift - ;; - -v|--verbose) - VERBOSE="true" - shift - ;; - -s|--server) - RUN_MODE="server" - shift - ;; - -h|--help) - show_usage - exit 0 - ;; - *) - # Break on first non-option argument - break - ;; - esac - done + log_info "Starting GUI via docker compose..." + DOCKER_BUILDKIT=1 docker compose ${compose_opts[@]+"${compose_opts[@]}"} \ + -f "${COMPOSE_FILE}" up ${up_opts[@]+"${up_opts[@]}"} - # Consume the separator if present - if [[ "${1:-}" == "--" ]]; then - shift + if [[ "${foreground}" == "false" ]]; then + log_success "GUI running at http://localhost:8989 (detached)" + log_info "Logs: docker compose logs -f" + log_info "Stop: docker compose down (or ./scripts/cleanup.sh)" fi - - log_info "Starting OpenUTM Verification Tool..." - check_dependencies - check_files - create_folders - - # Set up cleanup trap - trap 'run_cleanup' EXIT - - case "${RUN_MODE}" in - "production") - run_production "$@" - ;; - "development") - run_development "$@" - ;; - "server") - run_server "$@" - ;; - *) - log_error "Invalid run mode: ${RUN_MODE}" - exit 1 - ;; - esac - - log_success "Verification run completed successfully" } -# Run main function with all arguments main "$@" diff --git a/src/openutm_verification/core/execution/config_models.py b/src/openutm_verification/core/execution/config_models.py index 0f0b4733..043d5c83 100644 --- a/src/openutm_verification/core/execution/config_models.py +++ b/src/openutm_verification/core/execution/config_models.py @@ -204,7 +204,13 @@ def resolve_paths(self, config_file_path: Path) -> None: # and some uses pass a config file path. Support both to avoid # incorrect path resolution in containerized runs. if config_file_path.is_file(): - base_path = config_file_path.parent.parent + base_path = config_file_path.parent + # If the file lives in a `config/` directory, resolve relative + # data-file paths from the project root (one level up). For configs + # placed elsewhere (e.g. /config.yaml), use the file's + # own directory so we don't escape above the project root. + if base_path.name == "config": + base_path = base_path.parent else: base_path = config_file_path diff --git a/src/openutm_verification/server/main.py b/src/openutm_verification/server/main.py index 54b820c4..99cbcc79 100644 --- a/src/openutm_verification/server/main.py +++ b/src/openutm_verification/server/main.py @@ -18,6 +18,7 @@ import openutm_verification.core.execution.dependencies # noqa: F401 from openutm_verification.core.execution.config_models import ( AirTrafficSimulatorSettings, + AppConfig, ConfigProxy, DataFiles, FlightBlenderConfig, @@ -130,16 +131,22 @@ async def get_operations(runner: SessionManager = Depends(get_session_manager)): async def get_config(runner: SessionManager = Depends(get_session_manager)): """Return the full server config the GUI manages. - Re-reads from disk first so the GUI's Reload button reflects any - out-of-band edits to the YAML file (e.g. someone editing in an editor - while the server is running). Falls back to the in-memory config if the - reload fails so a transient parse error doesn't break the screen. + Re-reads the YAML from disk into a transient ``AppConfig`` so the GUI + reflects out-of-band edits without touching the live session. The running + session's ``runner.config`` is intentionally left alone here so that + simply opening the Settings screen never resets an in-progress run. + Falls back to the in-memory copy on parse errors so a transient bad file + doesn't break the screen. """ + import yaml as _yaml + + cfg = runner.config try: - await runner.reload_config() + with open(runner.config_path, "r", encoding="utf-8") as f: + raw = _yaml.safe_load(f) + cfg = AppConfig.model_validate(raw) except Exception: # noqa: BLE001 - logger.exception("reload_config during GET /api/config failed; serving in-memory copy") - cfg = runner.config + logger.exception("Re-reading config during GET /api/config failed; serving in-memory copy") return { "version": cfg.version, "run_id": cfg.run_id, @@ -173,9 +180,20 @@ async def put_config( Only the keys in ``_EDITABLE_CONFIG_KEYS`` are honored. Comments and formatting in the file are preserved via ruamel.yaml round-trip mode. + Refuses to save while a scenario is running (the reload would tear down + in-flight clients). Validates the candidate config before writing, and + writes atomically (temp file + rename) so a failed save can never leave + a half-written YAML on disk. """ + from pydantic import ValidationError from ruamel.yaml import YAML + if runner.current_run_task is not None and not runner.current_run_task.done(): + return { + "status": "error", + "message": "Cannot save config while a scenario is running. Stop the run first.", + } + config_path = runner.config_path if not config_path.exists(): return {"status": "error", "message": f"Config file not found: {config_path}"} @@ -189,12 +207,32 @@ async def put_config( applied: list[str] = [] for key in _EDITABLE_CONFIG_KEYS: - if key in payload and payload[key] is not None: + # Apply the key whenever it appears in the payload, including when + # its value is null, so the GUI can clear optional sections (e.g. + # set ``amqp: null`` to remove AMQP config). + if key in payload: doc[key] = payload[key] applied.append(key) - with open(config_path, "w", encoding="utf-8") as f: - yaml_rt.dump(doc, f) + # Validate the would-be config before touching disk so we never persist + # a broken YAML that would block future reloads/restarts. + try: + AppConfig.model_validate(dict(doc)) + except ValidationError as exc: + logger.warning(f"Rejected config save to {config_path}: validation failed") + return {"status": "error", "message": f"Invalid config: {exc}"} + + tmp_path = config_path.with_suffix(config_path.suffix + ".tmp") + try: + with open(tmp_path, "w", encoding="utf-8") as f: + yaml_rt.dump(doc, f) + os.replace(tmp_path, config_path) + finally: + if tmp_path.exists(): + try: + tmp_path.unlink() + except OSError: + pass logger.info(f"Wrote {applied} to {config_path}; reloading config") try: diff --git a/src/openutm_verification/server/runner.py b/src/openutm_verification/server/runner.py index 5fa394f5..f6314d1a 100644 --- a/src/openutm_verification/server/runner.py +++ b/src/openutm_verification/server/runner.py @@ -353,8 +353,13 @@ async def reload_config(self) -> AppConfig: """Re-read the config file from disk and re-initialize the session. Used after the GUI writes new values via PUT /api/config so the - running server picks them up without a restart. + running server picks them up without a restart. Refuses to reload + while a scenario run is active because tearing down the session + stack out from under in-flight tasks leaves them executing against + torn-down dependencies. Callers should ``stop_scenario()`` first. """ + if self.current_run_task is not None and not self.current_run_task.done(): + raise RuntimeError("Cannot reload config while a scenario run is active; stop it first") await self.close_session() self.current_output_dir = None self.current_timestamp_str = None diff --git a/uv.lock b/uv.lock index b9c474a9..8c2a6f13 100644 --- a/uv.lock +++ b/uv.lock @@ -148,11 +148,11 @@ dependencies = [ [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.4.22" 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/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } 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/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] [[package]] @@ -296,14 +296,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.2" +version = "8.3.3" 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/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } 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/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, ] [[package]] @@ -587,7 +587,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.136.0" +version = "0.136.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -596,9 +596,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4e/d9/e66315807e41e69e7f6a1b42a162dada2f249c5f06ad3f1a95f84ab336ef/fastapi-0.136.0.tar.gz", hash = "sha256:cf08e067cc66e106e102d9ba659463abfac245200752f8a5b7b1e813de4ff73e", size = 396607, upload-time = "2026-04-16T11:47:13.623Z" } +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/26/a3/0bd5f0cdb0bbc92650e8dc457e9250358411ee5d1b65e42b6632387daf81/fastapi-0.136.0-py3-none-any.whl", hash = "sha256:8793d44ec7378e2be07f8a013cf7f7aa47d6327d0dfe9804862688ec4541a6b4", size = 117556, upload-time = "2026-04-16T11:47:11.922Z" }, + { 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]] @@ -778,11 +778,11 @@ wheels = [ [[package]] name = "idna" -version = "3.12" +version = "3.13" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/12/2948fbe5513d062169bd91f7d7b1cd97bc8894f32946b71fa39f6e63ca0c/idna-3.12.tar.gz", hash = "sha256:724e9952cc9e2bd7550ea784adb098d837ab5267ef67a1ab9cf7846bdbdd8254", size = 194350, upload-time = "2026-04-21T13:32:48.916Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/b2/acc33950394b3becb2b664741a0c0889c7ef9f9ffbfa8d47eddb53a50abd/idna-3.12-py3-none-any.whl", hash = "sha256:60ffaa1858fac94c9c124728c24fcde8160f3fb4a7f79aa8cdd33a9d1af60a67", size = 68634, upload-time = "2026-04-21T13:32:47.403Z" }, + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, ] [[package]] @@ -1463,7 +1463,7 @@ wheels = [ [[package]] name = "openutm-verification" -version = "0.1.3" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "arrow" }, @@ -1658,11 +1658,11 @@ wheels = [ [[package]] name = "pathspec" -version = "1.0.4" +version = "1.1.0" 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/2e/17/9c3094b822982b9f1ea666d8580ce59000f61f87c1663556fb72031ad9ec/pathspec-1.1.0.tar.gz", hash = "sha256:f5d7c555da02fd8dde3e4a2354b6aba817a89112fa8f333f7917a2a4834dd080", size = 133918, upload-time = "2026-04-23T01:46:22.298Z" } 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/fa/c9/8eed0486f074e9f1ca7f8ce5ad663e65f12fdab344028d658fa1b03d35e0/pathspec-1.1.0-py3-none-any.whl", hash = "sha256:574b128f7456bd899045ccd142dd446af7e6cfd0072d63ad73fbc55fbb4aaa42", size = 56264, upload-time = "2026-04-23T01:46:20.606Z" }, ] [[package]] @@ -2733,15 +2733,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.45.0" +version = "0.46.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/62b0d9a2cfc8b4de6771322dae30f2db76c66dae9ec32e94e176a44ad563/uvicorn-0.45.0.tar.gz", hash = "sha256:3fe650df136c5bd2b9b06efc5980636344a2fbb840e9ddd86437d53144fa335d", size = 87818, upload-time = "2026-04-21T10:43:46.815Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/88/d0f7512465b166a4e931ccf7e77792be60fb88466a43964c7566cbaff752/uvicorn-0.45.0-py3-none-any.whl", hash = "sha256:2db26f588131aeec7439de00f2dd52d5f210710c1f01e407a52c90b880d1fd4f", size = 69838, upload-time = "2026-04-21T10:43:45.029Z" }, + { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, ] [package.optional-dependencies] diff --git a/web-editor/src/components/ScenarioEditor/ConfigEditor.tsx b/web-editor/src/components/ScenarioEditor/ConfigEditor.tsx index 7e9b7b26..db94508a 100644 --- a/web-editor/src/components/ScenarioEditor/ConfigEditor.tsx +++ b/web-editor/src/components/ScenarioEditor/ConfigEditor.tsx @@ -79,7 +79,7 @@ export const ConfigEditor: React.FC = ({ config, onUpdateConf }); }; - const updateAirTrafficSettings = (field: string, value: string | number | string[]) => { + const updateAirTrafficSettings = (field: string, value: string | number | string[] | undefined) => { onUpdateConfig({ ...config, air_traffic_simulator_settings: { @@ -89,6 +89,15 @@ export const ConfigEditor: React.FC = ({ config, onUpdateConf }); }; + // Parse a numeric input value, returning undefined when the field is + // cleared so we don't propagate NaN into the config state (which would + // serialize as null in JSON and fail server-side validation). + const parseIntOrUndefined = (raw: string): number | undefined => { + if (raw === '') return undefined; + const n = parseInt(raw, 10); + return Number.isNaN(n) ? undefined : n; + }; + const updateSensorIds = (value: string) => { const ids = value.split(',').map(id => id.trim()).filter(id => id.length > 0); updateAirTrafficSettings('sensor_ids', ids); @@ -362,7 +371,7 @@ export const ConfigEditor: React.FC = ({ config, onUpdateConf type="number" className={styles.paramInput} value={config.air_traffic_simulator_settings?.number_of_aircraft ?? ''} - onChange={(e) => updateAirTrafficSettings('number_of_aircraft', parseInt(e.target.value))} + onChange={(e) => updateAirTrafficSettings('number_of_aircraft', parseIntOrUndefined(e.target.value))} min="1" max="100" /> @@ -374,7 +383,7 @@ export const ConfigEditor: React.FC = ({ config, onUpdateConf type="number" className={styles.paramInput} value={config.air_traffic_simulator_settings?.simulation_duration ?? ''} - onChange={(e) => updateAirTrafficSettings('simulation_duration', parseInt(e.target.value))} + onChange={(e) => updateAirTrafficSettings('simulation_duration', parseIntOrUndefined(e.target.value))} min="1" max="3600" /> From b5571f997da9013c4468d2ec43ac87b363b69749 Mon Sep 17 00:00:00 2001 From: Roman Pszonka Date: Thu, 23 Apr 2026 22:17:50 +0100 Subject: [PATCH 3/5] Update pre-commit hook versions for improved functionality and fixes --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6c2cae6b..175dc0c0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,11 +13,11 @@ repos: - id: check-shebang-scripts-are-executable - id: mixed-line-ending - repo: https://github.com/Lucas-C/pre-commit-hooks - rev: v1.5.5 + rev: v1.5.6 hooks: - id: remove-crlf - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.5 + rev: v0.15.11 hooks: - id: ruff-check types_or: [python, pyi] @@ -35,7 +35,7 @@ repos: hooks: - id: blacken-docs - repo: https://github.com/codespell-project/codespell - rev: v2.4.1 + rev: v2.4.2 hooks: - id: codespell additional_dependencies: From 69ae218a20c69d75eec934d210f4a4a03d49bb4a Mon Sep 17 00:00:00 2001 From: Roman Pszonka Date: Sat, 25 Apr 2026 13:50:17 +0100 Subject: [PATCH 4/5] Refactor ConfigScreen to update AMQP defaults and remove ConfigEditor component - Updated the default values for the AMQP configuration in ConfigScreen to align with backend model. - Removed the ConfigEditor component, which included configuration settings for Flight Blender, data files, and air traffic simulator. Co-authored-by: Copilot --- Dockerfile | 94 ++-- .../bern}/geo_fence.geojson | 0 config/default.yaml | 18 +- config/pull_request.yaml | 2 +- docker-compose.yml | 15 +- .../core/execution/config_models.py | 21 +- .../core/execution/scenario_runner.py | 2 +- src/openutm_verification/scenarios/common.py | 6 - .../server/config_redaction.py | 68 +++ src/openutm_verification/server/main.py | 24 +- tests/test_config_redaction.py | 107 +++++ uv.lock | 137 +++--- web-editor/src/components/ConfigScreen.tsx | 5 +- .../ScenarioEditor/ConfigEditor.tsx | 420 ------------------ 14 files changed, 345 insertions(+), 574 deletions(-) rename {src/openutm_verification/assets/aoi_geo_fence_samples => config/bern}/geo_fence.geojson (100%) create mode 100644 src/openutm_verification/server/config_redaction.py create mode 100644 tests/test_config_redaction.py delete mode 100644 web-editor/src/components/ScenarioEditor/ConfigEditor.tsx diff --git a/Dockerfile b/Dockerfile index eff4272b..f6fc436d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,97 +23,87 @@ FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder ARG UV_COMPILE_BYTECODE=1 ARG UV_LINK_MODE=copy -# Install build dependencies +# `git` is required because one dependency (`cam-track-gen`) is fetched from +# a Git repo (see `pyproject.toml`); uv shells out to the `git` CLI to clone +# it. All other dependencies resolve to prebuilt wheels, so no C toolchain +# is needed. RUN apt-get update \ && apt-get install -y --no-install-recommends \ - build-essential \ git \ - pkg-config \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* WORKDIR /app -# Configure UV -ENV UV_COMPILE_BYTECODE=${UV_COMPILE_BYTECODE} -ENV UV_LINK_MODE=${UV_LINK_MODE} -ENV PYTHONUNBUFFERED=1 +ENV UV_COMPILE_BYTECODE=${UV_COMPILE_BYTECODE} \ + UV_LINK_MODE=${UV_LINK_MODE} \ + PYTHONUNBUFFERED=1 -# Install dependencies first for better caching +# Resolve dependencies first (cached unless lockfile changes). +# `docs/scenarios` is force-included into the wheel by hatch, so the docs +# tree must be present at build time. COPY pyproject.toml uv.lock ./ COPY docs ./docs -COPY scenarios ./scenarios - RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen --no-install-project --no-dev -# Copy application source and install +# Build & install just the project wheel (no editable, no tests). COPY LICENSE README.md ./ COPY src ./src -COPY tests ./tests - -RUN uv pip install --no-deps . && rm -f LICENSE +RUN uv pip install --no-deps . # --- Production Runtime Stage --- -FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS production +# Plain python image — `uv` is only needed at build time, so dropping it +# from the runtime keeps the final image smaller. +FROM python:3.12-slim-bookworm AS production ARG APP_USER=appuser ARG APP_GROUP=appgrp ARG UID=1000 ARG GID=1000 -# Install minimal runtime dependencies -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - curl \ - tzdata \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* +# No extra runtime apt packages — Python's stdlib covers the healthcheck, +# and `tzdata` ships with the Python image. -# Configure environment for GUI server mode -ENV PYTHONUNBUFFERED=1 -ENV TZ=UTC -ENV PATH="/app/.venv/bin:$PATH" -ENV WEB_EDITOR_PATH=/app/web-editor -ENV SCENARIOS_PATH=/app/scenarios -ENV DOCS_PATH=/app/docs -# Configuration and reports paths (typically mounted as volumes) -ENV OPENUTM_CONFIG_PATH=config/default.yaml -ENV REPORTS_DIR=reports - -# Create non-root user +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + TZ=UTC \ + PATH="/app/.venv/bin:$PATH" \ + WEB_EDITOR_PATH=/app/web-editor \ + SCENARIOS_PATH=/app/scenarios \ + DOCS_PATH=/app/docs \ + OPENUTM_CONFIG_PATH=config/default.yaml \ + REPORTS_DIR=reports + +# Non-root user RUN (getent group "${GID}" || groupadd -g "${GID}" "${APP_GROUP}") \ && useradd -u "${UID}" -g "${GID}" -s /bin/sh -m "${APP_USER}" -# Copy application from builder -COPY --chown=${UID}:${GID} --from=builder /app /app +WORKDIR /app -# Copy built UI from ui-builder +# Copy only what's needed at runtime: the venv (project + deps installed, +# including the package's bundled `assets/` data files), the docs/scenarios +# trees used as defaults, and the built UI bundle. The `src/` tree is NOT +# copied — the wheel installed into the venv contains everything the app +# imports, and sample data files live under `config/` (bind-mounted). +COPY --chown=${UID}:${GID} --from=builder /app/.venv /app/.venv +COPY --chown=${UID}:${GID} docs /app/docs +COPY --chown=${UID}:${GID} scenarios /app/scenarios COPY --chown=${UID}:${GID} --from=ui-builder /app/web-editor/dist /app/web-editor/dist -WORKDIR /app - -# Create necessary directories +# Volume targets (config + reports are normally bind-mounted in compose). RUN mkdir -p /app/config /app/reports \ && chown -R ${UID}:${GID} /app/config /app/reports USER ${UID}:${GID} -# Expose the server port (backend serves both API and frontend) EXPOSE 8989 -# Health check for the API HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:8989/health || exit 1 + CMD python -c "import urllib.request, sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8989/health', timeout=5).status == 200 else 1)" || exit 1 -# Start the server in GUI mode -# The backend serves the built frontend at / (via StaticFiles mount) and API at: -# - /health, /operations, /session/*, /run-scenario*, /stop-scenario -# - /reports (static mounted directory for report access) -# -# Environment variables: -# OPENUTM_CONFIG_PATH: Path to YAML config (default: config/default.yaml) -# FLIGHT_BLENDER_URL: Override Flight Blender endpoint -# LOG_LEVEL: Logging verbosity (DEBUG, INFO, WARNING, ERROR) +# Backend serves both API and frontend (StaticFiles mount at /). +# Honored env vars: +# OPENUTM_CONFIG_PATH, FLIGHT_BLENDER_URL, LOG_LEVEL ENTRYPOINT ["python", "-m", "uvicorn", "openutm_verification.server.main:app"] CMD ["--host", "0.0.0.0", "--port", "8989"] diff --git a/src/openutm_verification/assets/aoi_geo_fence_samples/geo_fence.geojson b/config/bern/geo_fence.geojson similarity index 100% rename from src/openutm_verification/assets/aoi_geo_fence_samples/geo_fence.geojson rename to config/bern/geo_fence.geojson diff --git a/config/default.yaml b/config/default.yaml index 34db4b12..6316fc61 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -15,7 +15,7 @@ flight_blender: # token_endpoint: "/oauth/token/" # passport_base_url: "https://passport.testflight.openutm.net" audience: "testflight.flightblender.com" # Audience for OAuth tokens, default for local testing - scopes: ["flightblender.write", "flightblender.read"] # Scopes for write and read access + scopes: [ "flightblender.write", "flightblender.read" ] # Scopes for write and read access # Details for connecting to the OpenSky Network API opensky: @@ -29,8 +29,8 @@ air_traffic_simulator_settings: number_of_aircraft: 3 simulation_duration: 10 single_or_multiple_sensors: "single" # this setting specifies if the traffic data is submitted from a single sensor or multiple sensors - sensor_ids: ["a0b7d47e5eac45dc8cbaf47e6fe0e558"] # List of sensor IDs to use when 'multiple' is selected - session_ids: ["ee9405e564ea4373823e37d950858e6a"] # List of session IDs to use when 'multiple' is selected + sensor_ids: [ "a0b7d47e5eac45dc8cbaf47e6fe0e558" ] # List of sensor IDs to use when 'multiple' is selected + session_ids: [ "ee9405e564ea4373823e37d950858e6a" ] # List of session IDs to use when 'multiple' is selected # AMQP/RabbitMQ configuration for event monitoring # Set AMQP_URL environment variable or configure here @@ -46,7 +46,7 @@ data_files: simulation: "config/bern/blue_sky_sim_example.scn" # Path to air traffic simulation scenario file, used for BlueSky enabled simulations only. flight_declaration: "config/bern/flight_declaration.json" # Path to flight declarations JSON file flight_declaration_via_operational_intent: "config/bern/flight_declaration_via_operational_intent.json" # Path to flight declaration via operational intent JSON file - geo_fence: "src/openutm_verification/assets/aoi_geo_fence_samples/geo_fence.geojson" # Path to geo-fences + geo_fence: "config/bern/geo_fence.geojson" # Path to geo-fences # List of test scenario IDs to execute suites: @@ -74,10 +74,10 @@ suites: simulation: "config/bern/blue_sky_sim_example.scn" extra: scenarios: - - name: F3_non_conforming_path - trajectory: "config/bern/trajectory_f3.json" - - name: add_flight_declaration - - name: geo_fence_upload + - name: F3_non_conforming_path + trajectory: "config/bern/trajectory_f3.json" + - name: add_flight_declaration + - name: geo_fence_upload astm_f3442: scenarios: - name: daa_a1_head_on_cooperative @@ -92,7 +92,7 @@ suites: # Reporting configuration reporting: output_dir: "reports" - formats: ["json", "html", "log"] # 'json' is raw, 'html' is summarized + formats: [ "json", "html", "log" ] # 'json' is raw, 'html' is summarized # A place to add metadata about the system under test deployment_details: name: "Local Flight Blender Dev Instance" diff --git a/config/pull_request.yaml b/config/pull_request.yaml index 9286b87e..415b4ebd 100644 --- a/config/pull_request.yaml +++ b/config/pull_request.yaml @@ -28,7 +28,7 @@ air_traffic_simulator_settings: data_files: trajectory: "config/bern/trajectory_f1.json" # Path to flight declarations JSON file flight_declaration: "config/bern/flight_declaration.json" # Path to flight declarations JSON file - geo_fence: "src/openutm_verification/assets/aoi_geo_fence_samples/geo_fence.geojson" + geo_fence: "config/bern/geo_fence.geojson" # List of test scenario IDs to execute suites: diff --git a/docker-compose.yml b/docker-compose.yml index 4edd1df6..cb0bd8d7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,8 +29,11 @@ services: - TZ=UTC - LOG_LEVEL=${LOG_LEVEL:-INFO} - FLIGHT_BLENDER_URL=${FLIGHT_BLENDER_URL:-http://host.docker.internal:8000} + # Bind to localhost by default. The GUI/API are unauthenticated and + # /api/config supports live edits, so exposing on all interfaces is unsafe. + # To expose intentionally, set HOST_BIND=0.0.0.0 (or a specific interface). ports: - - "8989:8989" + - "${HOST_BIND:-127.0.0.1}:8989:8989" # Volume mounts: # - config: YAML config (read-write so the in-app Settings screen can # persist edits via PUT /api/config back to default.yaml) @@ -39,10 +42,12 @@ services: # them without rebuilding the image) # # For Kubernetes: use ConfigMap for config, PersistentVolumeClaim for reports. + # The `:Z` flag relabels mounts for SELinux hosts (Fedora/RHEL); it's + # a no-op on macOS/Linux distros without SELinux. volumes: - - ./config:/app/config:rw - - ./reports:/app/reports:rw - - ./scenarios:/app/scenarios:rw + - ./config:/app/config:rw,Z + - ./reports:/app/reports:rw,Z + - ./scenarios:/app/scenarios:rw,Z extra_hosts: - "host.docker.internal:host-gateway" deploy: @@ -54,7 +59,7 @@ services: memory: 256M cpus: '0.5' healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8989/health"] + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8989/health',timeout=5).status==200 else 1)"] interval: 30s timeout: 10s retries: 3 diff --git a/src/openutm_verification/core/execution/config_models.py b/src/openutm_verification/core/execution/config_models.py index 043d5c83..6db33dc6 100644 --- a/src/openutm_verification/core/execution/config_models.py +++ b/src/openutm_verification/core/execution/config_models.py @@ -204,13 +204,20 @@ def resolve_paths(self, config_file_path: Path) -> None: # and some uses pass a config file path. Support both to avoid # incorrect path resolution in containerized runs. if config_file_path.is_file(): - base_path = config_file_path.parent - # If the file lives in a `config/` directory, resolve relative - # data-file paths from the project root (one level up). For configs - # placed elsewhere (e.g. /config.yaml), use the file's - # own directory so we don't escape above the project root. - if base_path.name == "config": - base_path = base_path.parent + config_dir = config_file_path.parent + base_path = config_dir + # If the file lives anywhere within a `config/` directory (e.g. + # `config/default.yaml` or `config/local/dev.yaml`), resolve + # relative data-file paths from that `config/` directory's + # parent (the project root). For configs placed elsewhere + # (e.g. `/config.yaml`), use the file's own directory + # so we don't escape above the project root. + config_root = next( + (candidate for candidate in (config_dir, *config_dir.parents) if candidate.name == "config"), + None, + ) + if config_root is not None: + base_path = config_root.parent else: base_path = config_file_path diff --git a/src/openutm_verification/core/execution/scenario_runner.py b/src/openutm_verification/core/execution/scenario_runner.py index 7cc69f4a..2f44cb94 100644 --- a/src/openutm_verification/core/execution/scenario_runner.py +++ b/src/openutm_verification/core/execution/scenario_runner.py @@ -271,7 +271,7 @@ def handle_exception(e: Exception, start_time: float) -> StepResult[Any]: step_result: StepResult[Any] if isinstance(e, (FlightBlenderError, OpenSkyError)): logger.error(f"Step '{step_name}' failed after {duration:.2f} seconds: {e}") - step_result = StepResult( + step_result: StepResult = StepResult( name=step_name, phase=phase, status=Status.FAIL, diff --git a/src/openutm_verification/scenarios/common.py b/src/openutm_verification/scenarios/common.py index 83d648e0..9b4713b5 100644 --- a/src/openutm_verification/scenarios/common.py +++ b/src/openutm_verification/scenarios/common.py @@ -62,9 +62,3 @@ def generate_telemetry( except Exception as e: logger.error(f"Failed to generate telemetry states from {config_path}: {e}") raise - - -def get_geo_fence_path(geo_fence_filename: str) -> str: - """Helper to get the full path to a geo-fence file.""" - parent_dir = Path(__file__).parent.resolve() - return str(parent_dir / f"../assets/aoi_geo_fence_samples/{geo_fence_filename}") diff --git a/src/openutm_verification/server/config_redaction.py b/src/openutm_verification/server/config_redaction.py new file mode 100644 index 00000000..8226b6e2 --- /dev/null +++ b/src/openutm_verification/server/config_redaction.py @@ -0,0 +1,68 @@ +"""Helpers for redacting secrets in config payloads served by the API. + +The unauthenticated GUI/API must never echo real credentials back to clients. +``redact_auth`` and ``redact_amqp`` produce safe copies for ``GET /api/config`` +responses; ``strip_redacted`` drops the placeholder on ``PUT /api/config`` so a +GET→PUT round-trip never overwrites the on-disk secret with the sentinel. +""" + +from typing import Any + +# Sentinel returned to the GUI in place of secret values. Non-empty (so users +# can tell the field is populated) but unmistakably not a real credential, and +# explicitly ignored by PUT /api/config so a round-trip never persists it. +REDACTED = "***REDACTED***" + + +def redact_auth(auth: dict[str, Any]) -> dict[str, Any]: + """Redact secret fields in an AuthConfig dict (returns a shallow copy).""" + redacted = dict(auth) + if redacted.get("client_secret"): + redacted["client_secret"] = REDACTED + return redacted + + +def redact_amqp(amqp: dict[str, Any] | None) -> dict[str, Any] | None: + """Redact embedded credentials in the AMQP URL (e.g. amqp://user:pass@…).""" + if not amqp: + return amqp + redacted = dict(amqp) + url = redacted.get("url") or "" + # Strip any "user:pass@" segment from the URL while keeping host/port/path. + if "@" in url and "://" in url: + scheme, rest = url.split("://", 1) + creds, _, host = rest.partition("@") + if ":" in creds: + user, _ = creds.split(":", 1) + redacted["url"] = f"{scheme}://{user}:{REDACTED}@{host}" + return redacted + + +def strip_redacted(key: str, new_value: Any, existing_value: Any) -> Any: + """Drop redaction sentinels from ``new_value`` so a round-trip GET→PUT + never overwrites the on-disk secret with the placeholder. + + Specifically: + - ``flight_blender.auth.client_secret`` / ``opensky.auth.client_secret`` + that equal the redaction sentinel are replaced with the existing + on-disk value (or dropped if no prior value exists). + - ``amqp.url`` containing the redaction sentinel as the password is + replaced with the existing on-disk URL. + """ + if new_value is None or not isinstance(new_value, dict): + return new_value + cleaned = dict(new_value) + if key in ("flight_blender", "opensky"): + auth = cleaned.get("auth") + if isinstance(auth, dict) and auth.get("client_secret") == REDACTED: + existing_secret = "" + if isinstance(existing_value, dict): + existing_auth = existing_value.get("auth") or {} + existing_secret = existing_auth.get("client_secret", "") + auth["client_secret"] = existing_secret + cleaned["auth"] = auth + elif key == "amqp": + url = cleaned.get("url") or "" + if REDACTED in url and isinstance(existing_value, dict): + cleaned["url"] = existing_value.get("url", url) + return cleaned diff --git a/src/openutm_verification/server/main.py b/src/openutm_verification/server/main.py index 99cbcc79..d65975cd 100644 --- a/src/openutm_verification/server/main.py +++ b/src/openutm_verification/server/main.py @@ -29,6 +29,11 @@ ScenarioResult, Status, ) +from openutm_verification.server.config_redaction import ( + redact_amqp, + redact_auth, + strip_redacted, +) from openutm_verification.server.router import scenario_router from openutm_verification.server.runner import SessionManager @@ -137,6 +142,11 @@ async def get_config(runner: SessionManager = Depends(get_session_manager)): simply opening the Settings screen never resets an in-progress run. Falls back to the in-memory copy on parse errors so a transient bad file doesn't break the screen. + + Secrets (auth ``client_secret`` values, AMQP URL passwords) are redacted + on response so the unauthenticated GUI/API never echoes credentials. The + matching PUT endpoint ignores the redaction sentinel so round-tripping + edits doesn't overwrite the stored secret with the placeholder. """ import yaml as _yaml @@ -147,13 +157,19 @@ async def get_config(runner: SessionManager = Depends(get_session_manager)): cfg = AppConfig.model_validate(raw) except Exception: # noqa: BLE001 logger.exception("Re-reading config during GET /api/config failed; serving in-memory copy") + + fb = cfg.flight_blender.model_dump() + fb["auth"] = redact_auth(fb.get("auth", {})) + opensky = cfg.opensky.model_dump() + opensky["auth"] = redact_auth(opensky.get("auth", {})) + return { "version": cfg.version, "run_id": cfg.run_id, "config_path": str(runner.config_path), - "flight_blender": cfg.flight_blender.model_dump(), - "opensky": cfg.opensky.model_dump(), - "amqp": cfg.amqp.model_dump() if cfg.amqp else None, + "flight_blender": fb, + "opensky": opensky, + "amqp": redact_amqp(cfg.amqp.model_dump() if cfg.amqp else None), "data_files": cfg.data_files.model_dump(), "air_traffic_simulator_settings": (cfg.air_traffic_simulator_settings.model_dump() if cfg.air_traffic_simulator_settings else None), } @@ -211,7 +227,7 @@ async def put_config( # its value is null, so the GUI can clear optional sections (e.g. # set ``amqp: null`` to remove AMQP config). if key in payload: - doc[key] = payload[key] + doc[key] = strip_redacted(key, payload[key], doc.get(key)) applied.append(key) # Validate the would-be config before touching disk so we never persist diff --git a/tests/test_config_redaction.py b/tests/test_config_redaction.py new file mode 100644 index 00000000..938fda49 --- /dev/null +++ b/tests/test_config_redaction.py @@ -0,0 +1,107 @@ +"""Unit tests for openutm_verification.server.config_redaction.""" + +from openutm_verification.server.config_redaction import ( + REDACTED, + redact_amqp, + redact_auth, + strip_redacted, +) + + +class TestRedactAuth: + def test_redacts_client_secret_when_present(self): + result = redact_auth({"client_id": "id", "client_secret": "supersecret"}) + assert result["client_secret"] == REDACTED + assert result["client_id"] == "id" + + def test_leaves_empty_secret_alone(self): + result = redact_auth({"client_id": "id", "client_secret": ""}) + assert result["client_secret"] == "" + + def test_handles_missing_secret(self): + result = redact_auth({"client_id": "id"}) + assert "client_secret" not in result + + def test_does_not_mutate_input(self): + original = {"client_secret": "supersecret"} + redact_auth(original) + assert original["client_secret"] == "supersecret" + + +class TestRedactAmqp: + def test_returns_none_for_none(self): + assert redact_amqp(None) is None + + def test_returns_falsy_unchanged(self): + assert redact_amqp({}) == {} + + def test_redacts_password_in_url(self): + result = redact_amqp({"url": "amqp://user:pass@host:5672/vhost"}) + assert result["url"] == f"amqp://user:{REDACTED}@host:5672/vhost" + + def test_preserves_url_without_credentials(self): + result = redact_amqp({"url": "amqp://host:5672/"}) + assert result["url"] == "amqp://host:5672/" + + def test_preserves_url_with_user_only(self): + # No colon in creds = no password to redact. + result = redact_amqp({"url": "amqp://user@host:5672/"}) + assert result["url"] == "amqp://user@host:5672/" + + def test_handles_empty_url(self): + result = redact_amqp({"url": ""}) + assert result["url"] == "" + + def test_does_not_mutate_input(self): + original = {"url": "amqp://user:pass@host"} + redact_amqp(original) + assert original["url"] == "amqp://user:pass@host" + + +class TestStripRedacted: + def test_returns_none_passthrough(self): + assert strip_redacted("flight_blender", None, {"auth": {"client_secret": "x"}}) is None + + def test_returns_non_dict_passthrough(self): + assert strip_redacted("flight_blender", "not-a-dict", {}) == "not-a-dict" + + def test_replaces_redacted_secret_with_existing(self): + new = {"auth": {"client_secret": REDACTED, "client_id": "id"}} + existing = {"auth": {"client_secret": "real-secret"}} + result = strip_redacted("flight_blender", new, existing) + assert result["auth"]["client_secret"] == "real-secret" + + def test_replaces_redacted_secret_with_empty_when_no_prior(self): + new = {"auth": {"client_secret": REDACTED}} + result = strip_redacted("flight_blender", new, None) + assert result["auth"]["client_secret"] == "" + + def test_keeps_real_new_secret(self): + new = {"auth": {"client_secret": "new-secret"}} + existing = {"auth": {"client_secret": "old-secret"}} + result = strip_redacted("opensky", new, existing) + assert result["auth"]["client_secret"] == "new-secret" + + def test_amqp_url_with_redacted_password_falls_back_to_existing(self): + new = {"url": f"amqp://user:{REDACTED}@host:5672/"} + existing = {"url": "amqp://user:realpw@host:5672/"} + result = strip_redacted("amqp", new, existing) + assert result["url"] == "amqp://user:realpw@host:5672/" + + def test_amqp_url_without_redaction_unchanged(self): + new = {"url": "amqp://user:newpw@host:5672/"} + existing = {"url": "amqp://user:oldpw@host:5672/"} + result = strip_redacted("amqp", new, existing) + assert result["url"] == "amqp://user:newpw@host:5672/" + + def test_amqp_redacted_url_with_no_existing_keeps_value(self): + new = {"url": f"amqp://user:{REDACTED}@host"} + result = strip_redacted("amqp", new, None) + # No existing dict to fall back on, so the (still-redacted) URL is + # left as-is. Validation downstream will reject it. + assert result["url"] == f"amqp://user:{REDACTED}@host" + + def test_unknown_key_passes_through(self): + new = {"foo": "bar", "auth": {"client_secret": REDACTED}} + result = strip_redacted("data_files", new, {}) + assert result == new diff --git a/uv.lock b/uv.lock index 8c2a6f13..9484fc69 100644 --- a/uv.lock +++ b/uv.lock @@ -824,7 +824,7 @@ wheels = [ [[package]] name = "ipython" -version = "9.12.0" +version = "9.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -834,13 +834,14 @@ dependencies = [ { name = "matplotlib-inline" }, { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, { name = "prompt-toolkit" }, + { name = "psutil" }, { name = "pygments" }, { name = "stack-data" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3a/73/7114f80a8f9cabdb13c27732dce24af945b2923dcab80723602f7c8bc2d8/ipython-9.12.0.tar.gz", hash = "sha256:01daa83f504b693ba523b5a407246cabde4eb4513285a3c6acaff11a66735ee4", size = 4428879, upload-time = "2026-03-27T09:42:45.312Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/87cda5842cf5c31837c06ddb588e11c3c35d8ece89b7a0108c06b8c9b00a/ipython-9.13.0.tar.gz", hash = "sha256:7e834b6afc99f020e3f05966ced34792f40267d64cb1ea9043886dab0dde5967", size = 4430549, upload-time = "2026-04-24T12:24:55.221Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/22/906c8108974c673ebef6356c506cebb6870d48cedea3c41e949e2dd556bb/ipython-9.12.0-py3-none-any.whl", hash = "sha256:0f2701e8ee86e117e37f50563205d36feaa259d2e08d4a6bc6b6d74b18ce128d", size = 625661, upload-time = "2026-03-27T09:42:42.831Z" }, + { url = "https://files.pythonhosted.org/packages/b9/86/3060e8029b7cc505cce9a0137431dda81d0a3fde93a8f0f50ee0bf37a795/ipython-9.13.0-py3-none-any.whl", hash = "sha256:57f9d4639e20818d328d287c7b549af3d05f12486ea8f2e7f73e52a36ec4d201", size = 627274, upload-time = "2026-04-24T12:24:53.038Z" }, ] [[package]] @@ -1197,7 +1198,7 @@ wheels = [ [[package]] name = "matplotlib" -version = "3.10.8" +version = "3.10.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "contourpy" }, @@ -1210,43 +1211,43 @@ dependencies = [ { name = "pyparsing" }, { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453, upload-time = "2025-12-10T22:55:30.709Z" }, - { url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321, upload-time = "2025-12-10T22:55:33.265Z" }, - { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944, upload-time = "2025-12-10T22:55:34.922Z" }, - { url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099, upload-time = "2025-12-10T22:55:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040, upload-time = "2025-12-10T22:55:38.715Z" }, - { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717, upload-time = "2025-12-10T22:55:41.103Z" }, - { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" }, - { url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076, upload-time = "2025-12-10T22:55:44.648Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794, upload-time = "2025-12-10T22:55:46.252Z" }, - { url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474, upload-time = "2025-12-10T22:55:47.864Z" }, - { url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637, upload-time = "2025-12-10T22:55:50.048Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678, upload-time = "2025-12-10T22:55:52.21Z" }, - { url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686, upload-time = "2025-12-10T22:55:54.253Z" }, - { url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917, upload-time = "2025-12-10T22:55:56.268Z" }, - { url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679, upload-time = "2025-12-10T22:55:57.856Z" }, - { url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336, upload-time = "2025-12-10T22:55:59.371Z" }, - { url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653, upload-time = "2025-12-10T22:56:01.032Z" }, - { url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356, upload-time = "2025-12-10T22:56:02.95Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" }, - { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" }, - { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" }, - { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" }, - { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" }, - { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" }, - { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" }, - { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" }, - { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" }, - { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" }, - { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" }, - { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" }, - { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" }, - { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/c6/5581e26c72233ebb2a2a6fed2d24fb7c66b4700120b813f51b0555acf0b6/matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1", size = 8319908, upload-time = "2026-04-24T00:12:21.323Z" }, + { url = "https://files.pythonhosted.org/packages/b7/18/4880dd762e40cd360c1bf06e890c5a97b997e91cb324602b1a19950ad5ce/matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320", size = 8216016, upload-time = "2026-04-24T00:12:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/d024616abdba99e83120e07a20658976f6a343646710760c4a51df126029/matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285", size = 8789336, upload-time = "2026-04-24T00:12:26.096Z" }, + { url = "https://files.pythonhosted.org/packages/5c/04/030a2f61ef2158f5e4c259487a92ac877732499fb33d871585d89e03c42d/matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2", size = 9604602, upload-time = "2026-04-24T00:12:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c2/541e4d09d87bb6b5830fc28b4c887a9a8cf4e1c6cee698a8c05552ae2003/matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf", size = 9670966, upload-time = "2026-04-24T00:12:32.131Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/4571fc46e7702de8d0c2dc54ad1b2f8e29328dea3ee90831181f7353d93c/matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6", size = 8217462, upload-time = "2026-04-24T00:12:35.226Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d0/2269edb12aa30c13c8bcc9382892e39943ce1d28aab4ec296e0381798e81/matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42", size = 8136688, upload-time = "2026-04-24T00:12:37.442Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d3/8d4f6afbecb49fc04e060a57c0fce39ea51cc163a6bd87303ccd698e4fa6/matplotlib-3.10.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b580440f1ff81a0e34122051a3dfabb7e4b7f9e380629929bde0eff9af72165f", size = 8320331, upload-time = "2026-04-24T00:12:39.688Z" }, + { url = "https://files.pythonhosted.org/packages/63/d9/9e14bc7564bf92d5ffa801ae5fac819ce74b925dfb55e3ebde61a3bbad3e/matplotlib-3.10.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b1b745c489cd1a77a0dc1120a05dc87af9798faebc913601feb8c73d89bf2d1e", size = 8216461, upload-time = "2026-04-24T00:12:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/8a/17/4402d0d14ccf1dfc70932600b68097fbbf9c898a4871d2cbbe79c7801a32/matplotlib-3.10.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f", size = 8790091, upload-time = "2026-04-24T00:12:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0b/322aeec06dd9b91411f92028b37d447342770a24392aa4813e317064dad5/matplotlib-3.10.9-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838", size = 9605027, upload-time = "2026-04-24T00:12:47.583Z" }, + { url = "https://files.pythonhosted.org/packages/74/88/5f13482f55e7b00bcfc09838b093c2456e1379978d2a146844aae05350ad/matplotlib-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2", size = 9671269, upload-time = "2026-04-24T00:12:50.878Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/0840fd2f93da988ec660b8ad1984abe9f25d2aed22a5e394ff1c68c88307/matplotlib-3.10.9-cp313-cp313-win_amd64.whl", hash = "sha256:f4399f64b3e94cd500195490972ae1ee81170df1636fa15364d157d5bdd7b921", size = 8217588, upload-time = "2026-04-24T00:12:53.784Z" }, + { url = "https://files.pythonhosted.org/packages/47/b9/d706d06dd605c49b9f83a2aed8c13e3e5db70697d7a80b7e3d7915de6b17/matplotlib-3.10.9-cp313-cp313-win_arm64.whl", hash = "sha256:ba7b3b8ef09eab7df0e86e9ae086faa433efbfbdb46afcb3aa16aabf779469a8", size = 8136913, upload-time = "2026-04-24T00:12:56.501Z" }, + { url = "https://files.pythonhosted.org/packages/9b/45/6e32d96978264c8ca8c4b1010adb955a1a49cfaf314e212bbc8908f04a61/matplotlib-3.10.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:09218df8a93712bd6ea133e83a153c755448cf7868316c531cffcc43f69d1cc9", size = 8368019, upload-time = "2026-04-24T00:12:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/0a/c8e3d3bba245f0f7fc424937f8ff7ef77291a36af3edb97ccd78aa93d84f/matplotlib-3.10.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:82368699727bfb7b0182e1aa13082e3c08e092fa1a25d3e1fd92405bff96f6d4", size = 8264645, upload-time = "2026-04-24T00:13:01.406Z" }, + { url = "https://files.pythonhosted.org/packages/3d/aa/5bf5a14fe4fed73a4209a155606f8096ff797aad89c6c35179026571133e/matplotlib-3.10.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc", size = 8802194, upload-time = "2026-04-24T00:13:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5e/b4be852d6bba6fd15893fadf91ff26ae49cb91aac789e95dde9d342e664f/matplotlib-3.10.9-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99", size = 9622684, upload-time = "2026-04-24T00:13:06.647Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/ed428c971139112ef730f62770654d609467346d09d4b62617e1afd68a5a/matplotlib-3.10.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d", size = 9680790, upload-time = "2026-04-24T00:13:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/e7/09/052e884aaf2b985c63cb79f715f1d5b6a3eaa7de78f6a52b9dbc077d5b53/matplotlib-3.10.9-cp313-cp313t-win_amd64.whl", hash = "sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8", size = 8287571, upload-time = "2026-04-24T00:13:13.087Z" }, + { url = "https://files.pythonhosted.org/packages/f4/38/ae27288e788c35a4250491422f3db7750366fc8c97d6f36fbdecfc1f5518/matplotlib-3.10.9-cp313-cp313t-win_arm64.whl", hash = "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38", size = 8188292, upload-time = "2026-04-24T00:13:15.546Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e6/3bd8afd04949f02eabc1c17115ea5255e19cacd4d06fc5abdde4eeb0052c/matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d", size = 8321276, upload-time = "2026-04-24T00:13:18.318Z" }, + { url = "https://files.pythonhosted.org/packages/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f", size = 8218218, upload-time = "2026-04-24T00:13:20.974Z" }, + { url = "https://files.pythonhosted.org/packages/85/8f/becc9722cafc64f5d2eb0b7c1bf5f585271c618a45dbd8fabeb021f898b6/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b", size = 9608145, upload-time = "2026-04-24T00:13:23.228Z" }, + { url = "https://files.pythonhosted.org/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2", size = 9885085, upload-time = "2026-04-24T00:13:25.849Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fd/fa69f2221534e80cc5772ac2b7d222011a2acafc2ec7216d5dd174c864ae/matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716", size = 9672358, upload-time = "2026-04-24T00:13:28.906Z" }, + { url = "https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f", size = 8349970, upload-time = "2026-04-24T00:13:31.904Z" }, + { url = "https://files.pythonhosted.org/packages/64/dc/95d60ecaefe30680a154b52ea96ab4b0dab547f1fd6aa12f5fb655e89cae/matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456", size = 8272785, upload-time = "2026-04-24T00:13:34.511Z" }, + { url = "https://files.pythonhosted.org/packages/70/a0/005d68bc8b8418300ce6591f18586910a8526806e2ab663933d9f20a41e9/matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe", size = 8367999, upload-time = "2026-04-24T00:13:36.962Z" }, + { url = "https://files.pythonhosted.org/packages/22/05/1236cc9290be70b2498af20ca348add76e3fffe7f67b477db5133a84f3ea/matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6", size = 8264543, upload-time = "2026-04-24T00:13:39.851Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c2/071f5a5ff6c5bd63aaaf2f45c811d9bf2ced94bde188d9e1a519e21d0cba/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c", size = 9622800, upload-time = "2026-04-24T00:13:42.296Z" }, + { url = "https://files.pythonhosted.org/packages/95/57/da7d1f10a85624b9e7db68e069dd94e58dc41dbf9463c5921632ecbe3661/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4", size = 9888561, upload-time = "2026-04-24T00:13:45.026Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/ef8d6bb59b0edb6c16c968b70f548aa13b54348972def5aa6ac85df67145/matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf", size = 9680884, upload-time = "2026-04-24T00:13:48.066Z" }, + { url = "https://files.pythonhosted.org/packages/61/1c/d21bfeb9931881ebe96bcfcff27c7ae4b160ae0ec291a714c42641a56d75/matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39", size = 8432333, upload-time = "2026-04-24T00:13:51.008Z" }, + { url = "https://files.pythonhosted.org/packages/78/23/92493c3e6e1b635ccfff146f7b99e674808787915420373ac399283764c2/matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c", size = 8324785, upload-time = "2026-04-24T00:13:53.633Z" }, ] [[package]] @@ -1588,11 +1589,11 @@ dev = [ [[package]] name = "packaging" -version = "26.1" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } +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/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, + { 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]] @@ -2385,27 +2386,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, - { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, - { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, - { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, - { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, - { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, - { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, - { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, - { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, - { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, - { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, - { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, - { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, - { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] [[package]] @@ -2703,11 +2704,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]] diff --git a/web-editor/src/components/ConfigScreen.tsx b/web-editor/src/components/ConfigScreen.tsx index 74c616b1..e3e4b8b8 100644 --- a/web-editor/src/components/ConfigScreen.tsx +++ b/web-editor/src/components/ConfigScreen.tsx @@ -197,8 +197,11 @@ export default function ConfigScreen() { setConfig({ ...config, flight_blender: { ...config.flight_blender, auth: { ...config.flight_blender.auth, ...patch } } }); const updateOpenSkyAuth = (patch: Partial) => setConfig({ ...config, opensky: { auth: { ...config.opensky.auth, ...patch } } }); + // Defaults mirror the backend AMQPConfig model so seeding an empty + // section doesn't silently overwrite users' existing values with mismatched + // defaults when only one field is being edited. const updateAMQP = (patch: Partial) => - setConfig({ ...config, amqp: { ...(config.amqp ?? { url: '', exchange_name: '', exchange_type: 'topic', routing_key: '#', queue_name: '' }), ...patch } }); + setConfig({ ...config, amqp: { ...(config.amqp ?? { url: '', exchange_name: 'operational_events', exchange_type: 'direct', routing_key: '#', queue_name: '' }), ...patch } }); const updateATS = (patch: Partial) => setConfig({ ...config, diff --git a/web-editor/src/components/ScenarioEditor/ConfigEditor.tsx b/web-editor/src/components/ScenarioEditor/ConfigEditor.tsx deleted file mode 100644 index db94508a..00000000 --- a/web-editor/src/components/ScenarioEditor/ConfigEditor.tsx +++ /dev/null @@ -1,420 +0,0 @@ -import React, { useState } from 'react'; -import { Settings, Database, Server, ChevronDown, ChevronRight, HelpCircle } from 'lucide-react'; -import styles from '../../styles/SidebarPanel.module.css'; -import type { ScenarioConfig } from '../../types/scenario'; - -const sectionTooltips: Record = { - flight_blender: 'Configure connection to Flight Blender, the UTM integration server. Set the URL, authentication method, and OAuth scopes.', - data_files: 'Specify paths to data files used in the scenario: trajectories, flight declarations, operational intents, and geo-fences.', - air_traffic: 'Default air traffic simulator settings: number of aircraft, simulation duration, and sensor configuration. Per-step overrides go in scenario YAML arguments.', -}; - -const Tooltip: React.FC<{ text: string }> = ({ text }) => ( -
- {text} -
-); - -interface ConfigEditorProps { - config: ScenarioConfig; - onUpdateConfig: (config: ScenarioConfig) => void; -} - -export const ConfigEditor: React.FC = ({ config, onUpdateConfig }) => { - const [expandedSections, setExpandedSections] = useState>(new Set()); - const [showHelp, setShowHelp] = useState(false); - - const toggleSection = (section: string) => { - setExpandedSections(prev => { - const next = new Set(prev); - if (next.has(section)) { - next.delete(section); - } else { - next.add(section); - } - return next; - }); - }; - - const updateFlightBlender = (field: string, value: string) => { - onUpdateConfig({ - ...config, - flight_blender: { - ...config.flight_blender, - [field]: value - } - }); - }; - - const updateFlightBlenderAuth = (field: string, value: string | string[]) => { - onUpdateConfig({ - ...config, - flight_blender: { - ...config.flight_blender, - auth: { - ...config.flight_blender.auth, - [field]: value - } - } - }); - }; - - const updateDataFiles = (field: string, value: string) => { - onUpdateConfig({ - ...config, - data_files: { - ...config.data_files, - [field]: value - } - }); - }; - - const updateAirTrafficSettings = (field: string, value: string | number | string[] | undefined) => { - onUpdateConfig({ - ...config, - air_traffic_simulator_settings: { - ...(config.air_traffic_simulator_settings || {}), - [field]: value - } - }); - }; - - // Parse a numeric input value, returning undefined when the field is - // cleared so we don't propagate NaN into the config state (which would - // serialize as null in JSON and fail server-side validation). - const parseIntOrUndefined = (raw: string): number | undefined => { - if (raw === '') return undefined; - const n = parseInt(raw, 10); - return Number.isNaN(n) ? undefined : n; - }; - - const updateSensorIds = (value: string) => { - const ids = value.split(',').map(id => id.trim()).filter(id => id.length > 0); - updateAirTrafficSettings('sensor_ids', ids); - }; - - return ( -
-
- - CONFIGURATION - -
-

- Configure settings and sources for Flight Blender, data files, and air traffic simulators to customize your scenario. -

- - {/* Flight Blender Section */} -
- {showHelp && } - - - {expandedSections.has('flight_blender') && ( -
-
- - updateFlightBlender('url', e.target.value)} - placeholder="http://localhost:8000" - /> -
- -
- - -
- - {config.flight_blender.auth.type === 'passport' && ( - <> -
- - updateFlightBlenderAuth('client_id', e.target.value)} - placeholder="your-client-id" - /> -
- -
- - updateFlightBlenderAuth('client_secret', e.target.value)} - placeholder="your-client-secret" - /> -
- -
- - updateFlightBlenderAuth('token_endpoint', e.target.value)} - placeholder="/oauth/token/" - /> -
- -
- - updateFlightBlenderAuth('passport_base_url', e.target.value)} - placeholder="https://passport.testflight.openutm.net" - /> -
- - )} - -
- - updateFlightBlenderAuth('audience', e.target.value)} - placeholder="testflight.flightblender.com" - /> -
- -
- - updateFlightBlenderAuth('scopes', e.target.value.split(',').map(s => s.trim()))} - placeholder="flightblender.write, flightblender.read" - /> -
-
- )} -
- - {/* Data Files Section */} -
- {showHelp && } - - - {expandedSections.has('data_files') && ( -
-
- - updateDataFiles('trajectory', e.target.value)} - placeholder="config/bern/trajectory_f1.json" - /> -
- -
- - updateDataFiles('flight_declaration', e.target.value)} - placeholder="config/bern/flight_declaration.json" - /> -
- -
- - updateDataFiles('flight_declaration_via_operational_intent', e.target.value)} - placeholder="config/bern/flight_declaration_via_operational_intent.json" - /> -
- -
- - updateDataFiles('geo_fence', e.target.value)} - placeholder="config/geo_fences.json" - /> -
-
- )} -
- - {/* Air Traffic Simulator Settings */} -
- {showHelp && } - - - {expandedSections.has('air_traffic') && ( -
-
- - updateAirTrafficSettings('number_of_aircraft', parseIntOrUndefined(e.target.value))} - min="1" - max="100" - /> -
- -
- - updateAirTrafficSettings('simulation_duration', parseIntOrUndefined(e.target.value))} - min="1" - max="3600" - /> -
- -
- - -
- -
- - updateSensorIds(e.target.value)} - placeholder="sensor-id-uuid" - /> -
-
- )} -
-
- ); -}; From 54971f92789611fc8eaec071d5ad45126df0107e Mon Sep 17 00:00:00 2001 From: Roman Pszonka Date: Sat, 25 Apr 2026 14:25:34 +0100 Subject: [PATCH 5/5] Remove config redaction functionality and associated tests for local single-user server Co-authored-by: Copilot --- .../server/config_redaction.py | 68 ----------- src/openutm_verification/server/main.py | 31 +++-- tests/test_config_redaction.py | 107 ------------------ 3 files changed, 13 insertions(+), 193 deletions(-) delete mode 100644 src/openutm_verification/server/config_redaction.py delete mode 100644 tests/test_config_redaction.py diff --git a/src/openutm_verification/server/config_redaction.py b/src/openutm_verification/server/config_redaction.py deleted file mode 100644 index 8226b6e2..00000000 --- a/src/openutm_verification/server/config_redaction.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Helpers for redacting secrets in config payloads served by the API. - -The unauthenticated GUI/API must never echo real credentials back to clients. -``redact_auth`` and ``redact_amqp`` produce safe copies for ``GET /api/config`` -responses; ``strip_redacted`` drops the placeholder on ``PUT /api/config`` so a -GET→PUT round-trip never overwrites the on-disk secret with the sentinel. -""" - -from typing import Any - -# Sentinel returned to the GUI in place of secret values. Non-empty (so users -# can tell the field is populated) but unmistakably not a real credential, and -# explicitly ignored by PUT /api/config so a round-trip never persists it. -REDACTED = "***REDACTED***" - - -def redact_auth(auth: dict[str, Any]) -> dict[str, Any]: - """Redact secret fields in an AuthConfig dict (returns a shallow copy).""" - redacted = dict(auth) - if redacted.get("client_secret"): - redacted["client_secret"] = REDACTED - return redacted - - -def redact_amqp(amqp: dict[str, Any] | None) -> dict[str, Any] | None: - """Redact embedded credentials in the AMQP URL (e.g. amqp://user:pass@…).""" - if not amqp: - return amqp - redacted = dict(amqp) - url = redacted.get("url") or "" - # Strip any "user:pass@" segment from the URL while keeping host/port/path. - if "@" in url and "://" in url: - scheme, rest = url.split("://", 1) - creds, _, host = rest.partition("@") - if ":" in creds: - user, _ = creds.split(":", 1) - redacted["url"] = f"{scheme}://{user}:{REDACTED}@{host}" - return redacted - - -def strip_redacted(key: str, new_value: Any, existing_value: Any) -> Any: - """Drop redaction sentinels from ``new_value`` so a round-trip GET→PUT - never overwrites the on-disk secret with the placeholder. - - Specifically: - - ``flight_blender.auth.client_secret`` / ``opensky.auth.client_secret`` - that equal the redaction sentinel are replaced with the existing - on-disk value (or dropped if no prior value exists). - - ``amqp.url`` containing the redaction sentinel as the password is - replaced with the existing on-disk URL. - """ - if new_value is None or not isinstance(new_value, dict): - return new_value - cleaned = dict(new_value) - if key in ("flight_blender", "opensky"): - auth = cleaned.get("auth") - if isinstance(auth, dict) and auth.get("client_secret") == REDACTED: - existing_secret = "" - if isinstance(existing_value, dict): - existing_auth = existing_value.get("auth") or {} - existing_secret = existing_auth.get("client_secret", "") - auth["client_secret"] = existing_secret - cleaned["auth"] = auth - elif key == "amqp": - url = cleaned.get("url") or "" - if REDACTED in url and isinstance(existing_value, dict): - cleaned["url"] = existing_value.get("url", url) - return cleaned diff --git a/src/openutm_verification/server/main.py b/src/openutm_verification/server/main.py index d65975cd..ab08514c 100644 --- a/src/openutm_verification/server/main.py +++ b/src/openutm_verification/server/main.py @@ -29,11 +29,6 @@ ScenarioResult, Status, ) -from openutm_verification.server.config_redaction import ( - redact_amqp, - redact_auth, - strip_redacted, -) from openutm_verification.server.router import scenario_router from openutm_verification.server.runner import SessionManager @@ -143,10 +138,9 @@ async def get_config(runner: SessionManager = Depends(get_session_manager)): Falls back to the in-memory copy on parse errors so a transient bad file doesn't break the screen. - Secrets (auth ``client_secret`` values, AMQP URL passwords) are redacted - on response so the unauthenticated GUI/API never echoes credentials. The - matching PUT endpoint ignores the redaction sentinel so round-tripping - edits doesn't overwrite the stored secret with the placeholder. + No redaction is performed: this server is intended for local single-user + use (binds 127.0.0.1 by default) and the user already has filesystem + access to the config file. """ import yaml as _yaml @@ -158,18 +152,13 @@ async def get_config(runner: SessionManager = Depends(get_session_manager)): except Exception: # noqa: BLE001 logger.exception("Re-reading config during GET /api/config failed; serving in-memory copy") - fb = cfg.flight_blender.model_dump() - fb["auth"] = redact_auth(fb.get("auth", {})) - opensky = cfg.opensky.model_dump() - opensky["auth"] = redact_auth(opensky.get("auth", {})) - return { "version": cfg.version, "run_id": cfg.run_id, "config_path": str(runner.config_path), - "flight_blender": fb, - "opensky": opensky, - "amqp": redact_amqp(cfg.amqp.model_dump() if cfg.amqp else None), + "flight_blender": cfg.flight_blender.model_dump(), + "opensky": cfg.opensky.model_dump(), + "amqp": cfg.amqp.model_dump() if cfg.amqp else None, "data_files": cfg.data_files.model_dump(), "air_traffic_simulator_settings": (cfg.air_traffic_simulator_settings.model_dump() if cfg.air_traffic_simulator_settings else None), } @@ -220,6 +209,12 @@ async def put_config( with open(config_path, "r", encoding="utf-8") as f: doc = yaml_rt.load(f) + # ruamel returns None for an empty/whitespace-only file; normalize so + # ``doc[key] = ...`` below doesn't TypeError. + if doc is None: + from ruamel.yaml.comments import CommentedMap + + doc = CommentedMap() applied: list[str] = [] for key in _EDITABLE_CONFIG_KEYS: @@ -227,7 +222,7 @@ async def put_config( # its value is null, so the GUI can clear optional sections (e.g. # set ``amqp: null`` to remove AMQP config). if key in payload: - doc[key] = strip_redacted(key, payload[key], doc.get(key)) + doc[key] = payload[key] applied.append(key) # Validate the would-be config before touching disk so we never persist diff --git a/tests/test_config_redaction.py b/tests/test_config_redaction.py deleted file mode 100644 index 938fda49..00000000 --- a/tests/test_config_redaction.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Unit tests for openutm_verification.server.config_redaction.""" - -from openutm_verification.server.config_redaction import ( - REDACTED, - redact_amqp, - redact_auth, - strip_redacted, -) - - -class TestRedactAuth: - def test_redacts_client_secret_when_present(self): - result = redact_auth({"client_id": "id", "client_secret": "supersecret"}) - assert result["client_secret"] == REDACTED - assert result["client_id"] == "id" - - def test_leaves_empty_secret_alone(self): - result = redact_auth({"client_id": "id", "client_secret": ""}) - assert result["client_secret"] == "" - - def test_handles_missing_secret(self): - result = redact_auth({"client_id": "id"}) - assert "client_secret" not in result - - def test_does_not_mutate_input(self): - original = {"client_secret": "supersecret"} - redact_auth(original) - assert original["client_secret"] == "supersecret" - - -class TestRedactAmqp: - def test_returns_none_for_none(self): - assert redact_amqp(None) is None - - def test_returns_falsy_unchanged(self): - assert redact_amqp({}) == {} - - def test_redacts_password_in_url(self): - result = redact_amqp({"url": "amqp://user:pass@host:5672/vhost"}) - assert result["url"] == f"amqp://user:{REDACTED}@host:5672/vhost" - - def test_preserves_url_without_credentials(self): - result = redact_amqp({"url": "amqp://host:5672/"}) - assert result["url"] == "amqp://host:5672/" - - def test_preserves_url_with_user_only(self): - # No colon in creds = no password to redact. - result = redact_amqp({"url": "amqp://user@host:5672/"}) - assert result["url"] == "amqp://user@host:5672/" - - def test_handles_empty_url(self): - result = redact_amqp({"url": ""}) - assert result["url"] == "" - - def test_does_not_mutate_input(self): - original = {"url": "amqp://user:pass@host"} - redact_amqp(original) - assert original["url"] == "amqp://user:pass@host" - - -class TestStripRedacted: - def test_returns_none_passthrough(self): - assert strip_redacted("flight_blender", None, {"auth": {"client_secret": "x"}}) is None - - def test_returns_non_dict_passthrough(self): - assert strip_redacted("flight_blender", "not-a-dict", {}) == "not-a-dict" - - def test_replaces_redacted_secret_with_existing(self): - new = {"auth": {"client_secret": REDACTED, "client_id": "id"}} - existing = {"auth": {"client_secret": "real-secret"}} - result = strip_redacted("flight_blender", new, existing) - assert result["auth"]["client_secret"] == "real-secret" - - def test_replaces_redacted_secret_with_empty_when_no_prior(self): - new = {"auth": {"client_secret": REDACTED}} - result = strip_redacted("flight_blender", new, None) - assert result["auth"]["client_secret"] == "" - - def test_keeps_real_new_secret(self): - new = {"auth": {"client_secret": "new-secret"}} - existing = {"auth": {"client_secret": "old-secret"}} - result = strip_redacted("opensky", new, existing) - assert result["auth"]["client_secret"] == "new-secret" - - def test_amqp_url_with_redacted_password_falls_back_to_existing(self): - new = {"url": f"amqp://user:{REDACTED}@host:5672/"} - existing = {"url": "amqp://user:realpw@host:5672/"} - result = strip_redacted("amqp", new, existing) - assert result["url"] == "amqp://user:realpw@host:5672/" - - def test_amqp_url_without_redaction_unchanged(self): - new = {"url": "amqp://user:newpw@host:5672/"} - existing = {"url": "amqp://user:oldpw@host:5672/"} - result = strip_redacted("amqp", new, existing) - assert result["url"] == "amqp://user:newpw@host:5672/" - - def test_amqp_redacted_url_with_no_existing_keeps_value(self): - new = {"url": f"amqp://user:{REDACTED}@host"} - result = strip_redacted("amqp", new, None) - # No existing dict to fall back on, so the (still-redacted) URL is - # left as-is. Validation downstream will reject it. - assert result["url"] == f"amqp://user:{REDACTED}@host" - - def test_unknown_key_passes_through(self): - new = {"foo": "bar", "auth": {"client_secret": REDACTED}} - result = strip_redacted("data_files", new, {}) - assert result == new