diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d6166ad --- /dev/null +++ b/.dockerignore @@ -0,0 +1,45 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +env/ +ENV/ +*.egg-info/ +dist/ +build/ + +# Git +.git/ +.gitignore +.github/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +*.cover + +# OpenTelemetry configs (not needed in container) +opentelemetry_collector/ + +# Docker +Dockerfile +docker-compose.yml +.dockerignore + +# Pre-commit +.pre-commit-config.yaml + +# Other +test/ +.claude/ diff --git a/.gitignore b/.gitignore index d49b752..7b4501f 100644 --- a/.gitignore +++ b/.gitignore @@ -175,3 +175,12 @@ cython_debug/ # Ignore JetBrains IDE configuration folder .idea/ + +# macOS +.DS_Store + +# Claude Code local config and session data +.claude/ + +# Internal development context (domain glossary, not shipped) +CONTEXT.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..08983db --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copy project files +COPY pyproject.toml README.md LICENSE ./ +COPY src ./src + +# Install Python dependencies + +RUN pip install --no-cache-dir -e . + +# Expose Flask port +EXPOSE 5000 + +# Run the Flask application +CMD ["python", "-m", "markus_ai_server"] diff --git a/README.md b/README.md index 88a06ee..6604bf9 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,34 @@ # markus-ai-server +markus-ai-server runs model prompts for MarkUs. It passes each prompt to a local +model (Ollama or llama.cpp) and returns the answer. + +Every request needs an API key. The server checks the key before it runs the prompt. +It records each failed check as an audit event. Standing rules read those events and +send an email when one address fails the key too many times. This catches brute-force +and spray attacks. + +See [docs/USER_GUIDE.md](docs/USER_GUIDE.md) to set it up, configure it, and test it. +For a plain-language, step-by-step test from the MarkUs UI, see +[docs/markus-testing-guide.md](docs/markus-testing-guide.md). + ## Developers -To install project dependencies, including development dependencies: +Install the project with its development dependencies: ```console -$ pip install -e .[dev] +$ uv sync ``` -To install pre-commit hooks: +(or `pip install -e . --group dev` with pip 25.1+; `dev` is a dependency group, not an extra) + +Install the pre-commit hooks: ```console $ pre-commit install ``` -To run the test suite: +Run the tests: ```console $ pytest diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4698217 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,235 @@ +services: + # ==================== Core Application Services ==================== + + # Redis - Cache and message broker for AI server + redis: + image: redis:7-alpine + container_name: ai-server-redis + ports: + - "6380:6379" # host 6380 to avoid clashing with the autotesting redis + volumes: + - redis_data:/data + command: redis-server --appendonly yes + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + networks: + - monitoring + restart: unless-stopped + + + # AI Server - Flask application + ai-server: + build: + context: . + dockerfile: Dockerfile + container_name: ai-server-app + ports: + - "5001:5000" # host 5001 to avoid clashing with the autotest client + environment: + # Flush stdout/stderr immediately so audit log lines reach `docker logs` + # without block-buffering delay (the default for a non-TTY container). + - PYTHONUNBUFFERED=1 + - REDIS_URL=redis://redis:6379 + - LLAMA_SERVER_URL=http://host.docker.internal:11434 + - OLLAMA_HOST=http://host.docker.internal:11434 + - DEFAULT_MODEL=deepseek-coder-v2:latest + # OpenTelemetry configuration (only used if monitoring profile is active) + - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 + # Mirrors the production reverse proxy: trust 1 X-Forwarded-For hop so the + # real client IP is recorded (and so the E2E test can inject attacker IPs). + - TRUSTED_PROXY_HOPS=1 + depends_on: + redis: + condition: service_healthy + volumes: + - .:/app + networks: + - monitoring + restart: unless-stopped + + # ==================== OpenTelemetry & Monitoring Services ==================== + # These services only start when using: docker compose --profile monitoring up + + otel-collector: + image: ghcr.io/open-telemetry/opentelemetry-collector-releases/opentelemetry-collector-contrib:0.139.0 + container_name: otel-collector + profiles: ["monitoring"] + deploy: + resources: + limits: + memory: 200M + command: ["--config=/etc/otelcol-config.yml"] + volumes: + - ./opentelemetry_collector/config.yml:/etc/otelcol-config.yml:ro + ports: + - "4317:4317" + - "4318:4318" + - "8889:8889" + - "8888:8888" + networks: + - monitoring + depends_on: + - jaeger + - prometheus + restart: unless-stopped + + prometheus: + image: prom/prometheus:latest + container_name: prometheus + profiles: ["monitoring"] + ports: + - "9090:9090" + - "18889:8889" + volumes: + - ./opentelemetry_collector/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./opentelemetry_collector/alert_rules.yml:/etc/prometheus/alert_rules.yml:ro + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.console.libraries=/usr/share/prometheus/console_libraries' + - '--web.console.templates=/usr/share/prometheus/consoles' + networks: + - monitoring + depends_on: + - alertmanager + restart: unless-stopped + + alertmanager: + image: prom/alertmanager:latest + container_name: alertmanager + profiles: ["monitoring"] + ports: + - "9093:9093" + volumes: + - ./opentelemetry_collector/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro + command: + - '--config.file=/etc/alertmanager/alertmanager.yml' + - '--storage.path=/alertmanager' + networks: + - monitoring + restart: unless-stopped + + jaeger: + image: jaegertracing/all-in-one:latest + container_name: jaeger + profiles: ["monitoring"] + ports: + - "16686:16686" + - "14317:4317" + - "14318:4318" + environment: + - METRICS_STORAGE_TYPE=prometheus + - PROMETHEUS_SERVER_URL=http://prometheus:9090 + - PROMETHEUS_QUERY_NAMESPACE=ai_server_traces_span_metrics + - PROMETHEUS_QUERY_DURATION_UNIT=ms + - PROMETHEUS_QUERY_NORMALIZE_CALLS=true + - PROMETHEUS_QUERY_NORMALIZE_DURATION=true + - JAEGER_DISABLED=false + - PROMETHEUS_QUERY_SUPPORT_SPANMETRICS_CONNECTOR=true + - COLLECTOR_OTLP_ENABLED=true + networks: + - monitoring + depends_on: + - prometheus + restart: unless-stopped + + # Loki - log store for audit events (intrusion detection) + loki: + image: grafana/loki:3.3.2 + container_name: loki + profiles: ["monitoring"] + command: ["-config.file=/etc/loki/loki.yml"] + volumes: + - ./opentelemetry_collector/loki.yml:/etc/loki/loki.yml:ro + - loki_data:/loki + ports: + - "3100:3100" + networks: + - monitoring + restart: unless-stopped + + # Grafana - single pane over Jaeger (traces), Prometheus (metrics), Loki (logs). + # Host port 3001 to avoid clashing with the MarkUs rails app on 3000. + grafana: + image: grafana/grafana:11.4.0 + container_name: grafana + profiles: ["monitoring"] + ports: + - "3001:3000" + environment: + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + # SMTP points at Mailpit for testing; override for the production relay. + - GF_SMTP_ENABLED=true + - GF_SMTP_HOST=mailpit:1025 + - GF_SMTP_FROM_ADDRESS=ai-server-alerts@markus.local + - GF_SMTP_FROM_NAME=MarkUs AI Server Alerts + - GF_SMTP_SKIP_VERIFY=true + volumes: + - ./opentelemetry_collector/grafana/provisioning:/etc/grafana/provisioning:ro + - grafana_data:/var/lib/grafana + depends_on: + - loki + - prometheus + networks: + - monitoring + restart: unless-stopped + + # Mailpit - captures alert emails during testing (SMTP sink + web UI/API) + mailpit: + image: axllent/mailpit:latest + container_name: mailpit + profiles: ["monitoring"] + ports: + - "8025:8025" # web UI + REST API (assert emails here) + - "1025:1025" # SMTP + networks: + - monitoring + restart: unless-stopped + +networks: + monitoring: + driver: bridge + +volumes: + redis_data: + loki_data: + grafana_data: + +# ==================== USAGE INSTRUCTIONS ==================== +# +# Start core services only (Redis, AI Server): +# docker compose up -d +# +# Start everything including OpenTelemetry monitoring: +# docker compose --profile monitoring up -d +# +# Stop all services: +# docker compose down +# # or with profile: +# docker compose --profile monitoring down +# +# View logs: +# docker compose logs -f ai-server +# +# Restart a service: +# docker compose restart ai-server +# +# Check status: +# docker compose ps +# +# ==================== ACCESS URLS ==================== +# +# Core Services: +# - AI Server: http://localhost:5001 +# - Redis: localhost:6380 +# - Ollama (on the host): http://localhost:11434 +# +# Monitoring (only when --profile monitoring is used): +# - Jaeger UI: http://localhost:16686 +# - Prometheus: http://localhost:9090 +# - Alertmanager: http://localhost:9093 +# - OTel Collector: http://localhost:8888/metrics diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md new file mode 100644 index 0000000..b85ef1d --- /dev/null +++ b/docs/USER_GUIDE.md @@ -0,0 +1,290 @@ +# markus-ai-server: Set Up, Configure, and Test + +This guide has three parts. Part 1 says what the server does. Part 2 says how the +watching works. Part 3 shows how to set it up and test it. + +For a step-by-step test from the MarkUs UI, see +[markus-testing-guide.md](./markus-testing-guide.md). + +--- + +## Part 1: What it does + +The server runs AI prompts for MarkUs. MarkUs sends a prompt. The server picks a +local model (Ollama or llama.cpp), runs it, and returns the answer. + +Every request needs an API key. The server checks the key first: + +- No key: the request is refused. +- Unknown key: the request is refused. +- Good key: the prompt runs and the answer comes back. + +Each refusal is a small warning sign. One refusal means little. It could be a typo +or an old key. Many refusals from one address in a few minutes look like someone +guessing keys. That is a brute-force attack. Many refusals from many addresses look +like a group attack. + +The server writes each refusal down as an audit event. The events go to a log +store. Rules watch the store and send an email when the count gets too high. + +### What a refusal records + +| Field | What it tells you | +|---|---| +| `event` | Always `auth_failure`, so security events are easy to find | +| `result` | `missing_key` (no key) or `invalid_key` (unknown key) | +| `client_ip` | The address the request came from | +| `endpoint` | The path that was hit, such as `/chat` | + +### What a refusal never records + +The server never logs the API key. It never logs the prompt. It never logs the +answer. Audit events hold only the four fields above. Student work stays out of the +log store. + +--- + +## Part 2: How the watching works + +The key check happens before any model runs. So you can test the watching with no +model running. + +``` +MarkUs --> markus-ai-server (Flask) --> Ollama / llama.cpp --> answer + | + | on a refused key: one audit event (OTLP log) + v + OTel Collector --> Loki --> Grafana rules --> email +``` + +### Where the parts live + +| File | What it does | +|---|---| +| `src/markus_ai_server/server.py` | The Flask app. `authenticate()` records each refusal | +| `src/markus_ai_server/telemetry.py` | Audit logging and proxy handling | +| `opentelemetry_collector/config.yml` | Collector: OTLP in, then out to Jaeger, Prometheus, Loki | +| `opentelemetry_collector/loki.yml` | Loki log store, 90-day retention | +| `opentelemetry_collector/grafana/provisioning/` | Data sources, alert rules, email contact point | +| `docker-compose.yml` | The whole stack. Monitoring sits behind a profile | +| `test/test_audit_logging.py` | Unit tests for the app | +| `test/e2e/suite.sh` | End-to-end test, the acceptance gate | + +### Three things the design promises + +1. **Logging never breaks the app.** Audit logging turns on only when + `OTEL_EXPORTER_OTLP_ENDPOINT` is set. Exports run on a background thread. Setup + errors are caught and logged. A down collector drops no request. +2. **The client IP can be trusted.** The server uses `X-Forwarded-For` only when + `TRUSTED_PROXY_HOPS` is above 0. Reached directly (`TRUSTED_PROXY_HOPS=0`), it + ignores that header. No caller can fake an address. +3. **A refused key returns 401, not 500.** The error handler keeps HTTP error + codes. A bad key returns a clean 401. + +### The two alarm rules + +Both live in `grafana/provisioning/alerting/rules.yml` and run every minute. + +| Rule | Fires when | Severity | +|---|---|---| +| Brute force | One IP has more than 10 refusals in 5 minutes | warning | +| Spray | More than 50 refusals across all IPs in 5 minutes | critical | + +The thresholds are starting points. Tune them against real traffic. When a rule +fires, Grafana sends an email (Mailpit catches it in the test stack). + +--- + +## Part 3: Set it up, configure it, test it + +Run it two ways. Local mode runs the app and unit tests. Docker mode runs the full +chain from audit event to email. The ports below match `docker-compose.yml`. + +### 3.1 Local mode (app and unit tests) + +```bash +cd ~/work/ai-server +uv sync # or: pip install -e . --group dev (pip >= 25.1) +pre-commit install +``` + +Run the tests. The audit tests need no services running: + +```bash +pytest # whole suite +pytest test/test_audit_logging.py -q # audit tests only +``` + +> **Verified:** `pytest` passes (53 tests, 13 in the audit file). The audit tests +> cover both `result` values with all four fields, no key or body in the record, +> the 401 and 400 responses, the gated OTLP setup, and the proxy IP rules. + +### 3.2 Configuration (environment variables) + +The server reads these at startup, from a `.env` file or the container environment. + +| Variable | Default | What it controls | +|---|---|---| +| `REDIS_URL` | *(required)* | Redis that holds the API keys (`api-key:` maps to a username) | +| `DEFAULT_MODEL` | `deepseek-coder-v2:latest` | Model used when a request names none | +| `OLLAMA_HOST` | *(unset)* | Ollama backend URL | +| `LLAMA_SERVER_URL` | *(unset)* | llama.cpp HTTP server URL | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | *(unset)* | Set this to turn audit logging on | +| `TRUSTED_PROXY_HOPS` | `0` | `0` reads the direct address. `1` trusts one proxy in front | +| `FLASK_DEBUG` | *(unset)* | Set to `1` for the dev debugger and auto-reload. Never set it in production | + +> **Security note.** Match `TRUSTED_PROXY_HOPS` to the real setup. Set it to `1` +> only when one trusted proxy sits in front. A number above the true count lets a +> caller fake `X-Forwarded-For`. That breaks per-IP detection. + +API keys live in Redis, one per user. The value is the username. + +```bash +redis-cli set "api-key:secret123" alice +``` + +Callers present the key in the `X-API-KEY` header. The MarkUs Autotester's AI +tester reads its copy from the `REMOTE_API_KEY` environment variable (set on +the Autotester, or in a `.env` file uploaded with the assignment's test files). +If it is unset, every request fails with 401 `Missing API key`. + +### 3.3 Start the full stack in Docker + +The monitoring services start only with the `monitoring` profile. + +```bash +cd ~/work/ai-server +docker compose --profile monitoring up -d --build +docker compose ps +``` + +The compose file sets `OTEL_EXPORTER_OTLP_ENDPOINT` and `TRUSTED_PROXY_HOPS=1` on +the app. Audit events flow. A test can set a client IP with `X-Forwarded-For`. + +| Service | URL or port | Use it for | +|---|---|---| +| ai-server | http://localhost:5001 | the app (`POST /chat`) | +| Loki | http://localhost:3100 | audit log store. API only, no UI — browse the logs in Grafana → Explore → Loki | +| Grafana | http://localhost:3001 | browse audit logs (Explore), alert rules, contact points (anonymous admin) | +| Mailpit | http://localhost:8025 | read the alert emails | +| Jaeger | http://localhost:16686 | traces | +| Prometheus | http://localhost:9090 | metrics | +| Alertmanager | http://localhost:9093 | metric alerts | + +Add a working key to the stack's Redis: + +```bash +docker exec ai-server-redis redis-cli set "api-key:secret123" alice +``` + +### 3.4 Test the key check and the audit event (no model needed) + +A missing key returns 401 and records one event: + +```bash +curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:5001/chat -F 'content=hi' +# Expect: 401 +``` + +A bad key returns 401 and records the address the proxy passes through: + +```bash +curl -s -X POST http://localhost:5001/chat -H 'X-API-KEY: bogus' -H 'X-Forwarded-For: 203.0.113.42' -F 'content=hi' +# Expect body: {"error":"Invalid API key"} (HTTP 401) +``` + +See the event in the app log: + +```bash +docker logs ai-server-app 2>&1 | grep "authentication failure" +``` + +Find the event in Loki by client IP. The fields are structured metadata, so the +query needs no parser: + +```bash +curl -s "http://localhost:3100/loki/api/v1/query" --data-urlencode 'query=sum by (client_ip) (count_over_time({service_name="ai-server"} | event="auth_failure" | client_ip="203.0.113.42" [5m]))' +# Expect: one result with a value of 1 or more +``` + +### 3.5 Test a good key against a model (happy path) + +This path needs a model at `OLLAMA_HOST` (Ollama on the host machine). + +```bash +curl -s -X POST http://localhost:5001/chat -H 'X-API-KEY: secret123' -F 'content=Say hello in one word.' +# Expect: HTTP 200 with the model's answer as a JSON string +``` + +With no model running, the key check still passes and records no event. The call +then fails with a model error. That is correct: the watching lives in the key +check, before any model runs. + +### 3.6 Test the alarms and the email (acceptance gate) + +The end-to-end suite replays brute-force and spray traffic. It then checks that +real alert emails land in Mailpit. + +```bash +# The stack must be up (3.3). Then: +bash test/e2e/suite.sh +``` + +> **Verified:** the suite passed every check (`PASSED: 12 FAILED: 0`). Both rules +> fired and emailed Mailpit. The below-threshold IP did not fire. No secret +> reached Loki. + +It runs eight scenarios (12 checks in total), prints a pass/fail count, and exits +non-zero on any failure. + +| # | Check | +|---|---| +| T1 | All monitoring services answer | +| T2 | The app records an event with the proxied client IP | +| T3 | The event reaches Loki, searchable by `client_ip` | +| T4 | Grafana loaded the data source, both rules, and the email contact point | +| T5 | Rule 1 (brute force) fires and emails | +| T6 | Rule 2 (spray) fires and emails | +| T7 | An IP below the threshold does not trip Rule 1 | +| T8 | No API key or secret reaches Loki | + +Watch the emails arrive at http://localhost:8025 while the suite runs. + +To drive Rule 1 by hand, send 12 bad-key requests from one IP, wait up to 3 +minutes, then read Mailpit: + +```bash +for i in $(seq 1 12); do curl -s -o /dev/null -X POST http://localhost:5001/chat -H "X-API-KEY: bogus-$i" -H 'X-Forwarded-For: 203.0.113.66' -F 'content=hi'; done +curl -s http://localhost:8025/api/v1/messages | python3 -c "import sys,json;[print(m['Subject']) for m in json.load(sys.stdin)['messages']]" +``` + +### 3.7 Tear it down + +```bash +docker compose --profile monitoring down # keep data +docker compose --profile monitoring down -v # also drop Loki, Grafana, Redis data +``` + +--- + +## Production deployment + +The Docker stack is the test rig. In production, the sysadmins run central Loki +and Grafana. The server's collector ships logs to that central endpoint. Nothing +else in the image changes. The handoff steps: + +1. Provision central Loki (3.0+) with OTLP ingest and 90-day retention. +2. Point the collector's `otlphttp/loki` exporter at it, over TLS. +3. Add Loki as a data source in central Grafana. +4. Import the two rules and the email contact point. Set the real email alias. +5. Run the app under gunicorn, not the Flask dev server. + +## Troubleshooting + +| Problem | Look here first | +|---|---| +| Events do not reach Loki | Is `OTEL_EXPORTER_OTLP_ENDPOINT` set on the app? Is the collector up? Export is batched, so wait a few seconds and query again | +| Every attacker shows one IP | `TRUSTED_PROXY_HOPS` is wrong. Behind a proxy it must equal the hop count. Direct it must be `0` | +| No alert email arrives | Grafana SMTP (`GF_SMTP_*`) must point at a reachable relay. Check `docker logs grafana` | +| `/chat` returns 500, not 401 | A non-auth error happened. Read the app log for the traceback | +| A good key still fails | The model backend (`OLLAMA_HOST`) is unreachable. The key passed. The failure is downstream | diff --git a/docs/markus-testing-guide.md b/docs/markus-testing-guide.md new file mode 100644 index 0000000..11fd1e9 --- /dev/null +++ b/docs/markus-testing-guide.md @@ -0,0 +1,382 @@ +# Set Up MarkUs and Test the New AI Server Changes + +This guide does two things: + +1. It shows you how to start everything, step by step. +2. It then runs you through tests that prove the new changes work. + +You do not need to know how the code works. +Plan for about 45 minutes the first time. + +**Good news:** the practice MarkUs database comes with a ready-made course +called **csc108**. It already has a teacher, students, and an assignment +called **autotest_py** with an AI test group and a handed-in student file. +We only fix its settings and run it. We do not build anything from scratch. + +> Menu names can change a little between versions. +> If a button name is not exact, look for one that means the same thing. + +--- + +## The map + +Here is how the pieces talk to each other: + +``` +MarkUs (the grading website) + → Autotester (runs the tests) + → AI server (this project) + → Ollama (holds the AI models) +``` + +And here is who watches for break-ins: + +``` +AI server → log book (Loki) → alarms (Grafana) → email (Mailpit) +``` + +### Where each program's web page is + +Most of the programs have a page you can open in your browser +(after Part 1 starts them): + +| Program | Address | What you see there | +|---|---|---| +| MarkUs | http://host.docker.internal:3000/csc108 | The grading website | +| Grafana | http://localhost:3001 | The main watcher window: the log book and the alarms | +| Mailpit | http://localhost:8025 | The alarm emails | +| RQ dashboard | http://localhost:9181 | The Autotester's job queue (stuck or failed test jobs) | +| Jaeger | http://localhost:16686 | Traces (not used in this guide) | +| Prometheus | http://localhost:9090 | Counters (not used in this guide) | + +> **Loki has no page of its own.** Loki only *stores* the log book. To read it, +> open Grafana → menu (three lines) → **Explore** → pick **Loki** at the top. +> Step 6.3 walks you through it. (Programs can query Loki directly at +> http://localhost:3100, but there is nothing there for a browser.) + +--- + +## What changed (what we are testing) + +1. **The server keeps a log book of break-in tries.** + A wrong key gets written down with the caller's address (IP). +2. **Two alarms ring when the server is attacked.** + Alarm 1: one address tries more than 10 wrong keys in 5 minutes. + Alarm 2: more than 50 wrong keys arrive from anywhere in 5 minutes. + Each alarm sends an email. +3. **Two fixes to the chat service.** + The server now reads the instructions MarkUs sends. Before, it threw them away. + A wrong key now gets a clean "not allowed" answer, not a crash. + +--- + +## Part 1 — Start all the programs + +You need a **Terminal** window. On a Mac, open the app called **Terminal**. +Paste each command as **one line** and press Enter. + +**1.1 — Check Ollama (the AI models).** + +```bash +ollama list +``` + +**PASS:** you see a list of models, and `qwen3:1.7b` is in it. +If the command fails, open the **Ollama** app first, then try again. + +**1.2 — Start the AI server and its watchers.** + +```bash +cd ~/work/ai-server && docker compose --profile monitoring up -d +``` + +Wait for it to finish. Then check: + +```bash +docker ps --format '{{.Names}}' +``` + +**PASS:** the list has `ai-server-app`, `grafana`, `loki`, `mailpit`, and `ai-server-redis`. + +**1.3 — Give the tests a working key.** + +```bash +docker exec ai-server-redis redis-cli set "api-key:secret123" alice +``` + +**PASS:** you see `OK`. +This tells the AI server which key to **accept**. The Autotester must **send** +that same key. Step 1.5 checks that it does. + +**1.4 — Make the shared network.** + +```bash +docker network create markus_dev +``` + +**PASS:** you see a long code, **or** a note that it already exists. Both are fine. + +**1.5 — Start the Autotester.** + +```bash +cd ~/work/autotesting && docker compose up -d +``` + +**PASS:** `docker ps` now also shows `autotest-client` and autotesting containers. +If you see an error about a missing network, run: + +```bash +cd ~/work/autotesting && docker compose down && docker compose up -d +``` + +Then check that the Autotester holds the key it will send: + +```bash +docker exec autotesting-server-1 printenv REMOTE_API_KEY +``` + +**PASS:** it prints `secret123`. + +> **If it prints nothing,** the Autotester will knock with no key at all, and +> every AI test will fail with **401 Missing API key**. Set the key one of +> two ways: +> +> 1. **On the Autotester itself** (how this setup does it): the file +> `~/work/autotesting/docker-compose.override.yml` gives the `server` +> service the line `REMOTE_API_KEY=secret123`. Add it if it is missing, +> then restart: `cd ~/work/autotesting && docker compose up -d`. +> 2. **As an uploaded test file:** make a file named `.env` holding the one +> line `REMOTE_API_KEY=secret123`, and upload it with the assignment's +> test files (the same upload button used for `sys_taok.txt` in step 4.3). +> The AI test reads it on every run — no restart needed. +> +> If both are set, the Autotester's own setting (way 1) wins. + +**1.6 — Start MarkUs.** + +```bash +cd ~/work/Markus && docker compose up -d rails +``` + +The **first start is slow**. It builds and fills a practice database. +This can take 10 minutes or more. Then open this page in your browser: + + http://host.docker.internal:3000/csc108 + +We use this special address instead of `localhost` so the Autotester +can talk back to MarkUs. Use it for all MarkUs pages in this guide. +(If it does not load on a Mac, try `http://docker.for.mac.localhost:3000/csc108`.) + +**PASS:** you see the MarkUs login page. + +> **About logins:** this practice MarkUs accepts **any password**. +> Only the user name matters. We will use the teacher account: `instructor`. + +--- + +## Part 2 — Open the ready-made course + +**2.1** Log in as `instructor`. Password: anything, like `x`. + +**2.2** Open the course **csc108**. + +**2.3** Find the assignment called **autotest_py** and open it. +This assignment already has automated tests turned on, +and one student group has already handed in a file called `submission.py`. + +--- + +## Part 3 — Check the Autotester connection + +**3.1** Open the course **Settings** (the course edit page). + +**3.2** Find the box called **Autotest URL**. It should already say: + + http://autotest-client:5000 + +**3.3** Click the **Test** button next to the connection settings. + +**PASS:** a message says the connection works. + +If it fails, the Autotester may have a new key. Retype the same URL, +save the page, and click **Test** again. +If there is a **Refresh** button, click it too. + +--- + +## Part 4 — Fix the AI test group settings + +The assignment already has an AI test group called **AI_TESTY**, +but it points at the wrong server and has two broken settings. +We fix all three here. + +**4.1** Make our special instruction file. Paste this in the Terminal: + +```bash +echo 'You are a helpful teaching assistant. Start your reply with the words TA_OK: then shortly explain what the code does and any problems in it.' > ~/Desktop/sys_taok.txt +``` + +The words `TA_OK:` are our tracer. +If the AI's answer starts with `TA_OK:`, our instructions got through. + +**4.2** Open the assignment **autotest_py → Settings → Automated Testing** tab. + +**4.3** Use the upload button to upload **`sys_taok.txt`** as a test file. + +**4.4** Find the test group **AI_TESTY** (tester type **ai**) and fix these fields: + +| Field | Change it to | Why | +|---|---|---| +| remote_url | `http://host.docker.internal:5001/chat` | It pointed at the school's real server. This points at ours. | +| system_prompt | `sys_taok.txt` | It held a plain sentence. It must be a **file name**. | +| prompt | `code_feedback_v3` | It was empty. This tells the AI how to review code. | +| Model Name | `qwen3:1.7b` | Without it, the tool asks for a big model this machine does not have. | +| Timeout | `300` | The small AI model can be slow. | + +Leave the other fields alone (`scope: code`, `submission: submission.py`, +`model: remote`, Output Display: `overall_comment`). + +Save the page. A status message appears at the top. +Wait until it says **Completed**. The first save may take a few minutes. + +> **If there is no Model Name box:** your Autotester has an older settings +> form. Ask for the one-line schema fix (`model_name` in the ai tester's +> `settings_schema.json`), restart the Autotester, then log in as `.admin` +> and press **Refresh schema** on the Admin → Courses → csc108 page. +> The box will appear. + +--- + +## Part 5 — The big test: AI feedback with our instructions + +The student's work is already handed in and collected. We just run the test. + +**5.1** In **autotest_py**, open the **Submissions** tab. + +**5.2** Click the group's name to open the grading view. +If the name is not a link yet, tick its box, click **Collect Submissions**, +wait a moment, refresh, and try again. + +**5.3** Open the **Test Results** tab. Click **Run Tests**. +Now wait. The AI is thinking. This can take one to three minutes. +Refresh the test results now and then. + +**5.4** When the test run finishes, look for the feedback. +Open the tab that holds the **Overall Comment** box (the annotations tab). + +**PASS — the most important check in this guide:** + +- A comment about the student's code appears, **and** +- it **starts with `TA_OK:`**. + +The `TA_OK:` proves our fix. The instruction file traveled +MarkUs → Autotester → AI server → the model, and the model obeyed it. +Before this fix, the AI server threw those instructions away. + +--- + +## Part 6 — Break-in checks through the real system + +Now we prove the watchdog sees **real** traffic, not just test commands. + +**6.1** Take the key away, so the Autotester's key becomes wrong: + +```bash +docker exec ai-server-redis redis-cli del "api-key:secret123" +``` + +**6.2** In MarkUs, click **Run Tests** again and wait for the result. + +**PASS:** the test result shows a clear error that says **401** +or **Invalid API key**. It must **not** say error **500**. + +**6.3** Now look in the log book. Open Grafana: + + http://localhost:3001 + +Click the menu (three lines) → **Explore**. The picker should say **Loki**. +Click **Code**, paste this line, and click **Run query**: + +``` +{service_name="ai-server"} | event="auth_failure" +``` + +**PASS:** the newest line is the Autotester's failed try, just now. +Open the line: it has a `client_ip`. It must **not** show any key text. + +**6.4** Give the key back: + +```bash +docker exec ai-server-redis redis-cli set "api-key:secret123" alice +``` + +--- + +## Part 7 — The alarms + +Open the email catcher in your browser and keep the tab open: + + http://localhost:8025 + +**7.1 — Alarm 1: many bad tries from one address.** +Paste this one line. It clears old emails, then knocks 12 times with wrong keys: + +```bash +curl -s -X DELETE http://localhost:8025/api/v1/messages >/dev/null; for i in $(seq 1 12); do curl -s -o /dev/null -X POST http://localhost:5001/chat -H "X-API-KEY: bad-try-$i" -H 'X-Forwarded-For: 203.0.113.66' -F 'content=hi'; done; echo "Done." +``` + +In Grafana: menu → **Alerting** → **Alert rules** → open the **AI Server** folder. +Wait up to 2 minutes and refresh. + +**PASS:** **Auth brute force from a single IP** turns red (**Firing**), +and an email lands in Mailpit naming the address `203.0.113.66`. + +**7.2 — Alarm 2: bad tries from many addresses.** +Paste this one line. It knocks 60 times, each from a different address: + +```bash +for i in $(seq 1 60); do curl -s -o /dev/null -X POST http://localhost:5001/chat -H "X-API-KEY: spray-$i" -H "X-Forwarded-For: 198.51.100.$i" -F 'content=hi'; done; echo "Done." +``` + +**PASS:** **Auth failure spray across many IPs** turns **Firing**, +and a second email arrives. It is marked **critical**. + +> Alarm 1 may ring again during this test. That is normal. +> Later you may get `[RESOLVED]` emails. That just means the alarms turned off. + +--- + +## Results sheet + +| # | What it checks | Pass? | +|---|---|---| +| 5.4 | AI feedback in MarkUs starts with `TA_OK:` (instructions get through) | ☐ | +| 6.2 | Bad key shows a clean 401 in MarkUs, not a 500 crash | ☐ | +| 6.3 | The failed try is in the log book, with the address, without the key | ☐ | +| 7.1 | Brute force alarm fires and emails | ☐ | +| 7.2 | Spray alarm fires and emails | ☐ | + +If all five boxes are checked, everything we built works. + +--- + +## If something goes wrong + +| What you saw | What it likely means | +|---|---| +| MarkUs page never loads | First start is slow. Wait 10 minutes. Then run `docker compose logs rails` in `~/work/Markus` and look for errors. | +| Autotest URL **Test** button fails | The Autotester is not running, or its key changed. Redo steps 1.5 and 3.3. | +| Autotester tests never start, or setup errors | The Autotester may be missing its parts (fresh checkout). In `~/work/autotesting` run `docker compose run --rm server-deps-updater` and then `docker compose run --rm client-deps-updater`, then restart it. | +| Error says `LimitExceededException` | The Autotester allows 20 calls per minute. Wait one minute and try again, or raise the limit: `docker exec autotesting-redis-1 redis-cli set "autotest:ratelimit::limit" 200` (the api key is on the course's autotest setting). | +| Error says connection refused to `localhost:3000` | MarkUs told the Autotester to call back at an address only your browser knows. Use a MarkUs build that sets `MARKUS_URL` to an address the Autotester can reach, or do every save and test run from a `host.docker.internal` browser tab. | +| Test result says 401 **Missing API key** | The Autotester has no key to send. Redo the `REMOTE_API_KEY` check at the end of step 1.5. | +| Test result says 401 **Invalid API key** | The AI server does not accept the key the Autotester sent. Redo step 1.3. Also check the result's date: the page shows the **last finished run**, which may be old. Click **Run Tests** for a fresh one. | +| Test result says "System Prompt file ... not found" | The system_prompt box holds plain words. It must be a **file name**. Redo steps 4.1–4.4. | +| Test result says model not found | The Model Name box is empty or misspelled. Set it to `qwen3:1.7b` and save again. | +| Test result says timeout | The AI was too slow. Raise Timeout to `600` and run again. | +| Feedback appears but no `TA_OK:` | Run the test once more. If it is still missing, the instructions fix is broken. Report it. | +| Test result shows error 500 | The wrong-key fix is broken. Report it. | +| Log book empty in Grafana | Logs are not flowing. The collector or Loki may be off. Redo step 1.2. | +| No alarm email after 2 minutes | Check the alert rules page first. If the rule fires but no email comes, Grafana cannot reach Mailpit. | + +When you report a problem, copy the exact words you saw. That helps a lot. diff --git a/opentelemetry_collector/alert_rules.yml b/opentelemetry_collector/alert_rules.yml new file mode 100644 index 0000000..2164ac4 --- /dev/null +++ b/opentelemetry_collector/alert_rules.yml @@ -0,0 +1,45 @@ +groups: + # Alert rules over the app's HTTP metrics. The app does not emit OTLP metrics + # yet (it sends audit logs only), so these rules sit in "no data" and cannot + # fire until HTTP metric instrumentation is added. The intrusion-detection + # alerts live in Grafana, not here. + - name: ai_server_alerts + interval: 30s + rules: + # Alert when service appears down (no requests) + - alert: ServiceDown + expr: rate(ai_server_http_server_duration_milliseconds_count[2m]) == 0 + for: 3m + labels: + severity: critical + annotations: + summary: "AI Server appears to be down" + description: "No HTTP requests received in the last 3 minutes." + + # Alert on high error rate (>10% of requests failing) + - alert: HighErrorRate + expr: | + ( + sum(rate(ai_server_http_server_duration_milliseconds_count{http_status_code=~"5.."}[5m])) + / + sum(rate(ai_server_http_server_duration_milliseconds_count[5m])) + ) > 0.10 + for: 3m + labels: + severity: warning + annotations: + summary: "High error rate detected" + description: "More than 10% of requests are failing with 5xx errors." + + # Alert on very slow response times (p95 > 5 seconds) + - alert: HighLatency + expr: | + histogram_quantile(0.95, + sum(rate(ai_server_http_server_duration_milliseconds_bucket[5m])) by (le) + ) > 5000 + for: 5m + labels: + severity: warning + annotations: + summary: "High latency detected" + description: "95th percentile response time is above 5 seconds." diff --git a/opentelemetry_collector/alertmanager.yml b/opentelemetry_collector/alertmanager.yml new file mode 100644 index 0000000..2125b56 --- /dev/null +++ b/opentelemetry_collector/alertmanager.yml @@ -0,0 +1,46 @@ +# Alertmanager configuration for ai-server alerts +# Docs: https://prometheus.io/docs/alerting/latest/configuration/ + +global: + # Default email settings (configure these with your SMTP server) + # OPTION 1: Gmail (requires app password) + # smtp_smarthost: 'smtp.gmail.com:587' + # smtp_from: 'your-email@gmail.com' + # smtp_auth_username: 'your-email@gmail.com' + # smtp_auth_password: 'your-app-password' + + # OPTION 2: Use a local mail relay + # smtp_smarthost: 'localhost:25' + # smtp_from: 'alertmanager@localhost' + # smtp_require_tls: false + +route: + # Default route - groups alerts by alert name + group_by: ['alertname'] + group_wait: 30s # Wait 30s before sending first alert in a group + group_interval: 5m # Wait 5m before sending updates for existing group + repeat_interval: 4h # Resend alert every 4h if still firing + receiver: 'default' # Default receiver (see below) + +receivers: + # Default receiver - no notifier configured, so alerts appear only in the + # Alertmanager UI (localhost:9093) and are not sent anywhere. + - name: 'default' + # Uncomment and configure email settings: + # email_configs: + # - to: 'your-email@example.com' + # headers: + # Subject: '[ALERT] {{ .GroupLabels.alertname }}' + + # Example: Critical alerts go to a different email/channel + # - name: 'critical' + # email_configs: + # - to: 'oncall@example.com' + +# Optional: Inhibition rules (suppress certain alerts when others fire) +# inhibit_rules: +# - source_match: +# severity: 'critical' +# target_match: +# severity: 'warning' +# equal: ['alertname', 'service'] diff --git a/opentelemetry_collector/config.yml b/opentelemetry_collector/config.yml new file mode 100644 index 0000000..b74a187 --- /dev/null +++ b/opentelemetry_collector/config.yml @@ -0,0 +1,86 @@ +# OpenTelemetry Collector Configuration for ai-server monitoring +receivers: + # Receives telemetry from the Flask app via OTLP. Today the app sends audit + # logs only; the traces and metrics pipelines below are ready for when the app + # adds tracing or metric instrumentation. + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 # the app's OTLP exporter targets this (gRPC) + http: + endpoint: 0.0.0.0:4318 # alternative HTTP endpoint (not currently used) + +connectors: + # spanmetrics connector - Generates RED metrics (calls, errors, duration) from traces + # This is what Jaeger SPM requires to function + spanmetrics: + histogram: + explicit: + # Latency buckets in seconds (0.001s = 1ms, 10s = 10000ms) + buckets: [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10] + dimensions: + # Group metrics by these span attributes + - name: http.method # GET, POST, etc. + - name: http.status_code # 200, 500, etc. + metrics_flush_interval: 15s # Export metrics every 15 seconds + +processors: + # Batches telemetry (traces, metrics, and the audit logs) for efficient export + batch: + timeout: 10s # Send a batch at least every 10 seconds + send_batch_size: 100 # OR as soon as 100 items accumulate + +exporters: + # Console output - shows exported telemetry in the collector's logs (all pipelines) + debug: + verbosity: normal + + # OTLP exporter - sends traces to Jaeger via OTLP protocol + # Jaeger container exposes OTLP receiver on port 4317 + otlp: + endpoint: jaeger:4317 # Jaeger's OTLP receiver (Docker network) + tls: + insecure: true # No TLS encryption (ok for local development) + + # Prometheus exporter - exposes metrics in Prometheus format + # Prometheus will scrape this endpoint every 15 seconds + prometheus: + endpoint: "0.0.0.0:8889" # Expose metrics for Prometheus to scrape + namespace: "ai_server" # Prefix for all metrics (ai_server_http_requests_total) + const_labels: # Labels added to all metrics + environment: "development" + + # OTLP/HTTP exporter - sends audit logs to Loki's native OTLP endpoint. + # The dedicated `loki` exporter was removed from collector-contrib (2024); + # Loki 3.0+ ingests OTLP directly. otlphttp appends /v1/logs to this base. + otlphttp/loki: + endpoint: http://loki:3100/otlp + tls: + insecure: true + +service: + pipelines: + # Traces pipeline - receives traces from Flask, sends to Jaeger AND spanmetrics + traces: + receivers: [otlp] # traces, once the app emits them + processors: [batch] # Batch the spans + exporters: [debug, otlp, spanmetrics] # Send to console, Jaeger, AND spanmetrics connector + + # Spanmetrics-generated metrics pipeline - RED metrics for Jaeger SPM + metrics/spanmetrics: + receivers: [spanmetrics] # Receive metrics generated from traces by spanmetrics connector + processors: [batch] + exporters: [prometheus] # Export to Prometheus (Jaeger reads from here) + + # Application-generated metrics pipeline - ready for app metric instrumentation + metrics: + receivers: [otlp] # app metrics, once the app emits them + processors: [batch] + exporters: [debug, prometheus] # Send to console AND Prometheus + + # Audit logs pipeline - ships structured auth events to Loki for + # intrusion detection, plus console for debugging. + logs: + receivers: [otlp] + processors: [batch] + exporters: [debug, otlphttp/loki] diff --git a/opentelemetry_collector/grafana/provisioning/alerting/contactpoints.yml b/opentelemetry_collector/grafana/provisioning/alerting/contactpoints.yml new file mode 100644 index 0000000..cc87752 --- /dev/null +++ b/opentelemetry_collector/grafana/provisioning/alerting/contactpoints.yml @@ -0,0 +1,14 @@ +# Email contact point for ai-server intrusion alerts. +# In test, Grafana's SMTP points at Mailpit; in production the sysadmins +# override GF_SMTP_* and this address with the real security alias. +apiVersion: 1 + +contactPoints: + - orgId: 1 + name: ai-server-email + receivers: + - uid: ai-server-email + type: email + settings: + addresses: ai-server-security@markus.local + singleEmail: true diff --git a/opentelemetry_collector/grafana/provisioning/alerting/policies.yml b/opentelemetry_collector/grafana/provisioning/alerting/policies.yml new file mode 100644 index 0000000..896c0ec --- /dev/null +++ b/opentelemetry_collector/grafana/provisioning/alerting/policies.yml @@ -0,0 +1,11 @@ +# Route all ai-server intrusion alerts to the email contact point. +# Grouped by IP so a per-IP brute force and a global spray notify separately. +apiVersion: 1 + +policies: + - orgId: 1 + receiver: ai-server-email + group_by: ['alertname', 'client_ip'] + group_wait: 10s + group_interval: 1m + repeat_interval: 1h diff --git a/opentelemetry_collector/grafana/provisioning/alerting/rules.yml b/opentelemetry_collector/grafana/provisioning/alerting/rules.yml new file mode 100644 index 0000000..65fe82b --- /dev/null +++ b/opentelemetry_collector/grafana/provisioning/alerting/rules.yml @@ -0,0 +1,117 @@ +# Intrusion-detection alert rules over ai-server audit logs. +# Audit events arrive via OTLP, so their fields (event, client_ip) are Loki +# structured metadata: filter them directly, no `| json` parser. +# Each rule: LogQL query (instant) -> Reduce (last) -> Threshold. +apiVersion: 1 + +groups: + - orgId: 1 + name: ai-server-intrusion-detection + folder: AI Server + interval: 1m + rules: + # Rule 1 - brute force: one IP with more than 10 auth failures in 5 minutes. + - uid: auth-bruteforce-per-ip + title: Auth brute force from a single IP + condition: threshold + for: 0m + noDataState: OK + execErrState: Error + labels: + service: ai-server + severity: warning + annotations: + summary: 'More than 10 failed API-key attempts from {{ $labels.client_ip }} in 5 minutes' + description: 'Possible brute force against the ai-server API key from {{ $labels.client_ip }}.' + data: + - refId: query + relativeTimeRange: + from: 300 + to: 0 + datasourceUid: loki + model: + datasource: + type: loki + uid: loki + editorMode: code + expr: 'sum by (client_ip) (count_over_time({service_name="ai-server"} | event="auth_failure" [5m]))' + queryType: instant + intervalMs: 1000 + maxDataPoints: 43200 + refId: query + - refId: reduce + datasourceUid: __expr__ + model: + type: reduce + datasource: + type: __expr__ + uid: __expr__ + expression: query + reducer: last + refId: reduce + - refId: threshold + datasourceUid: __expr__ + model: + type: threshold + datasource: + type: __expr__ + uid: __expr__ + expression: reduce + conditions: + - evaluator: + type: gt + params: [10] + refId: threshold + + # Rule 2 - spray/flood: more than 50 auth failures across all IPs in 5 minutes. + - uid: auth-spray-global + title: Auth failure spray across many IPs + condition: threshold + for: 0m + noDataState: OK + execErrState: Error + labels: + service: ai-server + severity: critical + annotations: + summary: 'More than 50 failed API-key attempts across all IPs in 5 minutes' + description: 'Possible distributed/spray attack against the ai-server API key.' + data: + - refId: query + relativeTimeRange: + from: 300 + to: 0 + datasourceUid: loki + model: + datasource: + type: loki + uid: loki + editorMode: code + expr: 'sum (count_over_time({service_name="ai-server"} | event="auth_failure" [5m]))' + queryType: instant + intervalMs: 1000 + maxDataPoints: 43200 + refId: query + - refId: reduce + datasourceUid: __expr__ + model: + type: reduce + datasource: + type: __expr__ + uid: __expr__ + expression: query + reducer: last + refId: reduce + - refId: threshold + datasourceUid: __expr__ + model: + type: threshold + datasource: + type: __expr__ + uid: __expr__ + expression: reduce + conditions: + - evaluator: + type: gt + params: [50] + refId: threshold diff --git a/opentelemetry_collector/grafana/provisioning/datasources/datasources.yml b/opentelemetry_collector/grafana/provisioning/datasources/datasources.yml new file mode 100644 index 0000000..bee75f3 --- /dev/null +++ b/opentelemetry_collector/grafana/provisioning/datasources/datasources.yml @@ -0,0 +1,20 @@ +# Grafana data sources: one pane over logs, metrics, and traces. +apiVersion: 1 + +datasources: + - name: Loki + type: loki + uid: loki # fixed uid so provisioned alert rules can reference it + access: proxy + url: http://loki:3100 + isDefault: true + + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + + - name: Jaeger + type: jaeger + access: proxy + url: http://jaeger:16686 diff --git a/opentelemetry_collector/loki.yml b/opentelemetry_collector/loki.yml new file mode 100644 index 0000000..cc39284 --- /dev/null +++ b/opentelemetry_collector/loki.yml @@ -0,0 +1,47 @@ +# Loki 3.x single-binary config for ai-server audit logs. +# Filesystem storage; native OTLP ingestion; 90-day retention. +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + log_level: warn + +common: + instance_addr: 127.0.0.1 + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +schema_config: + configs: + # tsdb + schema v13 are required for structured metadata (OTLP attributes). + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +storage_config: + tsdb_shipper: + active_index_directory: /loki/index + cache_location: /loki/index_cache + +limits_config: + # OTLP log/resource attributes (event, result, client_ip, ...) land as + # structured metadata, queryable in LogQL without a parser. + allow_structured_metadata: true + retention_period: 2160h # 90 days + +compactor: + working_directory: /loki/compactor + retention_enabled: true + delete_request_store: filesystem diff --git a/opentelemetry_collector/prometheus.yml b/opentelemetry_collector/prometheus.yml new file mode 100644 index 0000000..5548662 --- /dev/null +++ b/opentelemetry_collector/prometheus.yml @@ -0,0 +1,30 @@ +# Prometheus configuration for scraping OpenTelemetry Collector metrics +global: + scrape_interval: 15s # How often to scrape metrics + evaluation_interval: 15s # How often to evaluate rules + +# Alerting configuration +alerting: + alertmanagers: + - static_configs: + - targets: ['alertmanager:9093'] # Alertmanager service + +# Load alert rules +rule_files: + - '/etc/prometheus/alert_rules.yml' + +# Scrape configuration +scrape_configs: + # Scrape metrics from OpenTelemetry Collector (collector's own health metrics) + - job_name: 'otel-collector' + static_configs: + - targets: ['otel-collector:8888'] # Collector's internal metrics endpoint + labels: + service: 'otel-collector' + + # Scrape metrics that the collector exports (spanmetrics + Flask HTTP metrics) + - job_name: 'ai-server' + static_configs: + - targets: ['otel-collector:8889'] # Collector's Prometheus exporter + labels: + service: 'ai-server' diff --git a/pyproject.toml b/pyproject.toml index 9dca098..3333e7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,8 @@ license-files = ["LICEN[CS]E*"] dependencies = [ "flask", "ollama", + "opentelemetry-sdk", + "opentelemetry-exporter-otlp-proto-grpc", "python-dotenv", "redis", "requests", @@ -31,6 +33,7 @@ dependencies = [ dev = [ "pre-commit", "pytest", + "pytest-cov", ] [project.urls] diff --git a/src/markus_ai_server/__main__.py b/src/markus_ai_server/__main__.py index 9395c8f..8f14f74 100644 --- a/src/markus_ai_server/__main__.py +++ b/src/markus_ai_server/__main__.py @@ -1,3 +1,7 @@ +import os + from .server import app -app.run(debug=True, host="0.0.0.0") +# Debug mode exposes the Werkzeug remote-code-execution debugger; opt in +# explicitly for local development only. +app.run(debug=os.getenv('FLASK_DEBUG') == '1', host="0.0.0.0") diff --git a/src/markus_ai_server/server.py b/src/markus_ai_server/server.py index 5684740..c146c14 100644 --- a/src/markus_ai_server/server.py +++ b/src/markus_ai_server/server.py @@ -13,8 +13,10 @@ import requests from dotenv import load_dotenv from flask import Flask, abort, jsonify, request +from werkzeug.exceptions import HTTPException from .redis_helper import REDIS_CONNECTION +from .telemetry import configure_audit_logging, log_auth_failure # Configure logger logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(name)s - %(message)s') @@ -25,6 +27,10 @@ app = Flask('AI server') +# Audit logging / intrusion detection. TRUSTED_PROXY_HOPS must be 0 when the +# app is reached directly, or X-Forwarded-For becomes a spoofing hole. +configure_audit_logging(app) + # Configuration from environment variables DEFAULT_MODEL = os.getenv('DEFAULT_MODEL', 'deepseek-coder-v2:latest') @@ -89,15 +95,13 @@ def chat_with_llama_server_http( ) done_log_data = {'model': model, 'response_status_code': response.status_code} - logger.info(f'chat_with_llama_server_http done: {start_log_data}') - if response.status_code == 200: - data = response.json() - if 'choices' in data and len(data['choices']) > 0: - return data['choices'][0]['message']['content'] - else: - raise Exception("Invalid response format from llama-server") - else: - raise Exception(f"Llama-server HTTP error") + logger.info(f'chat_with_llama_server_http done: {done_log_data}') + if response.status_code != 200: + raise Exception(f"Llama-server HTTP error {response.status_code}") + data = response.json() + if 'choices' not in data or not data['choices']: + raise Exception("Invalid response format from llama-server") + return data['choices'][0]['message']['content'] except requests.Timeout: raise Exception(f"Llama-server request timed out for model {model}") @@ -248,18 +252,18 @@ def chat_with_model( ) -def authenticate() -> str: +def authenticate() -> bytes: """Authenticate the given request using an API key.""" api_key = request.headers.get('X-API-KEY') client_ip = request.remote_addr endpoint = request.path if not api_key: - logger.warning(f"Missing API key from {client_ip} at {endpoint}") + log_auth_failure('missing_key', client_ip, endpoint) abort(401, description="Missing API key") user = REDIS_CONNECTION.get(f"api-key:{api_key}") if not user: - logger.warning(f"Invalid API key attempt from {client_ip} at {endpoint}") + log_auth_failure('invalid_key', client_ip, endpoint) abort(401, description="Invalid API key") return user @@ -272,9 +276,13 @@ def chat(): model = request.form.get('model', DEFAULT_MODEL) content = request.form.get('content', '') llama_mode = request.form.get('llama_mode', 'cli') - system_prompt = request.form.get('system_prompt') + # Accept either field name: callers send 'system_prompt', the ai_feedback + # RemoteModel sends 'system_instructions'. Prefer the former when both exist. + system_prompt = request.form.get('system_prompt') or request.form.get('system_instructions') image_files = list(request.files.values()) model_options = request.form.get('model_options') + if model_options: + model_options = json.loads(model_options) json_schema = request.form.get('json_schema') if json_schema: json_schema = json.loads(json_schema) @@ -289,5 +297,9 @@ def chat(): @app.errorhandler(Exception) -def internal_error(error): +def handle_exception(error): + # Preserve aborts (e.g. 401 from authenticate, 400 for missing content) + # instead of collapsing every error into a 500. + if isinstance(error, HTTPException): + return jsonify({"error": error.description}), error.code return jsonify({"error": str(error)}), 500 diff --git a/src/markus_ai_server/telemetry.py b/src/markus_ai_server/telemetry.py new file mode 100644 index 0000000..cccebad --- /dev/null +++ b/src/markus_ai_server/telemetry.py @@ -0,0 +1,87 @@ +"""Audit logging and proxy handling for intrusion detection. + +Emits structured authentication audit events through the OpenTelemetry logs SDK +over OTLP, so they can be stored in Loki and monitored for brute-force / spray +attacks. Setup is best-effort: a missing collector or a missing/experimental SDK +never blocks a request. +""" + +from __future__ import annotations + +import logging +import os +from typing import Optional + +logger = logging.getLogger('ai-server') + + +def apply_proxy_fix(app, trusted_proxy_hops: int) -> None: + """Make ``request.remote_addr`` report the real client IP behind a proxy. + + When ``trusted_proxy_hops > 0`` the Werkzeug ProxyFix middleware rewrites the + remote address from the rightmost N entries of ``X-Forwarded-For``. When 0 + (the app is reached directly) the middleware is deliberately NOT applied: + trusting a client-supplied header would let any caller spoof their IP and + defeat per-IP intrusion detection. + """ + if trusted_proxy_hops > 0: + from werkzeug.middleware.proxy_fix import ProxyFix + + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=trusted_proxy_hops) + + +def setup_audit_logging(service_name: str) -> None: + """Route ``ai-server`` logs through OTLP to the collector (best-effort). + + Uses the OpenTelemetry logs SDK, which is still experimental. Any failure is + swallowed so the application keeps serving even when telemetry is + unavailable. Activates only when ``OTEL_EXPORTER_OTLP_ENDPOINT`` is set, so + local/test/CLI runs neither attempt nor retry exports. + """ + if not os.getenv('OTEL_EXPORTER_OTLP_ENDPOINT'): + return + try: + from opentelemetry._logs import set_logger_provider + from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter + from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler + from opentelemetry.sdk._logs.export import BatchLogRecordProcessor + from opentelemetry.sdk.resources import Resource + + provider = LoggerProvider(resource=Resource.create({'service.name': service_name})) + set_logger_provider(provider) + # BatchLogRecordProcessor exports on a background thread, so a down + # collector never blocks the request path. + provider.add_log_record_processor(BatchLogRecordProcessor(OTLPLogExporter())) + logger.addHandler(LoggingHandler(logger_provider=provider)) + except Exception as exc: # noqa: BLE001 - telemetry must never break the app + logger.warning(f'OTLP audit logging not initialized: {exc}') + + +def configure_audit_logging(app) -> None: + """Wire audit logging and proxy handling from environment configuration. + + Reads ``TRUSTED_PROXY_HOPS`` (trusted reverse proxies in front of the app; + 0 = reached directly) and ``OTEL_SERVICE_NAME`` (service.name on telemetry). + """ + apply_proxy_fix(app, int(os.getenv('TRUSTED_PROXY_HOPS', '0'))) + setup_audit_logging(os.getenv('OTEL_SERVICE_NAME', 'ai-server')) + + +def log_auth_failure(result: str, client_ip: Optional[str], endpoint: str) -> None: + """Emit a structured auth-failure audit event. + + Carries metadata only -- never the API key, prompt, or response body -- so + the log store stays free of sensitive content. The fields travel as OTel log + attributes and become Loki structured metadata, queryable by ``client_ip``. + + ``result`` is one of ``missing_key`` or ``invalid_key``. + """ + logger.warning( + 'authentication failure', + extra={ + 'event': 'auth_failure', + 'result': result, + 'client_ip': client_ip, + 'endpoint': endpoint, + }, + ) diff --git a/test/e2e/suite.sh b/test/e2e/suite.sh new file mode 100755 index 0000000..183b263 --- /dev/null +++ b/test/e2e/suite.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# End-to-end test SUITE for the intrusion-detection feature. +# +# Assumes the stack is running: +# docker compose --profile monitoring up -d --build +# +# Validates the whole chain across several scenarios and prints a pass/fail +# tally. Exit code is non-zero if any scenario fails. +set -uo pipefail + +APP=${APP_URL:-http://localhost:5001} +LOKI=${LOKI_URL:-http://localhost:3100} +GRAFANA=${GRAFANA_URL:-http://localhost:3001} +MAILPIT=${MAILPIT_URL:-http://localhost:8025} + +PASS=0 +FAIL=0 +note() { printf '\n=== %s ===\n' "$1"; } +ok() { echo "PASS: $1"; PASS=$((PASS + 1)); } +bad() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); } + +send_bad_key() { # $1=api-key suffix $2=client ip + curl -s -o /dev/null -X POST "${APP}/chat" -H "X-API-KEY: bogus-$1" -H "X-Forwarded-For: $2" -F "content=hi" || true +} + +loki_count() { # $1=logql -> single number (0 if none) + curl -s "${LOKI}/loki/api/v1/query" --data-urlencode "query=$1" | python3 -c "import sys,json; r=json.load(sys.stdin).get('data',{}).get('result',[]); print(int(float(r[0]['value'][1])) if r else 0)" 2>/dev/null || echo 0 +} + +auth_failures_from_ip() { # $1=client ip -> auth-failure count in Loki over the last 5m + loki_count "sum by (client_ip) (count_over_time({service_name=\"ai-server\"} | event=\"auth_failure\" | client_ip=\"$1\" [5m]))" +} + +mailpit_subjects() { + curl -s "${MAILPIT}/api/v1/messages" | python3 -c "import sys,json; [print(m.get('Subject','')) for m in json.load(sys.stdin).get('messages',[])]" 2>/dev/null +} + +mailpit_clear() { curl -s -X DELETE "${MAILPIT}/api/v1/messages" >/dev/null || true; } + +wait_for_subject() { # $1=case-insensitive subject substring; 0 if seen within ~3 min. + # grep -c reads the whole stream, so a match cannot SIGPIPE the pipe under pipefail. + for _ in $(seq 1 18); do + [ "$(mailpit_subjects | grep -ci "$1")" -ge 1 ] && return 0 + sleep 10 + done + return 1 +} + +# --------------------------------------------------------------------------- +note "T1: all monitoring services reachable" +for pair in "app:${APP}/" "loki:${LOKI}/ready" "grafana:${GRAFANA}/api/health" "mailpit:${MAILPIT}/api/v1/messages"; do + url=${pair#*:}; svc=${pair%%:*} + code=$(curl -s -o /dev/null -w '%{http_code}' "$url") + [ "$code" != "000" ] && ok "$svc reachable ($code)" || bad "$svc unreachable" +done + +note "T2: app emits structured audit event with proxied client IP" +send_bad_key probe 203.0.113.42 +# The WARNING line reaches stdout asynchronously, so retry briefly. Use +# `grep -c` (reads the whole stream) rather than `grep -q` (exits early and +# SIGPIPEs `docker logs`, which under `set -o pipefail` would falsely fail). +app_logged_auth_failure() { + for _ in $(seq 1 10); do + [ "$(docker logs ai-server-app 2>&1 | grep -c 'authentication failure')" -ge 1 ] && return 0 + sleep 1 + done + return 1 +} +app_logged_auth_failure && ok "auth-failure event emitted by app" || bad "no auth-failure event in app logs" + +note "T3: events reach Loki as structured metadata (queryable by client_ip)" +# OTLP export -> collector -> Loki is batched/async, so poll instead of a +# single sleep (which races the first export flush after an app restart). +c=0 +for _ in $(seq 1 20); do + c=$(auth_failures_from_ip 203.0.113.42) + [ "$c" -ge 1 ] && break + sleep 2 +done +[ "$c" -ge 1 ] && ok "Loki has events for 203.0.113.42 (count=$c)" || bad "Loki missing events for probe IP (count=$c)" + +note "T4: Grafana provisioning loaded (datasource + 2 rules + contact point)" +rules=$(curl -s "${GRAFANA}/api/v1/provisioning/alert-rules" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null || echo 0) +[ "$rules" -ge 2 ] && ok "$rules alert rules provisioned" || bad "expected >=2 alert rules, got $rules" +cp_hits=$(curl -s "${GRAFANA}/api/v1/provisioning/contact-points" | grep -c "ai-server-email") +[ "${cp_hits:-0}" -ge 1 ] && ok "email contact point present" || bad "email contact point missing" + +note "T5: Rule 1 (brute force) fires and emails" +mailpit_clear +for i in $(seq 1 12); do send_bad_key "$i" 203.0.113.66; done +echo "waiting up to 3 min for Rule 1 email..." +wait_for_subject "brute force" && ok "Rule 1 brute-force alert email received" || bad "Rule 1 email not received" + +note "T6: Rule 2 (spray) fires and emails" +for i in $(seq 1 55); do send_bad_key spray "198.51.100.$i"; done +echo "waiting up to 3 min for Rule 2 email..." +wait_for_subject "spray" && ok "Rule 2 spray alert email received" || bad "Rule 2 email not received" + +note "T7: negative — a sub-threshold IP stays below the brute-force bar" +send_bad_key low 192.0.2.7; send_bad_key low 192.0.2.7; send_bad_key low 192.0.2.7 +sleep 3 +c=$(auth_failures_from_ip 192.0.2.7) +[ "$c" -lt 10 ] && ok "sub-threshold IP count=$c (<10, would not fire Rule 1)" || bad "sub-threshold IP unexpectedly high (count=$c)" + +note "T8: secrets never reach the log store" +# Use `grep -c` (reads the whole stream) so a match cannot SIGPIPE curl and, +# under `set -o pipefail`, mask a leaked secret as a false PASS. +secret_hits=$(curl -s "${LOKI}/loki/api/v1/query_range" --data-urlencode 'query={service_name="ai-server"}' --data-urlencode 'limit=200' | grep -ciE "x-api-key|bogus-|api-key:") +if [ "${secret_hits:-0}" -ge 1 ]; then + bad "possible secret/key material found in Loki" +else + ok "no API keys or secrets found in Loki logs" +fi + +# --------------------------------------------------------------------------- +printf '\n================ E2E SUITE RESULT ================\n' +printf 'PASSED: %d FAILED: %d\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] && { echo "ALL GREEN"; exit 0; } || { echo "SUITE FAILED"; exit 1; } diff --git a/test/test_audit_logging.py b/test/test_audit_logging.py new file mode 100644 index 0000000..0ba3b45 --- /dev/null +++ b/test/test_audit_logging.py @@ -0,0 +1,166 @@ +"""Unit tests for audit logging / intrusion detection.""" + +import logging +import os +from unittest.mock import patch + +import pytest +from flask import Flask, request +from werkzeug.exceptions import HTTPException + +# Importing the app loads redis_helper, which reads REDIS_URL at import time. +# Set a default first so the import works in a bare test environment. +os.environ.setdefault('REDIS_URL', 'redis://localhost:6379') + +from markus_ai_server import telemetry # noqa: E402 +from markus_ai_server.server import app, authenticate # noqa: E402 + + +def _auth_events(caplog): + """Return only the structured auth-failure records captured.""" + return [r for r in caplog.records if getattr(r, 'event', None) == 'auth_failure'] + + +def _reject_and_capture(caplog, headers=None, environ_overrides=None): + """Run authenticate() with no valid key, assert the 401, return the audit record.""" + with patch('markus_ai_server.server.REDIS_CONNECTION') as redis_mock: + redis_mock.get.return_value = None # any presented key is unknown + with app.test_request_context('/chat', method='POST', headers=headers, environ_overrides=environ_overrides): + with caplog.at_level(logging.WARNING, logger='ai-server'): + with pytest.raises(HTTPException) as exc: + authenticate() + assert exc.value.code == 401 + return _auth_events(caplog)[-1] + + +def _post_chat(known_user, headers=None, data=None): + """POST /chat with REDIS_CONNECTION.get returning ``known_user`` (None = unknown key).""" + with patch('markus_ai_server.server.REDIS_CONNECTION') as redis_mock: + redis_mock.get.return_value = known_user + return app.test_client().post('/chat', headers=headers, data=data) + + +class TestStructuredAuditEvents: + def test_log_auth_failure_emits_all_fields(self, caplog): + with caplog.at_level(logging.WARNING, logger='ai-server'): + telemetry.log_auth_failure('invalid_key', '1.2.3.4', '/chat') + rec = _auth_events(caplog)[-1] + assert rec.event == 'auth_failure' + assert rec.result == 'invalid_key' + assert rec.client_ip == '1.2.3.4' + assert rec.endpoint == '/chat' + + def test_log_auth_failure_carries_no_secrets(self, caplog): + """Audit events must be metadata only -- no key, prompt, or body.""" + with caplog.at_level(logging.WARNING, logger='ai-server'): + telemetry.log_auth_failure('invalid_key', '1.2.3.4', '/chat') + rec = _auth_events(caplog)[-1] + blob = (rec.getMessage() + str(rec.__dict__)).lower() + assert 'api-key' not in blob + assert 'x-api-key' not in blob + assert not hasattr(rec, 'api_key') + + +class TestAuthenticateLogsEvents: + def test_missing_key_logs_missing_result(self, caplog): + rec = _reject_and_capture(caplog) + assert rec.result == 'missing_key' + assert rec.endpoint == '/chat' + + def test_invalid_key_logs_invalid_result_with_ip(self, caplog): + rec = _reject_and_capture( + caplog, headers={'X-API-KEY': 'bogus'}, environ_overrides={'REMOTE_ADDR': '203.0.113.7'} + ) + assert rec.result == 'invalid_key' + assert rec.client_ip == '203.0.113.7' + + def test_attempted_key_value_never_in_record(self, caplog): + """The key a caller tried must not appear in the audit record.""" + secret = 'sk-attacker-guess-2f9a' + rec = _reject_and_capture(caplog, headers={'X-API-KEY': secret}) + assert secret not in (rec.getMessage() + str(rec.__dict__)) + + +class TestAuthHttpContract: + """A rejected key must surface as 401, not a catch-all 500.""" + + def test_missing_key_returns_401(self): + resp = _post_chat(None, data={'content': 'hi'}) + assert resp.status_code == 401 + + def test_invalid_key_returns_401(self): + resp = _post_chat(None, headers={'X-API-KEY': 'nope'}, data={'content': 'hi'}) + assert resp.status_code == 401 + + def test_missing_content_returns_400_not_500(self): + """A valid key with empty content keeps its 400 (handler preserves it).""" + resp = _post_chat(b'alice', headers={'X-API-KEY': 'ok'}, data={'content': ' '}) + assert resp.status_code == 400 + + +class TestSetupAuditLogging: + """The OTLP setup is gated and best-effort.""" + + def test_noop_without_endpoint(self, monkeypatch): + monkeypatch.delenv('OTEL_EXPORTER_OTLP_ENDPOINT', raising=False) + before = list(telemetry.logger.handlers) + telemetry.setup_audit_logging('ai-server') + assert telemetry.logger.handlers == before # nothing attached + + def test_attaches_handler_when_endpoint_set(self, monkeypatch): + monkeypatch.setenv('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://localhost:4317') + before = len(telemetry.logger.handlers) + with ( + patch('opentelemetry._logs.set_logger_provider') as set_lp, + patch('opentelemetry.sdk._logs.LoggerProvider'), + patch('opentelemetry.sdk._logs.LoggingHandler'), + patch('opentelemetry.sdk._logs.export.BatchLogRecordProcessor'), + patch('opentelemetry.exporter.otlp.proto.grpc._log_exporter.OTLPLogExporter'), + ): + telemetry.setup_audit_logging('ai-server') + try: + assert set_lp.called + assert len(telemetry.logger.handlers) == before + 1 + finally: + while len(telemetry.logger.handlers) > before: + telemetry.logger.removeHandler(telemetry.logger.handlers[-1]) + + def test_swallows_setup_errors(self, monkeypatch, caplog): + monkeypatch.setenv('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://localhost:4317') + with patch('opentelemetry.sdk.resources.Resource.create', side_effect=RuntimeError('boom')): + with caplog.at_level(logging.WARNING, logger='ai-server'): + telemetry.setup_audit_logging('ai-server') # must not raise + assert any('not initialized' in r.getMessage() for r in caplog.records) + + +class TestClientIpResolution: + """ProxyFix must give the real client IP only when behind a trusted proxy.""" + + @staticmethod + def _ip_app(trusted_proxy_hops): + test_app = Flask(__name__) + telemetry.apply_proxy_fix(test_app, trusted_proxy_hops) + + @test_app.route('/ip') + def ip(): + return request.remote_addr or '' + + return test_app.test_client() + + def test_behind_proxy_uses_forwarded_for(self): + client = self._ip_app(trusted_proxy_hops=1) + resp = client.get( + '/ip', + headers={'X-Forwarded-For': '9.9.9.9'}, + environ_overrides={'REMOTE_ADDR': '10.0.0.1'}, + ) + assert resp.get_data(as_text=True) == '9.9.9.9' + + def test_direct_access_ignores_spoofed_forwarded_for(self): + client = self._ip_app(trusted_proxy_hops=0) # ProxyFix not applied + resp = client.get( + '/ip', + headers={'X-Forwarded-For': '9.9.9.9'}, # attacker-supplied + environ_overrides={'REMOTE_ADDR': '10.0.0.1'}, + ) + assert resp.get_data(as_text=True) == '10.0.0.1' diff --git a/test/test_system_prompt_api.py b/test/test_system_prompt_api.py index 38899a3..ceb73da 100644 --- a/test/test_system_prompt_api.py +++ b/test/test_system_prompt_api.py @@ -1,4 +1,3 @@ -import json from unittest.mock import patch import pytest @@ -6,6 +5,7 @@ TEST_MODEL = 'DeepSeek-V3-0324-UD-IQ2_XXS' TEST_SYSTEM_PROMPT = "You are a helpful coding assistant." TEST_USER_CONTENT = "Write a function" +MOCK_MODEL_ANSWER = "def function(): pass" class TestSystemPromptAPI: @@ -25,40 +25,72 @@ def client(self): with app.test_client() as client: yield client - @patch('markus_ai_server.server.REDIS_CONNECTION') - @patch('markus_ai_server.server.chat_with_model') - def test_api_with_system_prompt(self, mock_chat, mock_redis, client): - """Test /chat endpoint receives and passes system_prompt.""" + def _post_chat_and_assert_prompt_forwarded(self, client, mock_chat, mock_redis, form_fields, expected_prompt): + """POST /chat with a valid key and assert the resolved system prompt reaches the model.""" mock_redis.get.return_value = b'test_user' - mock_chat.return_value = "def function(): pass" + mock_chat.return_value = MOCK_MODEL_ANSWER response = client.post( '/chat', headers={'X-API-KEY': 'test-key'}, - data={'model': TEST_MODEL, 'content': TEST_USER_CONTENT, 'system_prompt': TEST_SYSTEM_PROMPT}, + data={'model': TEST_MODEL, 'content': TEST_USER_CONTENT, **form_fields}, ) assert response.status_code == 200 - mock_chat.assert_called_once_with( - TEST_MODEL, TEST_USER_CONTENT, 'cli', TEST_SYSTEM_PROMPT, [], json_schema=None, model_options=None + TEST_MODEL, TEST_USER_CONTENT, 'cli', expected_prompt, [], json_schema=None, model_options=None + ) + + @patch('markus_ai_server.server.REDIS_CONNECTION') + @patch('markus_ai_server.server.chat_with_model') + def test_api_with_system_prompt(self, mock_chat, mock_redis, client): + """Test /chat endpoint receives and passes system_prompt.""" + self._post_chat_and_assert_prompt_forwarded( + client, mock_chat, mock_redis, {'system_prompt': TEST_SYSTEM_PROMPT}, TEST_SYSTEM_PROMPT ) @patch('markus_ai_server.server.REDIS_CONNECTION') @patch('markus_ai_server.server.chat_with_model') def test_api_without_system_prompt(self, mock_chat, mock_redis, client): """Test /chat endpoint works without system_prompt.""" + self._post_chat_and_assert_prompt_forwarded(client, mock_chat, mock_redis, {}, None) + + @patch('markus_ai_server.server.REDIS_CONNECTION') + @patch('markus_ai_server.server.chat_with_model') + def test_api_accepts_system_instructions_alias(self, mock_chat, mock_redis, client): + """The ai_feedback RemoteModel sends 'system_instructions'; it must be honored.""" + self._post_chat_and_assert_prompt_forwarded( + client, mock_chat, mock_redis, {'system_instructions': TEST_SYSTEM_PROMPT}, TEST_SYSTEM_PROMPT + ) + + @patch('markus_ai_server.server.REDIS_CONNECTION') + @patch('markus_ai_server.server.chat_with_model') + def test_system_prompt_wins_over_alias(self, mock_chat, mock_redis, client): + """When both fields are sent, 'system_prompt' takes precedence.""" + self._post_chat_and_assert_prompt_forwarded( + client, + mock_chat, + mock_redis, + {'system_prompt': TEST_SYSTEM_PROMPT, 'system_instructions': 'ignored alias'}, + TEST_SYSTEM_PROMPT, + ) + + @patch('markus_ai_server.server.REDIS_CONNECTION') + @patch('markus_ai_server.server.chat_with_model') + def test_model_options_parsed_as_json(self, mock_chat, mock_redis, client): + """model_options arrives as a JSON form string and must reach the model as a dict.""" mock_redis.get.return_value = b'test_user' - mock_chat.return_value = "def function(): pass" + mock_chat.return_value = MOCK_MODEL_ANSWER response = client.post( - '/chat', headers={'X-API-KEY': 'test-key'}, data={'model': TEST_MODEL, 'content': TEST_USER_CONTENT} + '/chat', + headers={'X-API-KEY': 'test-key'}, + data={'model': TEST_MODEL, 'content': TEST_USER_CONTENT, 'model_options': '{"temperature": 0}'}, ) assert response.status_code == 200 - mock_chat.assert_called_once_with( - TEST_MODEL, TEST_USER_CONTENT, 'cli', None, [], model_options=None, json_schema=None + TEST_MODEL, TEST_USER_CONTENT, 'cli', None, [], json_schema=None, model_options={'temperature': 0} ) @patch('markus_ai_server.server.REDIS_CONNECTION') @@ -72,6 +104,6 @@ def test_api_authentication_still_required(self, mock_redis, client): data={'model': TEST_MODEL, 'content': TEST_USER_CONTENT, 'system_prompt': TEST_SYSTEM_PROMPT}, ) - assert response.status_code == 500 + assert response.status_code == 401 response_data = response.get_json() - assert "401 Unauthorized" in response_data['error'] + assert "Invalid API key" in response_data['error'] diff --git a/uv.lock b/uv.lock index 350fdaf..2d150e8 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,9 @@ version = 1 revision = 3 requires-python = ">=3.9" resolution-markers = [ - "python_full_version >= '3.10'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.10' and python_full_version < '3.13'", "python_full_version < '3.10'", ] @@ -73,7 +75,9 @@ name = "cfgv" version = "3.5.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.10'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.10' and python_full_version < '3.13'", ] sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } wheels = [ @@ -205,7 +209,9 @@ name = "click" version = "8.3.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.10'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.10' and python_full_version < '3.13'", ] dependencies = [ { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, @@ -224,6 +230,233 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.10.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, + { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, + { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, + { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, + { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, + { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, + { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, + { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, + { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, + { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, + { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, + { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, + { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, + { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, + { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, + { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, + { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, + { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, + { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, + { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, + { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, + { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, + { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, + { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, + { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, + { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, + { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978, upload-time = "2025-09-21T20:03:30.362Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370, upload-time = "2025-09-21T20:03:32.147Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802, upload-time = "2025-09-21T20:03:33.919Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/8a070782ce7e6b94ff6a0b6d7c65ba6bc3091d92a92cef4cd4eb0767965c/coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40", size = 246625, upload-time = "2025-09-21T20:03:36.09Z" }, + { url = "https://files.pythonhosted.org/packages/6a/92/1c1c5a9e8677ce56d42b97bdaca337b2d4d9ebe703d8c174ede52dbabd5f/coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594", size = 248399, upload-time = "2025-09-21T20:03:38.342Z" }, + { url = "https://files.pythonhosted.org/packages/c0/54/b140edee7257e815de7426d5d9846b58505dffc29795fff2dfb7f8a1c5a0/coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a", size = 245142, upload-time = "2025-09-21T20:03:40.591Z" }, + { url = "https://files.pythonhosted.org/packages/e4/9e/6d6b8295940b118e8b7083b29226c71f6154f7ff41e9ca431f03de2eac0d/coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b", size = 246284, upload-time = "2025-09-21T20:03:42.355Z" }, + { url = "https://files.pythonhosted.org/packages/db/e5/5e957ca747d43dbe4d9714358375c7546cb3cb533007b6813fc20fce37ad/coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3", size = 244353, upload-time = "2025-09-21T20:03:44.218Z" }, + { url = "https://files.pythonhosted.org/packages/9a/45/540fc5cc92536a1b783b7ef99450bd55a4b3af234aae35a18a339973ce30/coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0", size = 244430, upload-time = "2025-09-21T20:03:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/75/0b/8287b2e5b38c8fe15d7e3398849bb58d382aedc0864ea0fa1820e8630491/coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f", size = 245311, upload-time = "2025-09-21T20:03:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1d/29724999984740f0c86d03e6420b942439bf5bd7f54d4382cae386a9d1e9/coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431", size = 220500, upload-time = "2025-09-21T20:03:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/43/11/4b1e6b129943f905ca54c339f343877b55b365ae2558806c1be4f7476ed5/coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07", size = 221408, upload-time = "2025-09-21T20:03:51.803Z" }, + { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version < '3.10'" }, +] + +[[package]] +name = "coverage" +version = "7.14.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.10' and python_full_version < '3.13'", +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/91/0a7c28934e50d8ac9a7b117712d176f2953c3170bccced5eaacfa3e96175/coverage-7.14.3.tar.gz", hash = "sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f", size = 924398, upload-time = "2026-06-22T23:10:25.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bd/b01188f0de73ee8b6597cf20c63fccd898ad31405772f15165cb61a62c00/coverage-7.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:360bec1f58e7243e3405d3bdf7a1a8115aa9b448d54dc7cd6f7b7e0e9406b62e", size = 220378, upload-time = "2026-06-22T23:07:38.925Z" }, + { url = "https://files.pythonhosted.org/packages/33/eb/f7aa3cb46500b709070c8d12335446971ec8b8c2ea155fea05d2000b4b1f/coverage-7.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed68faa5e85de2f3e400bc3f122e5c82735a58c8bb24b9f63a2215954ba17b2d", size = 220895, upload-time = "2026-06-22T23:07:41.536Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/b41b8499fc9060ca40ad2a197d301155be1ead398f0f0bfdb27b2b4a660f/coverage-7.14.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:830c1fca669c572dec37ce9c838224ee45aac5be0f6961edf871e82e49d6537c", size = 247631, upload-time = "2026-06-22T23:07:43.244Z" }, + { url = "https://files.pythonhosted.org/packages/da/bb/e9ecea1307c6a549c223842cccbd5d55193cc27b82f26338782d4355047c/coverage-7.14.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a64caee2193563601dbaaa55fe2dcf597debef04a2f8f1fa8a07aa4bb7ac7a1e", size = 249460, upload-time = "2026-06-22T23:07:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/59/cb/3821542809b7b726296fd364ed1c23d10a5770f1469957010c3b4bc5d408/coverage-7.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0096fd7559178f0cc9cf088f2dbd2a02ef85bacaa69732c633517286b4494610", size = 251324, upload-time = "2026-06-22T23:07:46.875Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/f34f66f0ff152189ccc7b3f0582cf7909e239cb3b8c214362ed2149719b8/coverage-7.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6197e5a00183c11a8ce7c6abd18be1a9189fd8399084ffc95196f4f0db4f2137", size = 253237, upload-time = "2026-06-22T23:07:48.352Z" }, + { url = "https://files.pythonhosted.org/packages/22/81/aa363fa95d14fc892bd5de80edadc8d7cce584a0f6376f6336e492618e67/coverage-7.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7dfe427045520d6abca33687dfef767b4f635015893a1816c5decb12eb72ce18", size = 248344, upload-time = "2026-06-22T23:07:49.896Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/dc8a149441a3fea611cbbaf46bb12099adbe08f69903df1794581b0504b8/coverage-7.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9a3f142070eb7b82fc4085a55d887396f9c4e21250bccebe2ba22502c45b9647", size = 249365, upload-time = "2026-06-22T23:07:51.464Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a2/0004127deee122e020be24a4d86ce72fa14ae28198811b945aabf91293b5/coverage-7.14.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64b2055bb6e0dc945af35cdeceb3633e6ed9273475ef3af85592410fd6803803", size = 247369, upload-time = "2026-06-22T23:07:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/1e/72/3654c004f4df4f0c5a9643d9abaed5b26e5d3c1d0ecabe788786cb425efa/coverage-7.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1551b4caac3e3ec9f2bfcec6bf3776e01c0edbdd2e240431a50ca1a1aac72c27", size = 251182, upload-time = "2026-06-22T23:07:54.789Z" }, + { url = "https://files.pythonhosted.org/packages/a5/2f/7bdcdf1e7c4d0632648852768063c25582a0a747bb5f8036a04e211e7eb7/coverage-7.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:583d50d59142f8549470bd6390471d0fe8b8c8d69d6a0f28ac71e05380cef640", size = 247639, upload-time = "2026-06-22T23:07:56.254Z" }, + { url = "https://files.pythonhosted.org/packages/03/dc/0e01b071f69021d262a51ce39345dd6bc194465db0acfc7b34fd89e6b787/coverage-7.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0bb8a6bc7015efdf8a928753b25da1b9ca2d6f24ef04d2ee0688e486f32aae7", size = 248242, upload-time = "2026-06-22T23:07:57.692Z" }, + { url = "https://files.pythonhosted.org/packages/1c/51/08279e6ebe3479bf705db5fdc1a968e44ba1567e4cbc567f76b45f5e646e/coverage-7.14.3-cp310-cp310-win32.whl", hash = "sha256:d48400185564042287dc487c1f016a3397f18ab4f4c5d5ec36edc218f7ffa35b", size = 222431, upload-time = "2026-06-22T23:07:59.094Z" }, + { url = "https://files.pythonhosted.org/packages/40/2f/5c56670781fee5722ef0c415a74750c9a033bfacdb9d07b1493a0308108d/coverage-7.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:eadea7aba74e40adee867a8c0eec17b820b061d308a4b014f7a0e118c2b0aa61", size = 223059, upload-time = "2026-06-22T23:08:00.662Z" }, + { url = "https://files.pythonhosted.org/packages/f1/24/efb17eb94018dd3415d0e8a76a4786a866e8964aa9c50f033399d23939c2/coverage-7.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e574801e1d643561594aa021206c46d80b257e9853087090ba97bed8b0a509d3", size = 220501, upload-time = "2026-06-22T23:08:02.182Z" }, + { url = "https://files.pythonhosted.org/packages/76/93/32f1bfca6cdd34259c8af42820a034b7a28dfb44969a13ed38c17e0ba5b0/coverage-7.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f82b6bb7d75a2613e85d07cefa3a8c973d0544a8993337f6e2728e4a1e94c305", size = 221008, upload-time = "2026-06-22T23:08:03.701Z" }, + { url = "https://files.pythonhosted.org/packages/eb/88/0d0f974855ff905d15a64f7873d00bdc4182e2736267486c6634f4af293c/coverage-7.14.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a2335ea5fed26af2e831094964fa3f8fae60b45f7e37fcc2d3b615b2add3ad87", size = 251420, upload-time = "2026-06-22T23:08:05.211Z" }, + { url = "https://files.pythonhosted.org/packages/39/7f/117dd2ec65e4140576f8ef991d88220f9b806769f7a8c20e0550c0f924e2/coverage-7.14.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbb8c3a98e779013786ae01d229662aeacbc77100efbd3f2f245219ace5af700", size = 253331, upload-time = "2026-06-22T23:08:06.672Z" }, + { url = "https://files.pythonhosted.org/packages/87/55/f0bd6d6538e3f16829fb8a44b6c0d2fe9da638bbfdd6a20f8b5da8f4fa81/coverage-7.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac082660de8f429ba0ea363595abb838998570b9a7546777c60f413ab902bbde", size = 255441, upload-time = "2026-06-22T23:08:08.208Z" }, + { url = "https://files.pythonhosted.org/packages/1e/98/aa71f7879019c846a8a9662579ea4484b0202cf1e252ffeed647075e7eca/coverage-7.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac012839ff7e396030f1e94e10553a431d14e4de2ab65cb3acb72bbd5628ca2", size = 257398, upload-time = "2026-06-22T23:08:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/f3/4f/5fd367e59844190f5965015d7bee899e67a89d13eb2760118479bf836f2f/coverage-7.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5952f8c1bda2a5347154450379316e6dfa4d934d62ca35f6784451e6f55074fb", size = 251558, upload-time = "2026-06-22T23:08:11.37Z" }, + { url = "https://files.pythonhosted.org/packages/8f/de/5383a6ee5a6376701fe07d980fa8e4a66c0c377fead16712720340d701a3/coverage-7.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8cf0f2509acb4619e2471a1951089054dd58ebea7a912066d2ea56dd4c24ca4a", size = 253134, upload-time = "2026-06-22T23:08:13.04Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/09542b1a99f788e3daec7f0fadc288821e71aca9ea298d51bfa1ba79fed5/coverage-7.14.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2e41fd3aab806770008279a93879b0924b16247e09ab537c043d08bbca53b4ab", size = 251195, upload-time = "2026-06-22T23:08:14.606Z" }, + { url = "https://files.pythonhosted.org/packages/02/9d/722fe8c13f0fbb064491b9e8656e56a606286792e5068c47ca1042e773e8/coverage-7.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f0a47095963cfe054e0df178daca95aec21e680d6076da807c3add28dfe920f7", size = 254959, upload-time = "2026-06-22T23:08:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/fb/58/943627179ff1d82da9e54d0a5b0bb907bb19cf19515599ccd921de50b469/coverage-7.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a090cbf9521e78ffdb2fcf448b72902afe9f5923ff6a12d5c0d0120200348af9", size = 250914, upload-time = "2026-06-22T23:08:18.03Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d4/803efcbf9ae5567454a0c71e983589529448e2704ee0da2dc0163d482f18/coverage-7.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d310baf69a4fbe8a098ce727e4808a34866ac718a6f759ae659cbd3221358bc", size = 251824, upload-time = "2026-06-22T23:08:19.704Z" }, + { url = "https://files.pythonhosted.org/packages/32/79/3f78ea9563132746eed5cecb75d2e576f9d8fec45a47242b5ae0950b82a3/coverage-7.14.3-cp311-cp311-win32.whl", hash = "sha256:74fdd718d88fe144f4579b8747873a07ec3f04cb837d5faec5a25d9e22fa31a8", size = 222594, upload-time = "2026-06-22T23:08:21.311Z" }, + { url = "https://files.pythonhosted.org/packages/85/22/9ebbc5a2ab42ac5d0eea1f48648629e1de9bbe41ec243ed6b93d55a5a53f/coverage-7.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:cc96aa922e21d4bc5d5ed3c915cef27dfcbc13686f47d5e378d647fbfba655a2", size = 223073, upload-time = "2026-06-22T23:08:23.318Z" }, + { url = "https://files.pythonhosted.org/packages/71/af/69d5fcc16cb555153f99cec5467922f226be0369f7335a9506856d2a7bd0/coverage-7.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:c66f9f9d4f1e9712eb9b1de5310f881d4e2188cfcba5065e1a8490f38687f2c4", size = 222617, upload-time = "2026-06-22T23:08:25.054Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b0/8a911f6ffe6974dac4df95b468ab9a2899d0e59f0f99a489afeec39f00bc/coverage-7.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d74ff26299c4879ce3a4d826f9d3d4d556fd285fde7bbce3c0ef5a8ab1cec24", size = 220672, upload-time = "2026-06-22T23:08:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/0fc0cb52538783dbbae0934b834f5a58fd5354380ee6cad4a07b15dc845d/coverage-7.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:96150a9cf3468ea20f0bc5d0e21b3df8972c31480ef90fa7614b773cc6429665", size = 221035, upload-time = "2026-06-22T23:08:28.372Z" }, + { url = "https://files.pythonhosted.org/packages/77/e2/421ccfbb48335ac49e93301478cf5d623b0c2bf1c0cadd8e2b2fc6c0c710/coverage-7.14.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:27d07a46500ba23515b838dbcf52512026af04090755cf6cc64166d88c9b9a1a", size = 252540, upload-time = "2026-06-22T23:08:30.226Z" }, + { url = "https://files.pythonhosted.org/packages/06/c2/05b8c890097c61a7f4406b35396b997a635200ded0339eda83dfbe526c5f/coverage-7.14.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:621e13c6108234d7960aaf5762ab5c3c00f33c30c15af06dcbff0c73bf112727", size = 255274, upload-time = "2026-06-22T23:08:31.876Z" }, + { url = "https://files.pythonhosted.org/packages/dc/be/b6d9efe447f8ba3c3c854195f326bd64c54b907d936cd2fdebf8767ec72e/coverage-7.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b60ca6d8af70473491a15a343cbabab2e8f9ea66a4376e81c7aa24876a6f977", size = 256389, upload-time = "2026-06-22T23:08:33.843Z" }, + { url = "https://files.pythonhosted.org/packages/d4/3c/f26e50acc429e608bc534ac06f0a3c169019c798178ec5e9de3dbc0df9c9/coverage-7.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c90a7cdd5e380e1ce02f19792e2ac2fbfbf177e35a27e69fd3e873b30d895c0c", size = 258648, upload-time = "2026-06-22T23:08:35.481Z" }, + { url = "https://files.pythonhosted.org/packages/9e/a2/01c1fabf816c8e1dae197e258edf878a3d3ddc86fbda34b76e5794277d8f/coverage-7.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d788e5fd55347eef06ca0732c77d04a264de67e8ff24631270cdff3767a60cf", size = 252949, upload-time = "2026-06-22T23:08:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/941166dd79c31fd44a13063780ae8d552eee0089a0a0930b9bdb7df554ed/coverage-7.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62c7f79db2851c95ef020e5d28b97afde3daf9f7febcd35b53e05638f729063f", size = 254310, upload-time = "2026-06-22T23:08:39.174Z" }, + { url = "https://files.pythonhosted.org/packages/10/31/80b1fd028201a961033ce95be3cd1e39e521b3762e6b4a1ac1616cb291e7/coverage-7.14.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:90f7608aeb5d9b60b523b9fb2a4ee1973867cc4865a3f26fe6c7577073b70205", size = 252453, upload-time = "2026-06-22T23:08:40.84Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/c3d9addd94c4b524f3f4af0232075f5fe7170ce99a1386edff803e5934db/coverage-7.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1e3b91f9c4740aeb571ecf82e5e8d8e4ab62d34fcb5a5d4e5baa38c6f7d2857c", size = 256522, upload-time = "2026-06-22T23:08:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/14/e5a0575f73795af3a7a9ae13dadf812e17d32422896839987dc3f86947e1/coverage-7.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c946099774a7699de03cbd0ff0a64e21aed4525eed9d959adde4afe6d15758ef", size = 252023, upload-time = "2026-06-22T23:08:44.243Z" }, + { url = "https://files.pythonhosted.org/packages/38/9b/9652ee531937ce3b8a63a8896885b2b4a2d56adc30e53c9540c666286d88/coverage-7.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16b206e521feb8b7133a45754643dead0538489cf8b783b90cf5f4e3299625fd", size = 253893, upload-time = "2026-06-22T23:08:46.113Z" }, + { url = "https://files.pythonhosted.org/packages/b1/05/42678841c8c38e4b08bdfc48269f5a16dfbf5806000fe6a89b4cece3c691/coverage-7.14.3-cp312-cp312-win32.whl", hash = "sha256:ea3169c7116eb6cdf7608c6c7da9ecfcb3da40688e3a510fac2d1d2bafd6dc35", size = 222734, upload-time = "2026-06-22T23:08:47.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/87/07a4fcee55177a25f1b52331a8e92cf4f2c53b1a9c75ce2981fd59c684ad/coverage-7.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:7ea52fc08f007bcc494d4bb3df3851e95843d881860ba38fe2c64dc100db5e7d", size = 223266, upload-time = "2026-06-22T23:08:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/aa/34/2b8b66a989282ea7b370beb49f50bab29470dc30bb0b03935b6b802782f7/coverage-7.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:8cec0ad652ec57790970d817490105bd917d783c2f7b38d6b58a0ca312e1a336", size = 222655, upload-time = "2026-06-22T23:08:51.766Z" }, + { url = "https://files.pythonhosted.org/packages/a9/83/7fefbf5df23ed2b7f489907564a7b34b9b07098128e12e0fdfa92626e456/coverage-7.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c", size = 220699, upload-time = "2026-06-22T23:08:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/31/e6/38c3653ff6d56d704b29241362387ca824e38e15b76fdcb7096538195790/coverage-7.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a", size = 221068, upload-time = "2026-06-22T23:08:55.571Z" }, + { url = "https://files.pythonhosted.org/packages/20/86/4f5c45d51c5cd10a128933f0fd235393c9146abbfd2ce2dfa68b3267ead3/coverage-7.14.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa9e5c6857a7e80fa22ace5cf3550ae392bbfc322f1d8dd2d2d5a8be38cec027", size = 252060, upload-time = "2026-06-22T23:08:57.464Z" }, + { url = "https://files.pythonhosted.org/packages/82/50/dfce42eff2cecabcd5a9bbad5489449c87db3415f408d23ffee417ce01f6/coverage-7.14.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98a0859b0e98e43e1178a9402e19c8127766b14f7109a374d976e5a62c0e5c73", size = 254657, upload-time = "2026-06-22T23:08:59.453Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d2/639ceb1bc8038fd0d66768278d5dc22df3391918b8278c2a21aa2602a531/coverage-7.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69918344541ed9c8368566c2adc03c0e33d4550d7faa87d1b35e49b6a3286ea9", size = 255892, upload-time = "2026-06-22T23:09:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/8b/96/002094a10e113512500dc1e10430a449417e17b0f90f7d496bcb820208b7/coverage-7.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f300ac92cd4b570724c8ffbbd0c130fee298d2447f41d5a3abf58976fae1de", size = 258026, upload-time = "2026-06-22T23:09:03.017Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ec/286a5d2fad9c4bee59bd724feeb7d5bf8303c6c9200b51d1dd945a9c72b0/coverage-7.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11a7ec9f97ab950f4c5af62229befc7faf208fdbc0116d3902d7e306cf2c5abd", size = 252285, upload-time = "2026-06-22T23:09:04.773Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7d/a17753a0b12dd48d0d50f5fab079ad99d3be1eac790494d89f3a417ca0b9/coverage-7.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a571bd889cd36c5922ce8e42e059f9d37d02301531d11374afa4c87a578625d5", size = 254023, upload-time = "2026-06-22T23:09:06.513Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/a76c6ceba6a2c313f905310abf2701d534cada22d372db11731831e9e209/coverage-7.14.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de76caefc8deabb0dd1678b6a980be97d14c8d87e213ac194dbf8b09e96d63fb", size = 251989, upload-time = "2026-06-22T23:09:08.382Z" }, + { url = "https://files.pythonhosted.org/packages/d9/39/353013a75fec0fb49f7553519f9d52b4441e902e5178c93f38eb6c07cedb/coverage-7.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d20a15c622194234161535459affa8f7905830391c9ccfa060d495dbfe3a1c7f", size = 256144, upload-time = "2026-06-22T23:09:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/29/0e/613878555d734def11c5b20a2701a15cb3781b9e9ea749da27c5f436e928/coverage-7.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b488bd4b23397db62e7a9459129d01ff06a846582a732efd24834b24a6ada498", size = 251808, upload-time = "2026-06-22T23:09:12.057Z" }, + { url = "https://files.pythonhosted.org/packages/af/76/359c058c9cfdcf1e8b107663881225b03b364a320017eda24a2a66e55102/coverage-7.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a3693b4153394d265f44fb855fdc80e72403024d4d6f91c4871b334d028e4e0", size = 253579, upload-time = "2026-06-22T23:09:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d9/4ba2f060933a30ebe363cef9f67a365b0a317e580c0d5d9169d56a73ef1c/coverage-7.14.3-cp313-cp313-win32.whl", hash = "sha256:338b19131ab1a6b767b462bfcbaa692e7ae22f24463e39d49b02a83410ff6b37", size = 222741, upload-time = "2026-06-22T23:09:15.636Z" }, + { url = "https://files.pythonhosted.org/packages/76/e8/196ebc25d8f34c06d43a6e9c8513c9266ef8dbf3b5672beb1a00cf5e29fa/coverage-7.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:b3d77f7f196abdef7e01415de1bce09f216189e83e58159cfeef2b92d0464994", size = 223283, upload-time = "2026-06-22T23:09:17.478Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/51d2aac6417523a286f10fb25f09eb9518a84df9f1151e93ff6871f34849/coverage-7.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:e6230e688c7c3e65cedd41a774eb4ec221adc6bfee13768231015b702d5e4150", size = 222678, upload-time = "2026-06-22T23:09:19.7Z" }, + { url = "https://files.pythonhosted.org/packages/61/56/14e3b97facbfa1304dd19e676e26599ad359f04714bed32f7f1c5a88efdc/coverage-7.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:605ab2b566a22bd94834529d66d295c364aba84afd3e5498285c7a524017b1fc", size = 220741, upload-time = "2026-06-22T23:09:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/12/1d/db378b5cca433b90b893f26dab728b280ddd89f272a1fdfed4aeaa05c686/coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7", size = 221068, upload-time = "2026-06-22T23:09:23.452Z" }, + { url = "https://files.pythonhosted.org/packages/47/f0/3f8421b20d9c4fcd39be9a8ca3c3fda8bc204b44efbd09fede153afd3e2f/coverage-7.14.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c02efd507227bde9969cab0db8f48890eb3b5dcad6afac57a4792df4133543ce", size = 252117, upload-time = "2026-06-22T23:09:25.458Z" }, + { url = "https://files.pythonhosted.org/packages/27/ca/59ea35fb99743549ec8b37eff141ece4431fea590c89e536ed8032ef45cf/coverage-7.14.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5", size = 254622, upload-time = "2026-06-22T23:09:27.523Z" }, + { url = "https://files.pythonhosted.org/packages/c8/25/ec6de51ae7493b92a1cf74d1b763121c29636759167e2a593ba4db5881e4/coverage-7.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f502e948e03e866538048bba081c075caaa62e5bda6ea5b7432e45f587eb462a", size = 255968, upload-time = "2026-06-22T23:09:29.43Z" }, + { url = "https://files.pythonhosted.org/packages/5d/05/c8bfc77823f42b4664fb25842f13b567022f6f84a4c83c8ecbb16734b7cb/coverage-7.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9973ef2463f8e6cfb61a6324126bb3e17d67a85f22f58d856e583ea2e3ca6501", size = 258284, upload-time = "2026-06-22T23:09:31.397Z" }, + { url = "https://files.pythonhosted.org/packages/f6/15/1d1b242027124a32b26ef01f82018b8c4ef34ef174aa6aeba7b1eeef48e8/coverage-7.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9be4e7d4c5ca0427889f8f9d614bd630c2be741b1de7699bca3b2b6c0e41003e", size = 252143, upload-time = "2026-06-22T23:09:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/74/b6/d2a9842fd2a5d7d27f1ac851c043a734a494ad75402c5331db3da79ed691/coverage-7.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a574912f3bde4b0619f6e97d01aa590b70998859244793769eb3a6df78ee56d3", size = 253976, upload-time = "2026-06-22T23:09:35.351Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/e1600ddf7e226db5558bb5323d2186fff00f505c4b764643ec89ce5d8175/coverage-7.14.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e343fb086c9cd780b38622fea7c369acd64c1a0724312149b5d769c387a2b1f5", size = 251942, upload-time = "2026-06-22T23:09:37.313Z" }, + { url = "https://files.pythonhosted.org/packages/d9/2c/9159de64f9dd648e324328d588a44cfab1e331eb5259ce1141afe2a92dfb/coverage-7.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3c68df8e61f1e09633fefc7538297145623957a048534368c9d212782aa5e845", size = 256220, upload-time = "2026-06-22T23:09:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/91/67/b7f536cc2c124f48e91b22fbb741d2261f4e3d310faf6f76007f47566e5d/coverage-7.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3e5b550a128419373c2f6cec28a244207013ef15f5cbcff6a5ca09d1dfaaf027", size = 251756, upload-time = "2026-06-22T23:09:41.056Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ec/f3718038e2d4860c715a55428377ca7f6c75872caf98cabd982e1d76967d/coverage-7.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2bfc4dd0a912329eccc7484a7d0b2a38032b38c40663b1e1ac595f10c457954b", size = 253413, upload-time = "2026-06-22T23:09:43.306Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a5/91f11efeef89b3cc9b30461128db15b0511ef813ab889a7b7ab636b3a497/coverage-7.14.3-cp314-cp314-win32.whl", hash = "sha256:0423d64c013057a06e70f070f073cec4b0cbc7d2b27f3c7007292f2ff1d52965", size = 222946, upload-time = "2026-06-22T23:09:45.261Z" }, + { url = "https://files.pythonhosted.org/packages/58/fd/98ac9f524d9ec378de831c034dbdeb544ca7ef7d2d9c9996daf232a037fd/coverage-7.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3", size = 223436, upload-time = "2026-06-22T23:09:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a0/7cd612d650a772a0ae80144443406bf61981c896c3d57c9e6e79fb2cdbd1/coverage-7.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:41de778bd41780586e2b04912079c73089ab5d839624e28db3bdb26de638da92", size = 222861, upload-time = "2026-06-22T23:09:49.384Z" }, + { url = "https://files.pythonhosted.org/packages/55/57/017353fab573779c0d00448e47d102edd36c792f7b6f233a4d89a7a08384/coverage-7.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8427f370ca67db4c975d2a26acfc0e5783ca0b52444dbc50278ace0f35445949", size = 221474, upload-time = "2026-06-22T23:09:51.417Z" }, + { url = "https://files.pythonhosted.org/packages/69/92/90cf1f1a5c468a9c1b7ba2716e0e205293ad9b02f5f573a6de4318b15ba1/coverage-7.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8e88f335544a47e22ae2e45b344772925ec65166555c958720d5ed971880891", size = 221738, upload-time = "2026-06-22T23:09:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c0/4df964fa539f8399fd7679c09c472d73744de334686fd3f01e3a2465ce4e/coverage-7.14.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:beaab199b9e5ceaf5a225e16a9d4df136f2a1eae0a5c20de1e277c8a5225f388", size = 263101, upload-time = "2026-06-22T23:09:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/06/76/e5d33b2576ae3bf2be2058cd1cae57774b61e400f2c3c58f3783dc2ffb4a/coverage-7.14.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ff255799f5a1676c71c1c32ec01fd043aa09d57b3d95764b24992757184784", size = 265225, upload-time = "2026-06-22T23:09:57.904Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/e52419afe391a39ba27fdefaf0737d8e34bf03faef6ab3b3006545bbd0d0/coverage-7.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:878832eaac515b62decfa76965aed558775f86bf1fc8cca76993c0c84ae31aed", size = 267643, upload-time = "2026-06-22T23:09:59.938Z" }, + { url = "https://files.pythonhosted.org/packages/58/7a/f2625d8d5006b6b20fba5afaef00b24a763fe96476ea798a3076cbc1f84e/coverage-7.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:611e62cb9386096d81b63e0a05330750268617231e7bd598e1fe77482a2c58a5", size = 268762, upload-time = "2026-06-22T23:10:01.943Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bf/908024006bba57127354d74e938954b9c3cd765cc2e0412dc9c37b415cda/coverage-7.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02c41de2a88011b893050fc9830267d927a50a215f7ad5ec17349db7090ccf26", size = 262208, upload-time = "2026-06-22T23:10:03.954Z" }, + { url = "https://files.pythonhosted.org/packages/34/a0/d4f9296441b909817442fdb26bd77a698f08272ec683a7394b00eb2e47a0/coverage-7.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:526ce9721116af23b1065089f0b75046fe521e7772ab94b641cd66b7a0421889", size = 265096, upload-time = "2026-06-22T23:10:05.936Z" }, + { url = "https://files.pythonhosted.org/packages/e8/da/4ae4f3f4e477b56a4ce1e5c48a35eff38a94b50130ce5bdc897024741cfc/coverage-7.14.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e4ed44705ca4bead6fc977a8b741f2145608289b33c8a9b42a95d0f15aedbf4d", size = 262699, upload-time = "2026-06-22T23:10:07.973Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7a/6927148073ff32856d78baa77b4ddc07a9be7e90020f9db0661c4ca523a1/coverage-7.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2415902f385a23dcc4ccd26e0ba803249a169af6a930c003a4c715eeb9a5444e", size = 266433, upload-time = "2026-06-22T23:10:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a7/774f658dbe9c4c3f5daa86a87e0459ac3832e4e3cc67affe078547f727b9/coverage-7.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b75ee850fc2d7c831e883220c445b035f2224de2ba6103f1e56dbd237ab913f7", size = 261547, upload-time = "2026-06-22T23:10:12.191Z" }, + { url = "https://files.pythonhosted.org/packages/3d/14/a0c18c0376c43cbf973f43ef6ca20019c950597180e6396232f7b6a27102/coverage-7.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc9b4e35e7c3920e925ba7f14886fd5fbe481232754624e832ddba66c7535635", size = 263859, upload-time = "2026-06-22T23:10:14.492Z" }, + { url = "https://files.pythonhosted.org/packages/10/ac/43a3d0f460af524b131a6191805bc5d18b806ab4e828fbf82e8c8c3af446/coverage-7.14.3-cp314-cp314t-win32.whl", hash = "sha256:7b27c822a8161afbe48e99f1adfb098d270ae7e0f7d7b0555ce110529bdb69cc", size = 223250, upload-time = "2026-06-22T23:10:16.758Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5f/d5e5c56b0712e96ce8f69fe7dbf229ff938b437bc50862743c8a0d2cea84/coverage-7.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:39e1dbbb6ff2c338e0196a482558a792a1de3aa64261196f5cdb3da016ad9cda", size = 224082, upload-time = "2026-06-22T23:10:19.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/35/947cbd5be1d3bcbbdc43d6791de8a56c6501903311d42915ae06a82815f0/coverage-7.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:68520c90babfa2d560eca6d497921ed3a4f469623bd709733124491b2aa8ef3f", size = 223400, upload-time = "2026-06-22T23:10:21.24Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e3/a0aa32bfa3a081951f60a23bc0e7b512891ef0eecda1153cf1d8ba36c6b1/coverage-7.14.3-py3-none-any.whl", hash = "sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8", size = 212469, upload-time = "2026-06-22T23:10:23.405Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -262,7 +495,9 @@ name = "filelock" version = "3.20.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.10'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.10' and python_full_version < '3.13'", ] sdist = { url = "https://files.pythonhosted.org/packages/a7/23/ce7a1126827cedeb958fc043d61745754464eb56c5937c35bbf2b8e26f34/filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c", size = 19476, upload-time = "2025-12-15T23:54:28.027Z" } wheels = [ @@ -288,6 +523,158 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, +] + +[[package]] +name = "grpcio" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/cd/bb7b7e54084a344c03d68144450da7ddd5564e51a298ae1662de65f48e2d/grpcio-1.80.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:886457a7768e408cdce226ad1ca67d2958917d306523a0e21e1a2fdaa75c9c9c", size = 6050363, upload-time = "2026-03-30T08:46:20.894Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/1417f5c3460dea65f7a2e3c14e8b31e77f7ffb730e9bfadd89eda7a9f477/grpcio-1.80.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:7b641fc3f1dc647bfd80bd713addc68f6d145956f64677e56d9ebafc0bd72388", size = 12026037, upload-time = "2026-03-30T08:46:25.144Z" }, + { url = "https://files.pythonhosted.org/packages/43/98/c910254eedf2cae368d78336a2de0678e66a7317d27c02522392f949b5c6/grpcio-1.80.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:33eb763f18f006dc7fee1e69831d38d23f5eccd15b2e0f92a13ee1d9242e5e02", size = 6602306, upload-time = "2026-03-30T08:46:27.593Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f8/88ca4e78c077b2b2113d95da1e1ab43efd43d723c9a0397d26529c2c1a56/grpcio-1.80.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:52d143637e3872633fc7dd7c3c6a1c84e396b359f3a72e215f8bf69fd82084fc", size = 7301535, upload-time = "2026-03-30T08:46:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f9/96/f28660fe2fe0f153288bf4a04e4910b7309d442395135c88ed4f5b3b8b40/grpcio-1.80.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c51bf8ac4575af2e0678bccfb07e47321fc7acb5049b4482832c5c195e04e13a", size = 6808669, upload-time = "2026-03-30T08:46:31.984Z" }, + { url = "https://files.pythonhosted.org/packages/47/eb/3f68a5e955779c00aeef23850e019c1c1d0e032d90633ba49c01ad5a96e0/grpcio-1.80.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:50a9871536d71c4fba24ee856abc03a87764570f0c457dd8db0b4018f379fed9", size = 7409489, upload-time = "2026-03-30T08:46:34.684Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a7/d2f681a4bfb881be40659a309771f3bdfbfdb1190619442816c3f0ffc079/grpcio-1.80.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a72d84ad0514db063e21887fbacd1fd7acb4d494a564cae22227cd45c7fbf199", size = 8423167, upload-time = "2026-03-30T08:46:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/29b4589c204959aa35ce5708400a05bba72181807c45c47b3ec000c39333/grpcio-1.80.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f7691a6788ad9196872f95716df5bc643ebba13c97140b7a5ee5c8e75d1dea81", size = 7846761, upload-time = "2026-03-30T08:46:40.091Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d2/ed143e097230ee121ac5848f6ff14372dba91289b10b536d54fb1b7cbae7/grpcio-1.80.0-cp310-cp310-win32.whl", hash = "sha256:46c2390b59d67f84e882694d489f5b45707c657832d7934859ceb8c33f467069", size = 4156534, upload-time = "2026-03-30T08:46:42.026Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c9/df8279bb49b29409995e95efa85b72973d62f8aeff89abee58c91f393710/grpcio-1.80.0-cp310-cp310-win_amd64.whl", hash = "sha256:dc053420fc75749c961e2a4c906398d7c15725d36ccc04ae6d16093167223b58", size = 4889869, upload-time = "2026-03-30T08:46:44.219Z" }, + { url = "https://files.pythonhosted.org/packages/5d/db/1d56e5f5823257b291962d6c0ce106146c6447f405b60b234c4f222a7cde/grpcio-1.80.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:dfab85db094068ff42e2a3563f60ab3dddcc9d6488a35abf0132daec13209c8a", size = 6055009, upload-time = "2026-03-30T08:46:46.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/18/c83f3cad64c5ca63bca7e91e5e46b0d026afc5af9d0a9972472ceba294b3/grpcio-1.80.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5c07e82e822e1161354e32da2662f741a4944ea955f9f580ec8fb409dd6f6060", size = 12035295, upload-time = "2026-03-30T08:46:49.099Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8e/e14966b435be2dda99fbe89db9525ea436edc79780431a1c2875a3582644/grpcio-1.80.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba0915d51fd4ced2db5ff719f84e270afe0e2d4c45a7bdb1e8d036e4502928c2", size = 6610297, upload-time = "2026-03-30T08:46:52.123Z" }, + { url = "https://files.pythonhosted.org/packages/cc/26/d5eb38f42ce0e3fdc8174ea4d52036ef8d58cc4426cb800f2610f625dd75/grpcio-1.80.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3cb8130ba457d2aa09fa6b7c3ed6b6e4e6a2685fce63cb803d479576c4d80e21", size = 7300208, upload-time = "2026-03-30T08:46:54.859Z" }, + { url = "https://files.pythonhosted.org/packages/25/51/bd267c989f85a17a5b3eea65a6feb4ff672af41ca614e5a0279cc0ea381c/grpcio-1.80.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09e5e478b3d14afd23f12e49e8b44c8684ac3c5f08561c43a5b9691c54d136ab", size = 6813442, upload-time = "2026-03-30T08:46:57.056Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d9/d80eef735b19e9169e30164bbf889b46f9df9127598a83d174eb13a48b26/grpcio-1.80.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1", size = 7414743, upload-time = "2026-03-30T08:46:59.682Z" }, + { url = "https://files.pythonhosted.org/packages/de/f2/567f5bd5054398ed6b0509b9a30900376dcf2786bd936812098808b49d8d/grpcio-1.80.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8502122a3cc1714038e39a0b071acb1207ca7844208d5ea0d091317555ee7106", size = 8426046, upload-time = "2026-03-30T08:47:02.474Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/73ef0141b4732ff5eacd68430ff2512a65c004696997f70476a83e548e7e/grpcio-1.80.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce1794f4ea6cc3ca29463f42d665c32ba1b964b48958a66497917fe9069f26e6", size = 7851641, upload-time = "2026-03-30T08:47:05.462Z" }, + { url = "https://files.pythonhosted.org/packages/46/69/abbfa360eb229a8623bab5f5a4f8105e445bd38ce81a89514ba55d281ad0/grpcio-1.80.0-cp311-cp311-win32.whl", hash = "sha256:51b4a7189b0bef2aa30adce3c78f09c83526cf3dddb24c6a96555e3b97340440", size = 4154368, upload-time = "2026-03-30T08:47:08.027Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d4/ae92206d01183b08613e846076115f5ac5991bae358d2a749fa864da5699/grpcio-1.80.0-cp311-cp311-win_amd64.whl", hash = "sha256:02e64bb0bb2da14d947a49e6f120a75e947250aebe65f9629b62bb1f5c14e6e9", size = 4894235, upload-time = "2026-03-30T08:47:10.839Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" }, + { url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" }, + { url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" }, + { url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" }, + { url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" }, + { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" }, + { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, + { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, + { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, + { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, + { url = "https://files.pythonhosted.org/packages/08/58/7151ffa07cb3faf4bdd1a1902c067d2d162a4ba24678afd2ad5084a42382/grpcio-1.80.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:aacdfb4ed3eb919ca997504d27e03d5dba403c85130b8ed450308590a738f7a4", size = 6048562, upload-time = "2026-03-30T08:48:40.068Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/0287051dc65c2760155977d9775d1f3c87939e4d575a29aac40f9006b357/grpcio-1.80.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:a361c20ec1ccd3c3953d20fb6d7b4125093bdd10dff44c5e2bbb39e58917cedc", size = 12031536, upload-time = "2026-03-30T08:48:43.031Z" }, + { url = "https://files.pythonhosted.org/packages/7b/62/8fc355ffcc9fd8a3ca0438f007307c130dfb93949d3138cd23c8c9f434e8/grpcio-1.80.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:43168871f170d1e4ed16ae03d10cd21efa29f190e710a624cee7e5ae07da6f4f", size = 6602175, upload-time = "2026-03-30T08:48:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/12/cb/3efd0b505090804dfe88bf258ed26a6fb19ccbb31889a05b9edb3ae035fe/grpcio-1.80.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1b97cd29a8eda100b559b455331c487a80915b6ea6bd91cf3e89836c4ee8d957", size = 7299777, upload-time = "2026-03-30T08:48:48.848Z" }, + { url = "https://files.pythonhosted.org/packages/54/b1/50fdb826acafd5ac661e10df25b089721172530f2eb4aa1f36bd3c3d4254/grpcio-1.80.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bac1d573dfa84ce59a5547073e28fa7326d53352adda6912e362da0b917fcef4", size = 6808790, upload-time = "2026-03-30T08:48:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/41e9ed0bb5544836bb2685097beea972b0cabc8970aeaace0f152bfc5441/grpcio-1.80.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4560cf0e86514595dbbd330cd65b7afad4b5c4b8c4905c041cfffa138d45e6fd", size = 7410605, upload-time = "2026-03-30T08:48:54.466Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/889f0dfbc8a08050db6e23c3180dbe712b03af490352a4d7df649db26bc8/grpcio-1.80.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ec0a592e926071b4abad50c1495cd0d0d513324b3ff5e7267067c33ba27506e4", size = 8423134, upload-time = "2026-03-30T08:48:57.71Z" }, + { url = "https://files.pythonhosted.org/packages/3d/76/f44d853f38165d26a309565da31a312587dda668e9e7b5323179b87bcab4/grpcio-1.80.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:deb10a1528473c11f72a0939eed36d83e847d7cbb63e8cc5611fb7a912d38614", size = 7846917, upload-time = "2026-03-30T08:49:00.969Z" }, + { url = "https://files.pythonhosted.org/packages/74/fe/99c56d12b48f8c8b0d28c42edfb171642eb52dd90a0fe7bc74676909fa97/grpcio-1.80.0-cp39-cp39-win32.whl", hash = "sha256:627fb7312171cdc52828bd6fac8d7028ff2a64b89f1957b6f3416caa2218d141", size = 4157647, upload-time = "2026-03-30T08:49:04.196Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ff/33f6a8823f06c6a1d1f530c1531e563b76c02091525e36255c08575ae775/grpcio-1.80.0-cp39-cp39-win_amd64.whl", hash = "sha256:05d55e1798756282cddd52d56c896b3e7d673e3a8798c2f1cd05ba249a3bb4de", size = 4892359, upload-time = "2026-03-30T08:49:06.902Z" }, +] + +[[package]] +name = "grpcio" +version = "1.81.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.10' and python_full_version < '3.13'", +] +dependencies = [ + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/b5/1ff353970a87eda4c98251e34d2dfd214abd4982dc89119c9252a2a482d2/grpcio-1.81.1.tar.gz", hash = "sha256:6fa10a767143a5e82e8eaab53918af0cd8909a57a27f8cb2288b80a613ac671b", size = 13026582, upload-time = "2026-06-11T12:46:51.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/d5/f2b159d8eec08be2a855ef698f5b6f7f9fdda022e4dd9e4f5d968affd678/grpcio-1.81.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:6f9a0c9c1cc15c112d1c053064fd032b64917062292c3d70aea280e02ae10b77", size = 6086868, upload-time = "2026-06-11T12:44:19.364Z" }, + { url = "https://files.pythonhosted.org/packages/80/41/9c95232b94b219ed8b14029d9cd000e0381cafba869c451dda60af84f4ba/grpcio-1.81.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:69ef28e54fc85397f91b8c19592b8ef3d81952080366914823bd8572a2958120", size = 12062291, upload-time = "2026-06-11T12:44:27.142Z" }, + { url = "https://files.pythonhosted.org/packages/83/8b/bd9284bdd665ddf877a3e8bc2930d1bcf6ebdbae7b0da5c783dc26bd6e33/grpcio-1.81.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:15641444eca4a29358107b3dceb74c1c6305c55c822fd199b458aaea4068a7fb", size = 6635242, upload-time = "2026-06-11T12:44:30.741Z" }, + { url = "https://files.pythonhosted.org/packages/60/24/78fa025517a925f1a17da71c4ef9d5f1c6f9fa65af22dfb523c5c6317a21/grpcio-1.81.1-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:d4b2dddfc219f54f956ccd53cf76a1d338ffe68fc7f2849ec9c7feb9927ff692", size = 7332974, upload-time = "2026-06-11T12:44:33.72Z" }, + { url = "https://files.pythonhosted.org/packages/f7/11/402295b388dd35861007f8a26a37c2e2f284212d57bdf407c31f36043746/grpcio-1.81.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ca1cc11d82677b9662082e5478b7528e2b7db7beaa6bdff42bd62789d81be399", size = 6836597, upload-time = "2026-06-11T12:44:36.108Z" }, + { url = "https://files.pythonhosted.org/packages/4d/71/37b10fd4fd579ffade6e695c14e9df5e8cba9e2365b81c131da438b67c34/grpcio-1.81.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa2ba7d2ad6df4d80127cea65e5b8d5e2c3adbf153ff4804452836328aca7c54", size = 7440660, upload-time = "2026-06-11T12:44:38.664Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d5/40203f828abc83d458b634666df6df13778032f178c03845ad5a93682388/grpcio-1.81.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:592b5fee597faa91cce2dd294dd7d9a1c83d76c4dbf877e33ec1adb866b2fbed", size = 8443171, upload-time = "2026-06-11T12:44:41.678Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2c/0ed82ea35b5ec595e10444940c1db8c0e0ef57aa46bc8797d5ff838a219e/grpcio-1.81.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62481553b1793a27e9b9c3cf9e5bd483ef045ca72462592074b46d42b0c4d9b9", size = 7868905, upload-time = "2026-06-11T12:44:44.854Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/dcbdc1a68a07cc2b631c3098953794f17d75f93426a019240b90ce5423d6/grpcio-1.81.1-cp310-cp310-win32.whl", hash = "sha256:bb693b1e3d9a2f3fd228e2110daf4b5aeedb36761ca1e4282f74725f6d89f611", size = 4202215, upload-time = "2026-06-11T12:44:47.165Z" }, + { url = "https://files.pythonhosted.org/packages/75/a1/d7ab9f1f42efcb7d9e6111d38be6b367737a72ea2c534e1f55c81e1b6436/grpcio-1.81.1-cp310-cp310-win_amd64.whl", hash = "sha256:88268ca418cacea64cecb0d1d600d3c6b3a8038fcba02e1e205178c5b1f47661", size = 4936582, upload-time = "2026-06-11T12:44:49.479Z" }, + { url = "https://files.pythonhosted.org/packages/52/ea/1c2fa386b718ff493225e61cfc052ef400b4d6ffc54cbe261026432624b5/grpcio-1.81.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:d71d30f2d92f67d944631c523713934fee37292469e182ebcd2c1dd8a64ce53f", size = 6093112, upload-time = "2026-06-11T12:44:52.131Z" }, + { url = "https://files.pythonhosted.org/packages/2b/18/acf45fa8bd1bc5d7b0c2fd3dc4c209379fbd5bb396b440b68a83342226b7/grpcio-1.81.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:b137f4bf3ada9dc44d411478decc6ff09a79ed30b306cd2abaa98408c3588137", size = 12074277, upload-time = "2026-06-11T12:44:55.354Z" }, + { url = "https://files.pythonhosted.org/packages/48/d7/ee86a60699b7db039f772a2c4a7e4facc7138984ff42c0130933a0063884/grpcio-1.81.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a3acb384427816dd5d470f47e62137b87f74da694faa8a50147012cf40df276a", size = 6640348, upload-time = "2026-06-11T12:44:59.223Z" }, + { url = "https://files.pythonhosted.org/packages/26/ee/d2de5e47378ffc207d476c230fea3be4d2601edbce9995f4fe45535d4896/grpcio-1.81.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f9a0ebbe45c29b5e5866593c12b78bd9035f0f0f0d4bc8361680cd580d99db49", size = 7331842, upload-time = "2026-06-11T12:45:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/23/d6/abeda5c2b896a0b341584fe5ac411bbf72e197a9a374c355fb90965e08d2/grpcio-1.81.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a37165cc80b1a368384b383e63a4c38116a10467ae44c904d2d7468c4470ec2", size = 6842229, upload-time = "2026-06-11T12:45:04.76Z" }, + { url = "https://files.pythonhosted.org/packages/10/1c/1f0da7d590b4aeee006826ba568d0e419ca14b23e18f901a3da3e9fba613/grpcio-1.81.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6282caffb41ec326d4cb67ca9cf53b739d1b2f975a2acb498c7418e9f7d9a416", size = 7446096, upload-time = "2026-06-11T12:45:07.499Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/5c505d508f7c887aa7982d21443a4126597c80d34b0bcf40f9cec576d7f3/grpcio-1.81.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a35009284d0d3d5c2c9601c164a911b8b4331608d98a9a66d47d97bb2f522b70", size = 8445238, upload-time = "2026-06-11T12:45:10.243Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b2/524847365122ee509ca17bcc4e092198b700e94af7bfd5bb5e6dd9f3ee66/grpcio-1.81.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1b22c80559854b789a01fd89e8929b3798a156c0829b5282a8939f33ad4115ad", size = 7873989, upload-time = "2026-06-11T12:45:13.102Z" }, + { url = "https://files.pythonhosted.org/packages/18/fa/07c037c50b006909d1d13a5848774f8aa7b242f70dc03a035c64eea0e6db/grpcio-1.81.1-cp311-cp311-win32.whl", hash = "sha256:428bec0161b48d8cf583c068591bc0016d0d9cfff52462b72b3884861ea768c5", size = 4202223, upload-time = "2026-06-11T12:45:16.166Z" }, + { url = "https://files.pythonhosted.org/packages/41/ed/6bff15376920942fac6b95b9802752b837437172c9e8fc2d3170546b89cc/grpcio-1.81.1-cp311-cp311-win_amd64.whl", hash = "sha256:30e825f6848d9f18bba350ed6c75c1b02a0b5184474a31db9a32b1fa66fd8c79", size = 4941303, upload-time = "2026-06-11T12:45:18.724Z" }, + { url = "https://files.pythonhosted.org/packages/85/07/9a979c81738863a738dc23d65177056e71fbb2db817740ed870b33434e7a/grpcio-1.81.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:8b39472beafc0bdcafc4c8c73ad082ebfdb449d566897a61e7acb4fa88089115", size = 6053264, upload-time = "2026-06-11T12:45:21.017Z" }, + { url = "https://files.pythonhosted.org/packages/75/95/539706ca0d3bd40dbad583dc56fd883da941f37556b629132da5762781b9/grpcio-1.81.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:12b7524c88d4026d3dcb7b0ebe16b6714f3b4af402ddd0f0639ab064a00c87c3", size = 12052560, upload-time = "2026-06-11T12:45:23.652Z" }, + { url = "https://files.pythonhosted.org/packages/e0/44/f257b7e0bd69c93b06c6cb8ac8d1b901ccb42bedabd83c1a4c77a71f8810/grpcio-1.81.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1e123f9b37edb8375fd74130d1f69c944bbf0a7b06761ae7211154b8759e94d2", size = 6595983, upload-time = "2026-06-11T12:45:26.963Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f3/19782aa04c960968bef8c5539329d8e3bbc3364e2e46d19eb5e5cc5e43b7/grpcio-1.81.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2c2e2ae6867c2966b8daccc836d54a13218e0007e9a490aeb81dd05be64d22d7", size = 7303455, upload-time = "2026-06-11T12:45:29.707Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8c/dea020b6d91508cd84463917a63149ec196ee7db505d032ae43fcb3303b9/grpcio-1.81.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:766bc7c9a9c340342f4c864ccbda8e78111e4751f13b895812b9c148fb79e9d0", size = 6809167, upload-time = "2026-06-11T12:45:32.52Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/3030dd940408083bd32cd95d634777a71605ade4887154d93e8a89244946/grpcio-1.81.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b259a04a737cb3496be0901328eb8b7552ed8df4865d8c8f1cf1bffcfc0776a3", size = 7412536, upload-time = "2026-06-11T12:45:35.403Z" }, + { url = "https://files.pythonhosted.org/packages/e0/dd/1172a9e42b168edcafefad6115346ef619a3fc02158bb170e66ced24bcdd/grpcio-1.81.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:85b10a45b8993d195c4f3ff57025b8d1e11834909ee475c403bfa60cb4caefaf", size = 8408276, upload-time = "2026-06-11T12:45:37.78Z" }, + { url = "https://files.pythonhosted.org/packages/25/7a/71437c7f3596e5246155c515852795a85a1a8d228190212432b13b97a95d/grpcio-1.81.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8ea1936c26b99999b27479853039a7f34713f56c49375ad52b38535ec93a796c", size = 7849660, upload-time = "2026-06-11T12:45:40.627Z" }, + { url = "https://files.pythonhosted.org/packages/65/40/7debc0da45d2efebafb82da75644be347497fe4ee250514b8cd3b86ae8bf/grpcio-1.81.1-cp312-cp312-win32.whl", hash = "sha256:a185a04039df6cae8648bc8ab6d6fde7bf94f7188ecf7828e76ac52eef1e41d6", size = 4185819, upload-time = "2026-06-11T12:45:43.027Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b9/8fe3ba5ed462067774ebc1f9c7f26aa7ebcc280ddd476be107153de1339e/grpcio-1.81.1-cp312-cp312-win_amd64.whl", hash = "sha256:3ad74f8bb1a18963914c5452d289422830b39459e8776ebbcd207be1fbfb1d94", size = 4930461, upload-time = "2026-06-11T12:45:45.775Z" }, + { url = "https://files.pythonhosted.org/packages/7a/42/dcc2e4b600538ef18327c0839d56b7d3c3812337c5d710df5877dbb39b1e/grpcio-1.81.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b10e1ff4756ed27d5a29d7fc79cfce7ef1ff56ad20025b89bac7cf79e09abbbe", size = 6054466, upload-time = "2026-06-11T12:45:48.43Z" }, + { url = "https://files.pythonhosted.org/packages/7b/4a/a36e03210183a8a7d4c80c3936acee679f4bd77d5861f369db47b2cc5f05/grpcio-1.81.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:819edbdcb42ab8598b494bcf0222684bbb7a3c772bd1b1f0be7e029a6063c28e", size = 12048795, upload-time = "2026-06-11T12:45:54.011Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d5/d68e30b29098f63beab6fe501100fe82674ff142b32c672532da86a99b3a/grpcio-1.81.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5bf2dc311127d91230cc79b92188c082634a06cf66c5234db49a43b910183b0", size = 6599094, upload-time = "2026-06-11T12:45:57.799Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b3/e837954d279754f638a11cca5dcf6b24a005efb398984cefaf7735945a54/grpcio-1.81.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e8ca6a1fcdb2943c9cbc1804a1baf3acb6071d72a471591678ded84218006e14", size = 7307182, upload-time = "2026-06-11T12:46:00.568Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/b47957057e729adc6cdf519a47f8be2562b7140e280f1418443eb4022192/grpcio-1.81.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e64dd101d380a115cc5a0c7856788adb535f1a4e21fc543775602f8be95180ae", size = 6810962, upload-time = "2026-06-11T12:46:03.312Z" }, + { url = "https://files.pythonhosted.org/packages/40/26/569868e364e05b19ec8f969da53d230bcd89c962cd198f7c29943155c4d3/grpcio-1.81.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:98a07f9bf591e3a8919797bee1c53f026ba4acd587e5a4404c8e57c9ec36b2a5", size = 7415698, upload-time = "2026-06-11T12:46:06.005Z" }, + { url = "https://files.pythonhosted.org/packages/36/0c/5440a0582cb5653fc42a6e262eeb22700943313f8076f9dc927491b20a59/grpcio-1.81.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c261d74b1a945cf895a9d6eccd1685a8e837531beaab782da4d630a8d12deffb", size = 8407779, upload-time = "2026-06-11T12:46:08.84Z" }, + { url = "https://files.pythonhosted.org/packages/ff/aa/66fe9f39871d766987d869a03ee0842a026f499c7b1e62decb9e78a8088e/grpcio-1.81.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58ad1131c300d3c9b933802b3cc4dc69d380822935ba50b28703156ea826fbf7", size = 7844521, upload-time = "2026-06-11T12:46:12.171Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9e/69bb7194861bcd28fb3193261d4f9c3831b4446993f002cf59068943e7ab/grpcio-1.81.1-cp313-cp313-win32.whl", hash = "sha256:78e29211f26da2fdd0e9c6d2b79f489476140cf7029b6a64808ade7ca4156a42", size = 4182786, upload-time = "2026-06-11T12:46:15.192Z" }, + { url = "https://files.pythonhosted.org/packages/0d/20/3da8bb0d637feccdc3e1e419bb511ce93651ce7d54164f95de22cc0b8b34/grpcio-1.81.1-cp313-cp313-win_amd64.whl", hash = "sha256:edb59506291b647a30884b1d51a599d605f40b20af4a7dc3d33786a47a31de60", size = 4928648, upload-time = "2026-06-11T12:46:17.823Z" }, + { url = "https://files.pythonhosted.org/packages/b6/58/19414622b1bf6981bc9c05a365bd548e71876c89000083b3af489251e9c0/grpcio-1.81.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:506f48f2f9c29b143fca3dad7b0d518c188b6c9648c75a2ae6e2d9f2c13a060b", size = 6055336, upload-time = "2026-06-11T12:46:20.557Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/2ec88adb92b0eba970dd0e0e7dd086341daa3c75eba4f735f9e44bf684b0/grpcio-1.81.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d865db4a6318e1c1bea83292e0ed231090538fc4ca45425b0f0480eb338bbc6e", size = 12056279, upload-time = "2026-06-11T12:46:24.255Z" }, + { url = "https://files.pythonhosted.org/packages/41/36/e8c5f8c6ec71de73733695ebc809e98b178b534ec6d8eaa31a7ebab4ad4c/grpcio-1.81.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2aa72e3ce1770317ef534f63d397b55e130725f5149bd36077c3b539019db27", size = 6608225, upload-time = "2026-06-11T12:46:27.601Z" }, + { url = "https://files.pythonhosted.org/packages/30/22/96fc577a845ab093326d9ab1adb874bd4936c8cf98ac8ed2f3db13a0a2fb/grpcio-1.81.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0490c30c261eded63f3f354979f9dc4502a9fb944cccb60cd9dc85f5a7349854", size = 7306576, upload-time = "2026-06-11T12:46:30.514Z" }, + { url = "https://files.pythonhosted.org/packages/76/7b/61dab5d5969f28d97fb1009cead1df0a5cd987d3315e1b37f18a4449f8bc/grpcio-1.81.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:410482da976329fe5f4067270401b12cf2bd552ff8020f054ecfaddb5475f9d6", size = 6812165, upload-time = "2026-06-11T12:46:33.699Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/6e501929d4f5f96462fd82fd9f0f06e5f9612207582b862868d68757b27d/grpcio-1.81.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3657301562ac3cb8018d30d0d3ebfa39932239f7b5703422057ef14b69949f5", size = 7422962, upload-time = "2026-06-11T12:46:36.511Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7e/f2157589e66daa78ebb3165942d05a08bdea93b9d11c2bc1e172aef89685/grpcio-1.81.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:24c8e57504c8f45b237e40b99262d181071e5099a07053695b75d97bb53053a0", size = 8408176, upload-time = "2026-06-11T12:46:39.803Z" }, + { url = "https://files.pythonhosted.org/packages/da/df/c6717fef716e00d235ffb96123baf6dce76d6004f6233fa767c502861460/grpcio-1.81.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b427c19380991a4eaab2f6144b64b99b412043314c6bf4ab544f97bb31ee4190", size = 7846681, upload-time = "2026-06-11T12:46:43.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/84/3502e9f210a6a5c4438c8aca3f88edd2e04f6a27f3d41b26cf0a0024b096/grpcio-1.81.1-cp314-cp314-win32.whl", hash = "sha256:61233fe8951e5c85dff81c2458b6528624760166946b5b47ea150a589168411f", size = 4264615, upload-time = "2026-06-11T12:46:45.741Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/4af731ff7492c68a96e4c71bfd0f4590acde92b31c6fe4894e6465c10ff6/grpcio-1.81.1-cp314-cp314-win_amd64.whl", hash = "sha256:3768a5ff1b2125e6f552e561b6b2dca0e64982d8949689b4df145cf8b98d7821", size = 5070275, upload-time = "2026-06-11T12:46:48.486Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -372,7 +759,9 @@ name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.10'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.10' and python_full_version < '3.13'", ] sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ @@ -503,6 +892,10 @@ source = { editable = "." } dependencies = [ { name = "flask" }, { name = "ollama" }, + { name = "opentelemetry-exporter-otlp-proto-grpc", version = "1.41.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "opentelemetry-exporter-otlp-proto-grpc", version = "1.42.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "opentelemetry-sdk", version = "1.41.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "opentelemetry-sdk", version = "1.42.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "python-dotenv" }, { name = "redis", version = "7.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "redis", version = "7.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, @@ -515,12 +908,15 @@ dev = [ { name = "pre-commit", version = "4.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest-cov" }, ] [package.metadata] requires-dist = [ { name = "flask" }, { name = "ollama" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-sdk" }, { name = "python-dotenv" }, { name = "redis" }, { name = "requests" }, @@ -530,6 +926,7 @@ requires-dist = [ dev = [ { name = "pre-commit" }, { name = "pytest" }, + { name = "pytest-cov" }, ] [[package]] @@ -554,6 +951,217 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/4f/4a617ee93d8208d2bcf26b2d8b9402ceaed03e3853c754940e2290fed063/ollama-0.6.1-py3-none-any.whl", hash = "sha256:fc4c984b345735c5486faeee67d8a265214a31cbb828167782dc642ce0a2bf8c", size = 14354, upload-time = "2025-11-13T23:02:16.292Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/fc/b7564cbef36601aef0d6c9bc01f7badb64be8e862c2e1c3c5c3b43b53e4f/opentelemetry_api-1.41.1.tar.gz", hash = "sha256:0ad1814d73b875f84494387dae86ce0b12c68556331ce6ce8fe789197c949621", size = 71416, upload-time = "2026-04-24T13:15:38.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/59/3e7118ed140f76b0982ba4321bdaed1997a0473f9720de2d10788a577033/opentelemetry_api-1.41.1-py3-none-any.whl", hash = "sha256:a22df900e75c76dc08440710e51f52f1aa6b451b429298896023e60db5b3139f", size = 69007, upload-time = "2026-04-24T13:15:15.662Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.10' and python_full_version < '3.13'", +] +dependencies = [ + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "opentelemetry-proto", version = "1.41.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/fa/f9e3bd3c4d692b3ce9a2880a167d1f79681a1bea11f00d5bf76adc03e6ea/opentelemetry_exporter_otlp_proto_common-1.41.1.tar.gz", hash = "sha256:0e253156ea9c36b0bd3d2440c5c9ba7dd1f3fb64ba7a08fc85fbac536b56e1fb", size = 20409, upload-time = "2026-04-24T13:15:40.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/48/bce76d3ea772b609757e9bc844e02ab408a6446609bf74fb562062ba6b71/opentelemetry_exporter_otlp_proto_common-1.41.1-py3-none-any.whl", hash = "sha256:10da74dad6a49344b9b7b21b6182e3060373a235fde1528616d5f01f92e66aa9", size = 18366, upload-time = "2026-04-24T13:15:18.917Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.10' and python_full_version < '3.13'", +] +dependencies = [ + { name = "opentelemetry-proto", version = "1.42.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/9c/216acfeaedadf2e1937f4373929b20f73197c5c4a2546d4f584b7fa63813/opentelemetry_exporter_otlp_proto_common-1.42.1.tar.gz", hash = "sha256:04f1f01fb597c4249dfcd7f8b861c902c2102369d376d9d346ff38de4469a2ee", size = 21433, upload-time = "2026-05-21T16:32:55.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/43/2375e7612e1121a4518c17603b6e0b03ad94f565aafad53f464dc5be2bf6/opentelemetry_exporter_otlp_proto_common-1.42.1-py3-none-any.whl", hash = "sha256:f48d395ab815b444da118868977e9798ea354c25737d5cf39578ae894011c140", size = 17327, upload-time = "2026-05-21T16:32:33.387Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "googleapis-common-protos", marker = "python_full_version < '3.10'" }, + { name = "grpcio", version = "1.80.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "opentelemetry-api", version = "1.41.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "opentelemetry-exporter-otlp-proto-common", version = "1.41.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "opentelemetry-proto", version = "1.41.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "opentelemetry-sdk", version = "1.41.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/9b/e4503060b8695579dbaad187dc8cef4554188de68748c88060599b77489e/opentelemetry_exporter_otlp_proto_grpc-1.41.1.tar.gz", hash = "sha256:b05df8fa1333dc9a3fda36b676b96b5095ab6016d3f0c3296d430d629ba1443b", size = 25755, upload-time = "2026-04-24T13:15:41.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/f2/c54f33c92443d087703e57e52e55f22f111373a5c4c4aa349ea60efe512e/opentelemetry_exporter_otlp_proto_grpc-1.41.1-py3-none-any.whl", hash = "sha256:537926dcef951136992479af1d9cd88f25e33d56c530e9f020ed57774dca2f94", size = 20297, upload-time = "2026-04-24T13:15:20.212Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.10' and python_full_version < '3.13'", +] +dependencies = [ + { name = "googleapis-common-protos", marker = "python_full_version >= '3.10'" }, + { name = "grpcio", version = "1.81.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "opentelemetry-api", version = "1.42.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "opentelemetry-exporter-otlp-proto-common", version = "1.42.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "opentelemetry-proto", version = "1.42.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "opentelemetry-sdk", version = "1.42.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/87/ca7fc790dfdbcf4f9e9aab14a39ef1b7508ead13707e283de0b3131478d2/opentelemetry_exporter_otlp_proto_grpc-1.42.1.tar.gz", hash = "sha256:975c4461f167dd8ed8857d68d3b6b25f3d272eab896f6a9470d0f5b90e2faf15", size = 27140, upload-time = "2026-05-21T16:32:56.162Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/2b/28ba5b128f47fe8c3bab541000d6feb4b5a9bd26623ca013406f01c0fb60/opentelemetry_exporter_otlp_proto_grpc-1.42.1-py3-none-any.whl", hash = "sha256:0ae1177e2038b18a929b3098215243631ef91136cba26b7e2b12790ceb7e87cc", size = 19617, upload-time = "2026-05-21T16:32:34.278Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "protobuf", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/e8/633c6d8a9c8840338b105907e55c32d3da1983abab5e52f899f72a82c3d1/opentelemetry_proto-1.41.1.tar.gz", hash = "sha256:4b9d2eb631237ea43b80e16c073af438554e32bc7e9e3f8ca4a9582f900020e5", size = 45670, upload-time = "2026-04-24T13:15:49.768Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/1e/5cd77035e3e82070e2265a63a760f715aacd3cb16dddc7efee913f297fcc/opentelemetry_proto-1.41.1-py3-none-any.whl", hash = "sha256:0496713b804d127a4147e32849fbaf5683fac8ee98550e8e7679cd706c289720", size = 72076, upload-time = "2026-04-24T13:15:32.542Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.10' and python_full_version < '3.13'", +] +dependencies = [ + { name = "protobuf", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/55/63eac3e1089b768ba014091fdd2ae8a9a440c821ef5e2b786909c94c8836/opentelemetry_proto-1.42.1.tar.gz", hash = "sha256:c6a51e6b4f05ae63565f3a113217f3d2bfaec68f78c02d7a6c85f9010d1cfca6", size = 45839, upload-time = "2026-05-21T16:33:03.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/9d/171c02c84a76940b7e601805b3bb536985aded9168fbcc9ba52f0a730fa2/opentelemetry_proto-1.42.1-py3-none-any.whl", hash = "sha256:dedb74cba2886c59c7789b227a7a670613025a07489040050aedff6e5c0fb43c", size = 71782, upload-time = "2026-05-21T16:32:44.867Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "opentelemetry-api", version = "1.41.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "opentelemetry-semantic-conventions", version = "0.62b1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d0/54ee30dab82fb0acda23d144502771ff76ef8728459c83c3e89ef9fb1825/opentelemetry_sdk-1.41.1.tar.gz", hash = "sha256:724b615e1215b5aeacda0abb8a6a8922c9a1853068948bd0bd225a56d0c792e6", size = 230180, upload-time = "2026-04-24T13:15:50.991Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/e7/a1420b698aad018e1cf60fdbaaccbe49021fb415e2a0d81c242f4c518f54/opentelemetry_sdk-1.41.1-py3-none-any.whl", hash = "sha256:edee379c126c1bce952b0c812b48fe8ff35b30df0eecf17e98afa4d598b7d85d", size = 180213, upload-time = "2026-04-24T13:15:33.767Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.10' and python_full_version < '3.13'", +] +dependencies = [ + { name = "opentelemetry-api", version = "1.42.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "opentelemetry-semantic-conventions", version = "0.63b1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.62b1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "opentelemetry-api", version = "1.41.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/de/911ac9e309052aca1b20b2d5549d3db45d1011e1a610e552c6ccdd1b64f8/opentelemetry_semantic_conventions-0.62b1.tar.gz", hash = "sha256:c5cc6e04a7f8c7cdd30be2ed81499fa4e75bfbd52c9cb70d40af1f9cd3619802", size = 145750, upload-time = "2026-04-24T13:15:52.236Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/a6/83dc2ab6fa397ee66fba04fe2e74bdf7be3b3870005359ceb7689103c058/opentelemetry_semantic_conventions-0.62b1-py3-none-any.whl", hash = "sha256:cf506938103d331fbb78eded0d9788095f7fd59016f2bda813c3324e5a74a93c", size = 231620, upload-time = "2026-04-24T13:15:35.454Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.63b1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.10' and python_full_version < '3.13'", +] +dependencies = [ + { name = "opentelemetry-api", version = "1.42.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -580,7 +1188,9 @@ name = "platformdirs" version = "4.5.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.10'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.10' and python_full_version < '3.13'", ] sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } wheels = [ @@ -620,7 +1230,9 @@ name = "pre-commit" version = "4.5.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.10'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.10' and python_full_version < '3.13'", ] dependencies = [ { name = "cfgv", version = "3.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, @@ -634,6 +1246,23 @@ 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" }, ] +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/0c/bd/88a687e9147329fc7e6c26a058fc52214c47190688a496bb283000a4d2a3/protobuf-6.33.6-cp39-cp39-win32.whl", hash = "sha256:bd56799fb262994b2c2faa1799693c95cc2e22c62f56fb43af311cae45d26f0e", size = 425861, upload-time = "2026-03-18T19:04:57.064Z" }, + { url = "https://files.pythonhosted.org/packages/84/d6/fab384eea064bfc3b273183e4e09bb3a3cf4ec83876b3828c09fcacbb651/protobuf-6.33.6-cp39-cp39-win_amd64.whl", hash = "sha256:f443a394af5ed23672bc6c486be138628fbe5c651ccbc536873d7da23d1868cf", size = 437109, upload-time = "2026-03-18T19:04:58.713Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -815,7 +1444,9 @@ name = "pytest" version = "9.0.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.10'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.10' and python_full_version < '3.13'", ] dependencies = [ { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, @@ -831,6 +1462,22 @@ 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" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" }, + { name = "coverage", version = "7.14.3", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, + { name = "pluggy" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" @@ -933,7 +1580,9 @@ name = "redis" version = "7.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.10'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.10' and python_full_version < '3.13'", ] dependencies = [ { name = "async-timeout", marker = "python_full_version >= '3.10' and python_full_version < '3.11.3'" },