diff --git a/python/.gitignore b/python/.gitignore index b4304c325..f771a16b7 100644 --- a/python/.gitignore +++ b/python/.gitignore @@ -164,3 +164,4 @@ cython_debug/ # Ruff cache .ruff_cache/ +mitmproxy-ca-cert.pem diff --git a/python/docs/source/reference/package-apis/drivers/index.md b/python/docs/source/reference/package-apis/drivers/index.md index d1a8c1006..6d6a51ab7 100644 --- a/python/docs/source/reference/package-apis/drivers/index.md +++ b/python/docs/source/reference/package-apis/drivers/index.md @@ -34,6 +34,7 @@ Drivers that provide various communication interfaces: * **[CAN](can.md)** (`jumpstarter-driver-can`) - Controller Area Network communication * **[HTTP](http.md)** (`jumpstarter-driver-http`) - HTTP communication +* **[Mitmproxy](mitmproxy.md)** (`jumpstarter-driver-mitmproxy`) - HTTP(S) interception, mocking, and traffic recording * **[Network](network.md)** (`jumpstarter-driver-network`) - Network interfaces and configuration * **[PySerial](pyserial.md)** (`jumpstarter-driver-pyserial`) - Serial port @@ -114,6 +115,7 @@ flashers.md http.md http-power.md iscsi.md +mitmproxy.md network.md opendal.md power.md diff --git a/python/docs/source/reference/package-apis/drivers/mitmproxy.md b/python/docs/source/reference/package-apis/drivers/mitmproxy.md new file mode 120000 index 000000000..8b3457f25 --- /dev/null +++ b/python/docs/source/reference/package-apis/drivers/mitmproxy.md @@ -0,0 +1 @@ +../../../../../packages/jumpstarter-driver-mitmproxy/README.md \ No newline at end of file diff --git a/python/packages/jumpstarter-driver-mitmproxy/README.md b/python/packages/jumpstarter-driver-mitmproxy/README.md new file mode 100644 index 000000000..7f5f3f82e --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/README.md @@ -0,0 +1,404 @@ +# jumpstarter-driver-mitmproxy + +A [Jumpstarter](https://jumpstarter.dev) driver for [mitmproxy](https://mitmproxy.org) — bringing HTTP(S) interception, backend mocking, and traffic recording to Hardware-in-the-Loop testing. + +## What it does + +This driver manages a `mitmdump` or `mitmweb` process on the Jumpstarter exporter host, providing your pytest HiL tests with: + +- **Backend mocking** — Return deterministic JSON responses for any API endpoint, with hot-reloadable definitions, wildcard path matching, conditional rules, sequences, templates, and custom addons +- **SSL/TLS interception** — Inspect and modify HTTPS traffic from your DUT, with easy CA certificate retrieval for DUT provisioning +- **Traffic recording & replay** — Capture a "golden" session against real servers, then replay it offline in CI +- **Request capture** — Record every request the DUT makes and assert on them in your tests +- **Browser-based UI** — Launch `mitmweb` for interactive traffic inspection, with TCP port forwarding through the Jumpstarter tunnel +- **Scenario files** — Load complete mock configurations from YAML or JSON, swap between test scenarios instantly +- **Full CLI** — Control the proxy interactively from `jmp shell` sessions + +## Installation + +```bash +# On both the exporter host and test client +pip install --extra-index-url https://pkg.jumpstarter.dev/simple \ + jumpstarter-driver-mitmproxy +``` + +Or build from source: + +```bash +uv build +pip install dist/jumpstarter_driver_mitmproxy-*.whl +``` + +## Exporter Configuration + +```yaml +# /etc/jumpstarter/exporters/my-bench.yaml +export: + proxy: + type: jumpstarter_driver_mitmproxy.driver.MitmproxyDriver + config: + listen: + host: "0.0.0.0" + port: 8080 # Proxy port (DUT connects here) + web: + host: "0.0.0.0" + port: 8081 # mitmweb browser UI port + directories: + data: /opt/jumpstarter/mitmproxy + ssl_insecure: true # Skip upstream cert verification + + # Auto-load a scenario on startup (relative to mocks dir) + # mock_scenario: happy-path.yaml + + # Inline mock definitions (overlaid on scenario) + # mocks: + # GET /api/v1/health: + # status: 200 + # body: {ok: true} +``` + +### Configuration Reference + +| Parameter | Description | Type | Default | +| --------- | ----------- | ---- | ------- | +| `listen.host` | Proxy listener bind address | str | `0.0.0.0` | +| `listen.port` | Proxy listener port | int | `8080` | +| `web.host` | mitmweb UI bind address | str | `0.0.0.0` | +| `web.port` | mitmweb UI port | int | `8081` | +| `directories.data` | Base data directory | str | `/opt/jumpstarter/mitmproxy` | +| `directories.conf` | mitmproxy config/certs dir | str | `{data}/conf` | +| `directories.flows` | Recorded flow files dir | str | `{data}/flows` | +| `directories.addons` | Custom addon scripts dir | str | `{data}/addons` | +| `directories.mocks` | Mock definitions dir | str | `{data}/mock-responses` | +| `directories.files` | Files to serve from mocks | str | `{data}/mock-files` | +| `ssl_insecure` | Skip upstream SSL verification | bool | `true` | +| `mock_scenario` | Scenario file to auto-load on startup | str | `""` | +| `mocks` | Inline mock endpoint definitions | dict | `{}` | + +See `examples/exporter.yaml` in the package source for a full exporter config with DUT Link, serial, and video drivers. + +## Modes + +| Mode | Description | +|---------------|--------------------------------------------------| +| `mock` | Intercept traffic, return mock responses | +| `passthrough` | Transparent proxy, log only | +| `record` | Capture all traffic to a binary flow file | +| `replay` | Serve responses from a previously recorded flow | + +Add `web_ui=True` (Python) or `--web-ui` (CLI) to any mode for the mitmweb browser interface. + +## CLI Commands + +During a `jmp shell` session, control the proxy with `j proxy `: + +### Lifecycle + +```console +j proxy start # start in mock mode (default) +j proxy start -m passthrough # start in passthrough mode +j proxy start -m mock -w # start with mitmweb UI +j proxy start -m record # start recording traffic +j proxy start -m replay --replay-file capture_20260213.bin +j proxy stop # stop the proxy +j proxy restart # restart with same config +j proxy restart -m passthrough # restart with new mode +j proxy status # show proxy status +``` + +### Mock Management + +```console +j proxy mock list # list configured mocks +j proxy mock clear # remove all mocks +j proxy mock load happy-path.yaml # load a scenario file +j proxy mock load my-capture/ # load a saved capture directory +``` + +### Traffic Capture + +```console +j proxy capture list # show captured requests +j proxy capture clear # clear captured requests +j proxy capture save ./my-capture # export as scenario to directory +j proxy capture save -f '/api/v1/*' ./my-capture # with path filter +j proxy capture save --exclude-mocked ./my-capture +``` + +### Flow Files + +```console +j proxy flow list # list recorded flow files +j proxy flow save capture_20260101.bin # download to current directory +j proxy flow save capture_20260101.bin /tmp/my.bin # download to specific path +``` + +### Web UI & Certificates + +```console +j proxy web # forward mitmweb UI to localhost:8081 +j proxy web --port 9090 # forward to a custom port +j proxy cert # download CA cert to ./mitmproxy-ca-cert.pem +j proxy cert /tmp/ca.pem # download to a specific path +``` + +## Python API + +### Basic Usage + +```python +def test_device_status(client): + proxy = client.proxy + + # Start with web UI for debugging + proxy.start(mode="mock", web_ui=True) + + # Mock a backend endpoint + proxy.set_mock( + "GET", "/api/v1/status", + body={"id": "device-001", "status": "active"}, + ) + + # ... interact with DUT ... + + proxy.stop() +``` + +### Context Managers + +Context managers ensure clean teardown even if the test fails: + +```python +def test_firmware_update(client): + proxy = client.proxy + + with proxy.session(mode="mock", web_ui=True): + with proxy.mock_endpoint( + "GET", "/api/v1/updates/check", + body={"update_available": True, "version": "2.6.0"}, + ): + # DUT will see the mocked update + trigger_update_check(client) + assert_update_dialog_shown(client) + # Mock auto-removed here + # Proxy auto-stopped here +``` + +Available context managers: + +| Context Manager | Description | +| --------------- | ----------- | +| `proxy.session(mode, web_ui)` | Start/stop the proxy | +| `proxy.mock_endpoint(method, path, ...)` | Temporary mock endpoint | +| `proxy.mock_scenario(file)` | Load/clear a scenario file | +| `proxy.mock_conditional(method, path, rules)` | Temporary conditional mock | +| `proxy.recording()` | Record traffic to a flow file | +| `proxy.capture()` | Capture and assert on requests | + +### Request Capture + +Verify that the DUT is making the right API calls: + +```python +def test_telemetry_sent(client): + proxy = client.proxy + + with proxy.capture() as cap: + # ... DUT sends telemetry through the proxy ... + cap.wait_for_request("POST", "/api/v1/telemetry", timeout=10) + + # After the block, cap.requests is a frozen snapshot + assert len(cap.requests) >= 1 + cap.assert_request_made("POST", "/api/v1/telemetry") +``` + +### Advanced Mocking + +#### Conditional responses + +Return different responses based on request headers, body, or query params: + +```python +proxy.set_mock_conditional("POST", "/api/auth", [ + { + "match": {"body_json": {"username": "admin", "password": "secret"}}, + "status": 200, + "body": {"token": "mock-token-001"}, + }, + {"status": 401, "body": {"error": "unauthorized"}}, +]) +``` + +#### Response sequences + +Return different responses on successive calls: + +```python +proxy.set_mock_sequence("GET", "/api/v1/auth/token", [ + {"status": 200, "body": {"token": "aaa"}, "repeat": 3}, + {"status": 401, "body": {"error": "expired"}, "repeat": 1}, + {"status": 200, "body": {"token": "bbb"}}, +]) +``` + +#### Dynamic templates + +Responses with per-request dynamic values: + +```python +proxy.set_mock_template("GET", "/api/v1/weather", { + "temp_f": "{{random_int(60, 95)}}", + "condition": "{{random_choice('sunny', 'rain')}}", + "timestamp": "{{now_iso}}", + "request_id": "{{uuid}}", +}) +``` + +#### Simulated latency + +```python +proxy.set_mock_with_latency( + "GET", "/api/v1/status", + body={"status": "online"}, + latency_ms=3000, +) +``` + +#### File serving + +```python +proxy.set_mock_file( + "GET", "/api/v1/downloads/firmware.bin", + "firmware/test.bin", + content_type="application/octet-stream", +) +``` + +#### Custom addon scripts + +```python +proxy.set_mock_addon( + "GET", "/streaming/audio/channel/*", + "hls_audio_stream", + addon_config={"segment_duration_s": 6}, +) +``` + +### State Store + +Share state between tests and conditional mock rules: + +```python +proxy.set_state("auth_token", "mock-token-001") +proxy.set_state("retries", 3) + +token = proxy.get_state("auth_token") # "mock-token-001" +all_state = proxy.get_all_state() # {"auth_token": "...", "retries": 3} + +proxy.clear_state() +``` + +## SSL/TLS Setup + +For HTTPS interception, the mitmproxy CA certificate must be installed on the DUT. The certificate is generated the first time the proxy starts. + +### From the CLI + +```console +j proxy cert # writes ./mitmproxy-ca-cert.pem +j proxy cert /tmp/ca.pem # custom output path +``` + +### From Python + +```python +# Get the PEM certificate contents +pem = proxy.get_ca_cert() + +# Write to a local file +from pathlib import Path +Path("/tmp/mitmproxy-ca.pem").write_text(pem) + +# Or push directly to the DUT via serial/ssh/adb +dut.write_file("/etc/ssl/certs/mitmproxy-ca.pem", pem) +``` + +### Exporter-side path + +If you need the path on the exporter host itself (for provisioning scripts that run locally): + +```python +cert_path = proxy.get_ca_cert_path() +# -> /opt/jumpstarter/mitmproxy/conf/mitmproxy-ca-cert.pem +``` + +## Mock Scenarios + +Create YAML or JSON files with endpoint definitions: + +```yaml +# scenarios/happy-path.yaml +endpoints: + GET /api/v1/status: + status: 200 + body: + id: device-001 + status: active + firmware_version: "2.5.1" + + POST /api/v1/telemetry/upload: + status: 202 + body: + accepted: true + + GET /api/v1/search*: # wildcard prefix match + status: 200 + body: + results: [] +``` + +Load from CLI or Python: + +```console +j proxy mock load happy-path.yaml +j proxy mock load my-capture/ # directory from 'capture save' +``` + +```python +proxy.load_mock_scenario("happy-path.yaml") + +# Or with automatic cleanup: +with proxy.mock_scenario("happy-path.yaml"): + run_tests() +``` + +See `examples/scenarios/` in the package source for complete scenario examples including conditional rules, templates, and sequences. + +## Web UI Port Forwarding + +The mitmweb UI runs on the exporter host and is not directly reachable from the test client. The `web` command tunnels it through the Jumpstarter gRPC transport: + +```console +j proxy start -m mock -w # start with web UI on the exporter +j proxy web # tunnel to localhost:8081 +j proxy web --port 9090 # use a custom local port +``` + +Then open `http://localhost:8081` in your browser to inspect traffic in real time. + +## Container Deployment + +```bash +podman build -t jumpstarter-mitmproxy:latest . + +podman run --rm -it --privileged \ + -v /dev:/dev \ + -v /etc/jumpstarter:/etc/jumpstarter:Z \ + -p 8080:8080 -p 8081:8081 \ + jumpstarter-mitmproxy:latest \ + jmp exporter start my-bench +``` + +## License + +Apache-2.0 diff --git a/python/packages/jumpstarter-driver-mitmproxy/demo/backend.py b/python/packages/jumpstarter-driver-mitmproxy/demo/backend.py new file mode 100644 index 000000000..7c0ad3630 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/demo/backend.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Simulated cloud backend server for the mitmproxy demo. + +Serves four REST endpoints that a DUT would typically call. +Every response includes a ``"source": "real-backend"`` field so the +difference between real and mocked traffic is immediately visible. + +Usage:: + + python backend.py [--port 9000] +""" + +from __future__ import annotations + +import argparse +import json +import time +from http.server import BaseHTTPRequestHandler, HTTPServer + +START_TIME = time.time() + +# ANSI colours for terminal output +_GREEN = "\033[92m" +_CYAN = "\033[96m" +_YELLOW = "\033[93m" +_DIM = "\033[2m" +_RESET = "\033[0m" + + +class DemoBackendHandler(BaseHTTPRequestHandler): + """Handles GET/POST for the four demo API endpoints.""" + + # Suppress the default stderr log line per request + def log_message(self, format, *args): # noqa: A002 + pass + + # ── routes ──────────────────────────────────────────────── + + def do_GET(self): # noqa: N802 + if self.path == "/api/v1/status": + self._send_json(200, { + "device_id": "DUT-REAL-001", + "status": "online", + "firmware_version": "1.0.0", + "uptime_s": int(time.time() - START_TIME), + "source": "real-backend", + }) + elif self.path == "/api/v1/updates/check": + self._send_json(200, { + "update_available": False, + "current_version": "1.0.0", + "source": "real-backend", + }) + elif self.path == "/api/v1/config": + self._send_json(200, { + "log_level": "info", + "features": { + "ota_updates": True, + "remote_diagnostics": False, + "telemetry": True, + }, + "source": "real-backend", + }) + else: + self._send_json(404, { + "error": "not found", + "source": "real-backend", + }) + + def do_POST(self): # noqa: N802 + if self.path == "/api/v1/telemetry": + # Read (and discard) the request body + length = int(self.headers.get("Content-Length", 0)) + if length: + self.rfile.read(length) + self._send_json(200, { + "accepted": True, + "source": "real-backend", + }) + else: + self._send_json(404, { + "error": "not found", + "source": "real-backend", + }) + + # ── helpers ─────────────────────────────────────────────── + + def _send_json(self, status: int, body: dict): + payload = json.dumps(body).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + self._log_request(status) + + def _log_request(self, status: int): + colour = _GREEN if 200 <= status < 300 else _YELLOW + ts = time.strftime("%H:%M:%S") + print( + f" {_DIM}{ts}{_RESET} " + f"{colour}{status}{_RESET} " + f"{_CYAN}{self.command:4s}{_RESET} {self.path}" + ) + + +def main(): + parser = argparse.ArgumentParser(description="Demo backend server") + parser.add_argument("--port", type=int, default=9000) + args = parser.parse_args() + + server = HTTPServer(("127.0.0.1", args.port), DemoBackendHandler) + print(f"Backend server listening on http://127.0.0.1:{args.port}") + print("Endpoints:") + print(" GET /api/v1/status") + print(" GET /api/v1/updates/check") + print(" POST /api/v1/telemetry") + print(" GET /api/v1/config") + print() + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nShutting down.") + server.shutdown() + + +if __name__ == "__main__": + main() diff --git a/python/packages/jumpstarter-driver-mitmproxy/demo/conftest.py b/python/packages/jumpstarter-driver-mitmproxy/demo/conftest.py new file mode 100644 index 000000000..085fbc786 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/demo/conftest.py @@ -0,0 +1,90 @@ +"""Pytest fixtures for the mitmproxy demo. + +Run via:: + + cd python/packages/jumpstarter-driver-mitmproxy/demo + jmp shell --exporter exporter.yaml -- pytest . -v +""" + +from __future__ import annotations + +import socket +import threading +import time +from http.server import HTTPServer + +import pytest +import requests +from backend import DemoBackendHandler + +BACKEND_PORT = 9000 +PROXY_PORT = 8080 + + +def _wait_for_port(host: str, port: int, timeout: float = 10) -> bool: + """TCP retry loop to confirm a port is accepting connections.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with socket.create_connection((host, port), timeout=1): + return True + except OSError: + time.sleep(0.2) + return False + + +# ── Backend server ──────────────────────────────────────────── + + +@pytest.fixture(scope="session") +def backend_server(): + """Start the demo backend HTTP server in a daemon thread.""" + server = HTTPServer(("127.0.0.1", BACKEND_PORT), DemoBackendHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + assert _wait_for_port("127.0.0.1", BACKEND_PORT), ( + f"Backend server did not start on port {BACKEND_PORT}" + ) + yield server + server.shutdown() + + +# ── Proxy ───────────────────────────────────────────────────── + + +@pytest.fixture(scope="session") +def proxy(client): + """Start the mitmproxy driver in mock mode. + + The ``client`` fixture is injected by Jumpstarter when tests + run inside ``jmp shell --exporter exporter.yaml -- pytest``. + """ + proxy = client.proxy + proxy.start("mock") + assert _wait_for_port("127.0.0.1", PROXY_PORT), ( + f"Proxy did not start on port {PROXY_PORT}" + ) + yield proxy + proxy.stop() + + +@pytest.fixture +def proxy_client(proxy): + """Per-test wrapper: clears mocks and captures before/after each test.""" + proxy.clear_mocks() + proxy.clear_captured_requests() + yield proxy + proxy.clear_mocks() + proxy.clear_captured_requests() + + +@pytest.fixture +def http_session(): + """Requests session pre-configured to route through the proxy.""" + session = requests.Session() + session.proxies = { + "http": f"http://127.0.0.1:{PROXY_PORT}", + "https": f"http://127.0.0.1:{PROXY_PORT}", + } + session.verify = False + return session diff --git a/python/packages/jumpstarter-driver-mitmproxy/demo/dut_simulator.py b/python/packages/jumpstarter-driver-mitmproxy/demo/dut_simulator.py new file mode 100644 index 000000000..37e9f122e --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/demo/dut_simulator.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Simulated DUT client that polls the backend through the proxy. + +Cycles through all four API endpoints every ``--interval`` seconds, +printing colour-coded output so the "source" switch is obvious when +mock scenarios are loaded/cleared during the live demo. + +Usage:: + + python dut_simulator.py [--proxy http://127.0.0.1:8080] + [--backend http://127.0.0.1:9000] + [--interval 5] +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +from urllib.parse import urlsplit + +import requests + +# ── ANSI colours ────────────────────────────────────────────── +_GREEN = "\033[92m" +_YELLOW = "\033[93m" +_RED = "\033[91m" +_CYAN = "\033[96m" +_MAGENTA = "\033[95m" +_DIM = "\033[2m" +_BOLD = "\033[1m" +_RESET = "\033[0m" + + +def _status_colour(code: int) -> str: + if 200 <= code < 300: + return _GREEN + if 400 <= code < 500: + return _YELLOW + return _RED + + +def _source_colour(source: str) -> str: + if source == "mock": + return _MAGENTA + return _CYAN + + +def _print_response(method: str, path: str, resp: requests.Response): + ts = time.strftime("%H:%M:%S") + sc = _status_colour(resp.status_code) + + print(f" {_DIM}{ts}{_RESET} {sc}{resp.status_code}{_RESET} {method:4s} {path}") + + try: + body = resp.json() + except (json.JSONDecodeError, ValueError): + print(f" {_DIM}(non-JSON body){_RESET}") + return + + source = body.get("source", "?") + src_c = _source_colour(source) + # Print a compact one-liner for the key fields + fields = {k: v for k, v in body.items() if k != "source"} + summary = ", ".join(f"{k}={v}" for k, v in fields.items()) + print(f" source={src_c}{_BOLD}{source}{_RESET} {_DIM}{summary}{_RESET}") + + +def _print_error(method: str, path: str, err: Exception): + ts = time.strftime("%H:%M:%S") + print(f" {_DIM}{ts}{_RESET} {_RED}ERR{_RESET} {method:4s} {path}") + print(f" {_RED}{type(err).__name__}: {err}{_RESET}") + + +def run_cycle(session: requests.Session, backend: str): + """Execute one full cycle of DUT requests.""" + endpoints = [ + ("GET", f"{backend}/api/v1/status"), + ("GET", f"{backend}/api/v1/updates/check"), + ("POST", f"{backend}/api/v1/telemetry"), + ("GET", f"{backend}/api/v1/config"), + ] + + for method, url in endpoints: + path = urlsplit(url).path + try: + if method == "POST": + resp = session.post( + url, + json={"cpu_temp": 42.5, "mem_used_pct": 61}, + timeout=10, + ) + else: + resp = session.get(url, timeout=10) + _print_response(method, path, resp) + except ( + requests.exceptions.ProxyError, + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + ) as exc: + _print_error(method, path, exc) + + +def main(): + parser = argparse.ArgumentParser(description="DUT simulator") + parser.add_argument( + "--proxy", default="http://127.0.0.1:8080", + help="HTTP proxy URL (default: http://127.0.0.1:8080)", + ) + parser.add_argument( + "--backend", default="http://127.0.0.1:9000", + help="Backend server URL (default: http://127.0.0.1:9000)", + ) + parser.add_argument( + "--interval", type=float, default=5, + help="Seconds between polling cycles (default: 5)", + ) + args = parser.parse_args() + + session = requests.Session() + session.proxies = {"http": args.proxy, "https": args.proxy} + + print(f"{_BOLD}DUT Simulator{_RESET}") + print(f" Backend : {args.backend}") + print(f" Proxy : {args.proxy}") + print(f" Interval: {args.interval}s") + print() + + cycle = 0 + try: + while True: + cycle += 1 + print(f"{_DIM}── cycle {cycle} ──{_RESET}") + run_cycle(session, args.backend) + print() + time.sleep(args.interval) + except KeyboardInterrupt: + print(f"\n{_DIM}Stopped after {cycle} cycles.{_RESET}") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/python/packages/jumpstarter-driver-mitmproxy/demo/exporter.yaml b/python/packages/jumpstarter-driver-mitmproxy/demo/exporter.yaml new file mode 100644 index 000000000..59ea41d06 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/demo/exporter.yaml @@ -0,0 +1,29 @@ +# Demo exporter config for local mode (no endpoint/token needed). +# +# Usage: +# cd python/packages/jumpstarter-driver-mitmproxy/demo +# jmp shell --exporter exporter.yaml +# +# Then inside the shell: +# j proxy start -m mock -w +# j proxy mock load scenarios/happy-path +# j proxy mock clear + +apiVersion: jumpstarter.dev/v1alpha1 +kind: ExporterConfig +metadata: + namespace: default + name: mitmproxy-demo +export: + proxy: + type: jumpstarter_driver_mitmproxy.driver.MitmproxyDriver + config: + listen: + host: "127.0.0.1" + port: 8080 + web: + host: "127.0.0.1" + port: 8081 + directories: + data: /tmp/jumpstarter-demo/mitmproxy + ssl_insecure: true diff --git a/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/backend-outage/scenario.yaml b/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/backend-outage/scenario.yaml new file mode 100644 index 000000000..f0d6229be --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/backend-outage/scenario.yaml @@ -0,0 +1,37 @@ +# Backend-outage scenario: all API endpoints return 503. +# +# Demonstrates match.headers_absent: ordinary DUT traffic (no +# X-Internal-Monitor header) receives 503 Service Unavailable, while +# internal health-check probes (carrying the header) still get 200 so +# the monitoring system does not raise false alerts. + +endpoints: + http://127.0.0.1:9000/api/v1/*: + - method: GET + match: + headers_absent: + - X-Internal-Monitor # no header → outage response + status: 503 + body: + error: Service Unavailable + message: "Backend is down for maintenance" + source: mock + - method: GET # internal health-check → pass + status: 200 + body: + status: ok + source: mock + - method: POST + match: + headers_absent: + - X-Internal-Monitor + status: 503 + body: + error: Service Unavailable + message: "Backend is down for maintenance" + source: mock + - method: POST + status: 200 + body: + accepted: true + source: mock diff --git a/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/happy-path/scenario.yaml b/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/happy-path/scenario.yaml new file mode 100644 index 000000000..069efa4bc --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/happy-path/scenario.yaml @@ -0,0 +1,81 @@ +# Happy-path scenario: all endpoints return mock success data. +# +# Demonstrates per-entry match conditions — entries are evaluated in +# order and the first matching one wins. An entry with no "match" key +# is an unconditional default. +# +# match.query — require specific query parameters +# match.body_json — match on JSON fields in the request body +# match.headers — require specific request headers + +endpoints: + http://127.0.0.1:9000/api/v1/status: + - method: GET + match: + headers: + X-Device-Type: automotive # automotive head-unit → richer response + status: 200 + body: + device_id: DUT-MOCK-999 + status: online + firmware_version: "2.5.1" + uptime_s: 172800 + features: + climate_control: true + navigation: true + source: mock + - method: GET # default for any other device type + status: 200 + body: + device_id: DUT-MOCK-999 + status: online + firmware_version: "2.5.1" + uptime_s: 172800 + source: mock + + http://127.0.0.1:9000/api/v1/updates/check: + - method: GET + match: + query: + channel: beta # opt-in beta channel → newer pre-release + status: 200 + body: + update_available: true + current_version: "2.5.1" + latest_version: "2.6.0-beta" + channel: beta + source: mock + - method: GET # default stable channel → no update + status: 200 + body: + update_available: false + current_version: "2.5.1" + latest_version: "2.5.1" + source: mock + + http://127.0.0.1:9000/api/v1/telemetry: + - method: POST + match: + body_json: + level: critical # critical alerts → extra acknowledgment + status: 200 + body: + accepted: true + alert_triggered: true + source: mock + - method: POST # default for normal telemetry + status: 200 + body: + accepted: true + source: mock + + http://127.0.0.1:9000/api/v1/config: + - method: GET + status: 200 + body: + log_level: info + features: + ota_updates: true + remote_diagnostics: true + telemetry: true + source: mock diff --git a/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/update-available/scenario.yaml b/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/update-available/scenario.yaml new file mode 100644 index 000000000..d5d970678 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/update-available/scenario.yaml @@ -0,0 +1,55 @@ +# Update-available scenario: OTA update reported as available. +# +# Demonstrates match.headers: devices that already report the latest +# firmware version via X-Current-Version are told they are up to date; +# all other devices receive the update notification. + +endpoints: + http://127.0.0.1:9000/api/v1/status: + - method: GET + status: 200 + body: + device_id: DUT-MOCK-999 + status: online + firmware_version: "2.5.1" + uptime_s: 172800 + source: mock + + http://127.0.0.1:9000/api/v1/updates/check: + - method: GET + match: + headers: + X-Current-Version: "3.0.0" # device already on latest → no-op + status: 200 + body: + update_available: false + current_version: "3.0.0" + latest_version: "3.0.0" + source: mock + - method: GET # default → update available + status: 200 + body: + update_available: true + current_version: "2.5.1" + latest_version: "3.0.0" + release_notes: "Major update: improved power management and new diagnostic API" + size_bytes: 524288000 + source: mock + + http://127.0.0.1:9000/api/v1/telemetry: + - method: POST + status: 200 + body: + accepted: true + source: mock + + http://127.0.0.1:9000/api/v1/config: + - method: GET + status: 200 + body: + log_level: info + features: + ota_updates: true + remote_diagnostics: true + telemetry: true + source: mock diff --git a/python/packages/jumpstarter-driver-mitmproxy/demo/test_demo.py b/python/packages/jumpstarter-driver-mitmproxy/demo/test_demo.py new file mode 100644 index 000000000..8e0cd56bd --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/demo/test_demo.py @@ -0,0 +1,276 @@ +"""Automated demo tests for the mitmproxy Jumpstarter driver. + +Each test class demonstrates a different capability of the proxy driver. +Run with:: + + cd python/packages/jumpstarter-driver-mitmproxy/demo + jmp shell --exporter exporter.yaml -- pytest . -v + +The tests require the backend server fixture (started automatically) +and a running proxy (started automatically via the ``proxy`` fixture). +""" + +from __future__ import annotations + +import time +from pathlib import Path + +import pytest + +SCENARIOS_DIR = Path(__file__).parent / "scenarios" +BACKEND_URL = "http://127.0.0.1:9000" + + +# ── Passthrough (no mocks) ──────────────────────────────────── + + +class TestPassthrough: + """No mocks configured — requests flow through the proxy to the real backend.""" + + def test_status_from_real_backend( + self, backend_server, proxy_client, http_session, + ): + resp = http_session.get(f"{BACKEND_URL}/api/v1/status", timeout=10) + assert resp.status_code == 200 + data = resp.json() + assert data["source"] == "real-backend" + assert data["device_id"] == "DUT-REAL-001" + + def test_updates_from_real_backend( + self, backend_server, proxy_client, http_session, + ): + resp = http_session.get( + f"{BACKEND_URL}/api/v1/updates/check", timeout=10, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["source"] == "real-backend" + assert data["update_available"] is False + + def test_telemetry_from_real_backend( + self, backend_server, proxy_client, http_session, + ): + resp = http_session.post( + f"{BACKEND_URL}/api/v1/telemetry", + json={"cpu_temp": 42.5}, + timeout=10, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["source"] == "real-backend" + assert data["accepted"] is True + + +# ── Mock overrides ──────────────────────────────────────────── + + +class TestMockOverride: + """Setting mocks replaces real backend responses.""" + + def test_mock_replaces_real_response( + self, backend_server, proxy_client, http_session, + ): + proxy_client.set_mock( + "GET", "/api/v1/status", + body={ + "device_id": "DUT-MOCK-999", + "status": "online", + "firmware_version": "2.5.1", + "source": "mock", + }, + ) + time.sleep(1) # hot-reload timing + + resp = http_session.get( + "http://example.com/api/v1/status", timeout=10, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["source"] == "mock" + assert data["device_id"] == "DUT-MOCK-999" + + def test_mock_error_status( + self, backend_server, proxy_client, http_session, + ): + proxy_client.set_mock( + "GET", "/api/v1/status", + status=503, + body={ + "error": "Service Unavailable", + "source": "mock", + }, + ) + time.sleep(1) + + resp = http_session.get( + "http://example.com/api/v1/status", timeout=10, + ) + assert resp.status_code == 503 + assert resp.json()["error"] == "Service Unavailable" + + def test_mock_endpoint_context_manager( + self, backend_server, proxy_client, http_session, + ): + """mock_endpoint() auto-cleans up after the with block.""" + with proxy_client.mock_endpoint( + "GET", "/api/v1/status", + body={ + "device_id": "TEMP-MOCK", + "source": "mock", + }, + ): + time.sleep(1) + resp = http_session.get( + "http://example.com/api/v1/status", timeout=10, + ) + assert resp.json()["source"] == "mock" + assert resp.json()["device_id"] == "TEMP-MOCK" + + # After context exit, the mock is removed + time.sleep(1) + mocks = proxy_client.list_mocks() + assert "GET /api/v1/status" not in mocks + + +# ── Scenario loading ────────────────────────────────────────── + + +class TestScenarioLoading: + """Load complete YAML scenario files to set up groups of mocks.""" + + def test_load_happy_path( + self, backend_server, proxy_client, http_session, + ): + proxy_client.load_mock_scenario(str(SCENARIOS_DIR / "happy-path")) + time.sleep(1) + + resp = http_session.get( + f"{BACKEND_URL}/api/v1/status", timeout=10, + ) + data = resp.json() + assert data["source"] == "mock" + assert data["device_id"] == "DUT-MOCK-999" + assert data["firmware_version"] == "2.5.1" + + def test_load_update_available( + self, backend_server, proxy_client, http_session, + ): + proxy_client.load_mock_scenario( + str(SCENARIOS_DIR / "update-available"), + ) + time.sleep(1) + + resp = http_session.get( + f"{BACKEND_URL}/api/v1/updates/check", timeout=10, + ) + data = resp.json() + assert data["source"] == "mock" + assert data["update_available"] is True + assert data["latest_version"] == "3.0.0" + + def test_load_backend_outage( + self, backend_server, proxy_client, http_session, + ): + proxy_client.load_mock_scenario( + str(SCENARIOS_DIR / "backend-outage"), + ) + time.sleep(1) + + resp = http_session.get( + f"{BACKEND_URL}/api/v1/status", timeout=10, + ) + assert resp.status_code == 503 + assert resp.json()["error"] == "Service Unavailable" + + def test_mock_scenario_context_manager( + self, backend_server, proxy_client, http_session, + ): + """mock_scenario() loads on entry, clears on exit.""" + with proxy_client.mock_scenario( + str(SCENARIOS_DIR / "happy-path"), + ): + time.sleep(1) + resp = http_session.get( + f"{BACKEND_URL}/api/v1/status", timeout=10, + ) + assert resp.json()["source"] == "mock" + + # After exit: mocks cleared → passthrough to real backend + time.sleep(1) + resp = http_session.get( + f"{BACKEND_URL}/api/v1/status", timeout=10, + ) + assert resp.json()["source"] == "real-backend" + + +# ── Request capture ─────────────────────────────────────────── + + +class TestRequestCapture: + """Demonstrate request capture and inspection.""" + + def test_capture_context_manager( + self, backend_server, proxy_client, http_session, + ): + proxy_client.set_mock( + "GET", "/api/v1/status", + body={"device_id": "CAP-001", "source": "mock"}, + ) + time.sleep(1) + + with proxy_client.capture() as cap: + http_session.get( + "http://example.com/api/v1/status", timeout=10, + ) + cap.wait_for_request("GET", "/api/v1/status", 5.0) + + assert len(cap.requests) >= 1 + assert cap.requests[0]["method"] == "GET" + assert cap.requests[0]["path"] == "/api/v1/status" + + def test_wait_for_request( + self, backend_server, proxy_client, http_session, + ): + proxy_client.set_mock( + "POST", "/api/v1/telemetry", + body={"accepted": True, "source": "mock"}, + ) + time.sleep(1) + + http_session.post( + "http://example.com/api/v1/telemetry", + json={"cpu_temp": 42.5}, + timeout=10, + ) + result = proxy_client.wait_for_request( + "POST", "/api/v1/telemetry", 5.0, + ) + assert result["method"] == "POST" + assert result["path"] == "/api/v1/telemetry" + assert result["response_status"] == 200 + + def test_assert_request_made_with_wildcard( + self, backend_server, proxy_client, http_session, + ): + proxy_client.set_mock( + "GET", "/api/v1/config", + body={"log_level": "debug", "source": "mock"}, + ) + time.sleep(1) + + http_session.get( + "http://example.com/api/v1/config", timeout=10, + ) + proxy_client.wait_for_request("GET", "/api/v1/config", 5.0) + + # Exact match + result = proxy_client.assert_request_made("GET", "/api/v1/config") + assert result["method"] == "GET" + + # Wildcard match + result = proxy_client.assert_request_made("GET", "/api/v1/*") + assert result["method"] == "GET" + + # Should fail for unmatched + with pytest.raises(AssertionError, match="not captured"): + proxy_client.assert_request_made("DELETE", "/api/v1/config") diff --git a/python/packages/jumpstarter-driver-mitmproxy/examples/addons/_template.py b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/_template.py new file mode 100644 index 000000000..f03d343e6 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/_template.py @@ -0,0 +1,112 @@ +""" +Custom addon template for jumpstarter-driver-mitmproxy. + +Copy this file, rename it, and implement your Handler class. +The filename (without .py) becomes the addon name you reference +in mock scenario JSON files. + +Example: Save as ``addons/my_custom_api.py``, then reference in +your scenario JSON as:: + + "GET /my/endpoint/*": { + "addon": "my_custom_api", + "addon_config": { + "any_key": "passed to your handler" + } + } + +The Handler class must implement at minimum: + + handle(flow, config) -> bool + Called for every matched HTTP request. + Return True if you handled it (set flow.response). + Return False to fall through to default handling. + +Optional methods: + + websocket_message(flow, config) + Called for each WebSocket message on matched connections. + + cleanup() + Called when the addon is unloaded (not currently triggered + automatically — reserved for future use). +""" + +from __future__ import annotations + +import json + +from mitmproxy import ctx, http + + +class Handler: + """Template handler — replace with your implementation.""" + + def __init__(self): + # Initialize any state your handler needs. + # This is called once when the addon is first loaded. + self.request_count = 0 + + def handle(self, flow: http.HTTPFlow, config: dict) -> bool: + """Handle an incoming HTTP request. + + Args: + flow: The mitmproxy HTTPFlow. Read from flow.request, + write to flow.response. + config: The "addon_config" dict from the mock scenario + JSON. Empty dict if not specified. + + Returns: + True if you set flow.response (request is fully handled). + False to let the request fall through to the real server + or to the next matching mock rule. + """ + self.request_count += 1 + + # Example: return a JSON response + flow.response = http.Response.make( + 200, + json.dumps({ + "handler": "template", + "request_count": self.request_count, + "path": flow.request.path, + "config": config, + }).encode(), + {"Content-Type": "application/json"}, + ) + + ctx.log.info( + f"Template handler: {flow.request.method} " + f"{flow.request.path} (#{self.request_count})" + ) + + return True + + def websocket_message(self, flow: http.HTTPFlow, config: dict): + """Handle a WebSocket message (optional). + + Args: + flow: The HTTPFlow with .websocket data. + config: The "addon_config" from the scenario JSON. + """ + if flow.websocket is None: + return + + last_msg = flow.websocket.messages[-1] + if last_msg.from_client: + ctx.log.info( + f"WS client message: {last_msg.content!r}" + ) + + # Example: echo back to client with modification + # ctx.master.commands.call( + # "inject.websocket", flow, True, + # b'{"type": "echo", "data": ...}', + # ) + + def cleanup(self) -> None: + """Called when the addon is unloaded. + + Reserved for future use — not yet triggered automatically. + Add teardown logic here (close connections, flush buffers, etc.). + """ diff --git a/python/packages/jumpstarter-driver-mitmproxy/examples/addons/data_stream_websocket.py b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/data_stream_websocket.py new file mode 100644 index 000000000..289007ce0 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/data_stream_websocket.py @@ -0,0 +1,349 @@ +""" +Custom addon: Real-time data stream WebSocket simulation. + +Simulates a WebSocket endpoint that pushes live sensor/telemetry data +to the DUT. This exercises real-time data rendering and update logic +without needing a real data source. + +Mock config entry:: + + "WEBSOCKET /api/v1/data/realtime": { + "addon": "data_stream_websocket", + "addon_config": { + "push_interval_ms": 100, + "scenario": "normal", + "scenarios": { + "idle": { + "value_range": [0, 0], + "rate_range": [0, 0], + "drain_pct_per_s": 0.001 + }, + "normal": { + "value_range": [30, 70], + "rate_range": [100, 500], + "drain_pct_per_s": 0.015 + }, + "variable": { + "value_range": [0, 55], + "rate_range": [50, 1000], + "drain_pct_per_s": 0.02 + }, + "recovery": { + "value_range": [5, 40], + "rate_range": [0, 200], + "drain_pct_per_s": -0.03 + } + } + } + } + +The addon intercepts the initial WebSocket handshake and, once +established, periodically injects telemetry messages to the client +using mitmproxy's ``inject.websocket`` command. +""" + +from __future__ import annotations + +import asyncio +import json +import math +import random +import time + +from mitmproxy import ctx, http + + +class Handler: + """Sensor data WebSocket mock handler. + + On WebSocket connect, starts an async task that pushes JSON + telemetry frames to the client at the configured interval. + + Each frame looks like:: + + { + "type": "telemetry", + "timestamp": 1708300000.123, + "sensor_value": 52.3, + "rate": 350, + "battery_pct": 84.7, + "voltage": 3.82, + "state": "active", + "counter": 12450, + "temperature_c": 42.1, + "gps": {"lat": 0.0, "lon": 0.0, "heading": 0.0} + } + """ + + def __init__(self): + self._tasks: dict[int, asyncio.Task] = {} + + def handle(self, flow: http.HTTPFlow, config: dict) -> bool: + """Handle the initial WebSocket upgrade request. + + We let the upgrade proceed (so mitmproxy establishes the + WebSocket), then the websocket_message hook and async + injector take over. + + Returns True to indicate the request was handled (but we + don't set flow.response — we let the WebSocket handshake + complete naturally by NOT intercepting it here). + """ + # Don't block the handshake — return False to let it through + # to the server (or get intercepted later by websocket hooks) + return False + + def websocket_message(self, flow: http.HTTPFlow, config: dict): + """Handle WebSocket messages and start telemetry injection. + + On the first client message (typically a subscribe/init + message), start the async telemetry push task. + """ + if flow.websocket is None: + return + + last_msg = flow.websocket.messages[-1] + + # Only react to client messages + if not last_msg.from_client: + return + + # Parse client command + try: + cmd = json.loads(last_msg.text) if last_msg.is_text else {} + except (json.JSONDecodeError, UnicodeDecodeError): + cmd = {} + + flow_id = id(flow) + + msg_type = cmd.get("type", cmd.get("action", "subscribe")) + + if msg_type in ("subscribe", "start", "init"): + # Start pushing telemetry if not already running + if flow_id not in self._tasks or self._tasks[flow_id].done(): + scenario_name = cmd.get( + "scenario", + config.get("scenario", "normal"), + ) + interval_ms = config.get("push_interval_ms", 100) + scenarios = config.get("scenarios", DEFAULT_SCENARIOS) + scenario = scenarios.get(scenario_name, scenarios.get( + "normal", DEFAULT_SCENARIOS["normal"], + )) + + task = asyncio.ensure_future( + self._push_telemetry( + flow, scenario, interval_ms / 1000.0, + ) + ) + self._tasks[flow_id] = task + + # Send acknowledgment + ack = json.dumps({ + "type": "subscribed", + "scenario": scenario_name, + "interval_ms": interval_ms, + }) + ctx.master.commands.call( + "inject.websocket", flow, True, ack.encode(), + ) + ctx.log.info( + f"WS telemetry started: scenario={scenario_name}, " + f"interval={interval_ms}ms" + ) + + elif msg_type in ("unsubscribe", "stop"): + if flow_id in self._tasks: + self._tasks[flow_id].cancel() + del self._tasks[flow_id] + ctx.log.info("WS telemetry stopped") + + elif msg_type == "set_scenario": + # Switch scenario mid-stream + new_scenario = cmd.get("scenario", "normal") + if flow_id in self._tasks: + self._tasks[flow_id].cancel() + del self._tasks[flow_id] + scenarios = config.get("scenarios", DEFAULT_SCENARIOS) + scenario = scenarios.get(new_scenario, DEFAULT_SCENARIOS.get( + new_scenario, DEFAULT_SCENARIOS["normal"], + )) + interval_ms = config.get("push_interval_ms", 100) + task = asyncio.ensure_future( + self._push_telemetry( + flow, scenario, interval_ms / 1000.0, + ) + ) + self._tasks[flow_id] = task + ctx.log.info(f"WS telemetry scenario changed: {new_scenario}") + + async def _push_telemetry( + self, + flow: http.HTTPFlow, + scenario: dict, + interval_s: float, + ): + """Async loop that pushes telemetry frames to the client.""" + state = SensorState(scenario) + + try: + while ( + flow.websocket is not None + and flow.websocket.timestamp_end is None + ): + frame = state.next_frame() + payload = json.dumps(frame).encode() + + ctx.master.commands.call( + "inject.websocket", flow, True, payload, + ) + + await asyncio.sleep(interval_s) + + except asyncio.CancelledError: + ctx.log.debug("Telemetry push task cancelled") + except Exception as e: + ctx.log.error(f"Telemetry push error: {e}") + + +class SensorState: + """Generates simulated sensor telemetry data. + + Uses simple simulation to produce correlated values: + - Sensor value oscillates within the scenario's range + - Rate correlates with value level + - Battery drains at the configured rate + - GPS coordinates drift along a simulated path + - Temperature rises toward a steady state + """ + + def __init__(self, scenario: dict): + self.scenario = scenario + self.t0 = time.time() + self.frame_num = 0 + + # Initial state + value_range = scenario.get("value_range", [30, 70]) + self.value = (value_range[0] + value_range[1]) / 2 + self.rate = scenario.get("rate_range", [100, 500])[0] + self.battery_pct = 85.0 + self.counter = 0 + self.temperature = 25.0 + self.gps_lat = 0.0 + self.gps_lon = 0.0 + self.heading = 0.0 + + def next_frame(self) -> dict: + """Generate the next telemetry frame.""" + dt = 0.1 # ~100ms per frame + elapsed = time.time() - self.t0 + self.frame_num += 1 + + # Value: sinusoidal oscillation within range + value_range = self.scenario.get("value_range", [30, 70]) + value_mid = (value_range[0] + value_range[1]) / 2 + value_amp = (value_range[1] - value_range[0]) / 2 + self.value = value_mid + value_amp * math.sin(elapsed * 0.3) + self.value += random.gauss(0, 0.5) # Jitter + self.value = max(value_range[0], min(value_range[1], self.value)) + + # Rate: correlates with value, with random variation + rate_range = self.scenario.get("rate_range", [100, 500]) + if value_range[1] > value_range[0]: + rate_ratio = (self.value - value_range[0]) / ( + value_range[1] - value_range[0] + ) + else: + rate_ratio = 0.5 + self.rate = rate_range[0] + rate_ratio * (rate_range[1] - rate_range[0]) + self.rate += random.gauss(0, 5) + self.rate = max(0, self.rate) + + # Battery: drain (or recover) over time + drain_rate = self.scenario.get("drain_pct_per_s", 0.015) + self.battery_pct -= drain_rate * dt + self.battery_pct = max(0, min(100, self.battery_pct)) + + # Counter: accumulate based on value + self.counter += self.value * dt + + # Temperature: exponential rise toward steady state + target_temp = 45.0 if self.value > 0 else 25.0 + self.temperature += (target_temp - self.temperature) * 0.01 + + # GPS: drift along heading + speed_ms = self.value * 0.1 + self.gps_lat += ( + math.cos(math.radians(self.heading)) + * speed_ms * dt / 111320 + ) + self.gps_lon += ( + math.sin(math.radians(self.heading)) + * speed_ms * dt + / max(111320 * math.cos(math.radians(self.gps_lat)), 1) + ) + # Gentle heading wander + self.heading += random.gauss(0, 0.2) + self.heading %= 360 + + # State selection based on value + if self.value < 1: + state = "idle" + elif self.value < 30: + state = "low" + elif self.value < 60: + state = "normal" + else: + state = "high" + + # Voltage: correlates with battery + voltage = 3.0 + (self.battery_pct / 100) * 1.2 + + return { + "type": "telemetry", + "frame": self.frame_num, + "timestamp": time.time(), + "sensor_value": round(self.value, 1), + "rate": round(self.rate), + "state": state, + "battery_pct": round(self.battery_pct, 1), + "voltage": round(voltage, 2), + "counter": round(self.counter, 1), + "temperature_c": round(self.temperature, 1), + "gps": { + "lat": round(self.gps_lat, 6), + "lon": round(self.gps_lon, 6), + "heading": round(self.heading, 1), + "altitude_m": 0, + }, + } + + +# Default scenario definitions (used if not in config) +DEFAULT_SCENARIOS = { + "idle": { + "value_range": [0, 0], + "rate_range": [0, 0], + "drain_pct_per_s": 0.001, + }, + "normal": { + "value_range": [30, 70], + "rate_range": [100, 500], + "drain_pct_per_s": 0.015, + }, + "variable": { + "value_range": [0, 55], + "rate_range": [50, 1000], + "drain_pct_per_s": 0.02, + }, + "recovery": { + "value_range": [5, 40], + "rate_range": [0, 200], + "drain_pct_per_s": -0.03, + }, + "peak": { + "value_range": [0, 100], + "rate_range": [500, 2000], + "drain_pct_per_s": 0.05, + }, +} diff --git a/python/packages/jumpstarter-driver-mitmproxy/examples/addons/hls_audio_stream.py b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/hls_audio_stream.py new file mode 100644 index 000000000..2d610f97c --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/hls_audio_stream.py @@ -0,0 +1,246 @@ +""" +Custom addon: HLS audio stream simulation. + +Generates fake HLS (HTTP Live Streaming) playlists and serves +audio segment files from disk, simulating a live audio stream +like internet radio on the DUT. + +Mock config entry:: + + "GET /streaming/audio/channel/*": { + "addon": "hls_audio_stream", + "addon_config": { + "segments_dir": "audio/segments", + "segment_duration_s": 6, + "channels": { + "ch101": {"name": "Classic Rock", "bitrate": 128000}, + "ch202": {"name": "Jazz", "bitrate": 256000} + } + } + } + +File layout:: + + mock-files/ + └── audio/ + └── segments/ + ├── silence_6s_128k.aac ← default fallback segment + ├── tone_6s_128k.aac ← test tone segment + ├── ch101_001.aac ← real content segments + ├── ch101_002.aac + └── ... + +If real segment files aren't available, the addon generates a +minimal silent AAC segment so the client's audio stack still +exercises its full decode/buffer/playback path. + +NOTE: This addon references a configurable files directory for loading +real audio segments. If unavailable, it falls back to generated silence. + +""" + +from __future__ import annotations + +import time +from pathlib import Path + +from mitmproxy import ctx, http + +# Minimal valid AAC-LC frame: 1024 samples of silence at 44100Hz +# This is enough to keep an AAC decoder happy without real content. +SILENT_AAC_FRAME = ( + b"\xff\xf1" # ADTS sync word + MPEG-4, Layer 0 + b"\x50" # AAC-LC, 44100 Hz (idx 4) + b"\x80" # Channel config: 2 (stereo) + b"\x00\x1f" # Frame length (header + padding) + b"\xfc" # Buffer fullness (VBR) + + b"\x00" * 24 # Silent spectral data +) + + +def _generate_silent_segment(duration_s: float = 6.0) -> bytes: + """Generate a silent AAC segment of approximately the given duration. + + Each AAC-LC frame at 44100Hz covers ~23.2ms (1024 samples). + """ + frames_needed = int(duration_s / 0.0232) + return SILENT_AAC_FRAME * frames_needed + + +class Handler: + """HLS audio stream mock handler. + + Serves: + - Master playlist: /streaming/audio/channel/{id}/master.m3u8 + - Media playlist: /streaming/audio/channel/{id}/media.m3u8 + - Segments: /streaming/audio/channel/{id}/seg_{n}.aac + """ + + def __init__(self): + self._sequence_counters: dict[str, int] = {} + + def handle(self, flow: http.HTTPFlow, config: dict) -> bool: + """Route HLS requests to the appropriate handler.""" + path = flow.request.path + files_dir = Path( + config.get("files_dir", "/opt/jumpstarter/mitmproxy/mock-files") + ) + segments_dir = config.get("segments_dir", "audio/segments") + segment_duration = config.get("segment_duration_s", 6) + channels = config.get("channels", { + "default": {"name": "Test Channel", "bitrate": 128000}, + }) + + # Parse channel ID from path + # Expected: /streaming/audio/channel/{channel_id}/... + parts = path.rstrip("/").split("/") + if len(parts) < 5: + return False + + channel_id = parts[4] + resource = parts[5] if len(parts) > 5 else "master.m3u8" + + channel = channels.get(channel_id, { + "name": f"Channel {channel_id}", + "bitrate": 128000, + }) + + if resource == "master.m3u8": + self._serve_master_playlist( + flow, channel_id, channel, + ) + elif resource == "media.m3u8": + self._serve_media_playlist( + flow, channel_id, channel, segment_duration, + ) + elif resource.startswith("seg_") and resource.endswith(".aac"): + self._serve_segment( + flow, channel_id, resource, files_dir, + segments_dir, segment_duration, + ) + else: + return False + + return True + + def _serve_master_playlist( + self, + flow: http.HTTPFlow, + channel_id: str, + channel: dict, + ): + """Serve the HLS master playlist (points to media playlist).""" + bitrate = channel.get("bitrate", 128000) + base = f"/streaming/audio/channel/{channel_id}" + + playlist = ( + "#EXTM3U\n" + "#EXT-X-VERSION:3\n" + f"#EXT-X-STREAM-INF:BANDWIDTH={bitrate}," + f"CODECS=\"mp4a.40.2\",NAME=\"{channel.get('name', channel_id)}\"\n" + f"{base}/media.m3u8\n" + ) + + flow.response = http.Response.make( + 200, + playlist.encode(), + { + "Content-Type": "application/vnd.apple.mpegurl", + "Cache-Control": "no-cache", + }, + ) + ctx.log.info(f"HLS master playlist: {channel_id}") + + def _serve_media_playlist( + self, + flow: http.HTTPFlow, + channel_id: str, + channel: dict, + segment_duration: float, + ): + """Serve a live-style media playlist with a sliding window. + + Generates a playlist with 3 segments, advancing the sequence + number based on wall-clock time to simulate a live stream. + """ + # Calculate current sequence number from wall clock + # This gives a continuously advancing "live" stream + current_time = int(time.time()) + seq_base = current_time // int(segment_duration) + base = f"/streaming/audio/channel/{channel_id}" + + lines = [ + "#EXTM3U", + "#EXT-X-VERSION:3", + f"#EXT-X-TARGETDURATION:{int(segment_duration)}", + f"#EXT-X-MEDIA-SEQUENCE:{seq_base}", + # No #EXT-X-ENDLIST → live stream + ] + + # Sliding window of 3 segments + for i in range(3): + seq = seq_base + i + lines.append(f"#EXTINF:{segment_duration:.1f},") + lines.append(f"{base}/seg_{seq}.aac") + + playlist = "\n".join(lines) + "\n" + + flow.response = http.Response.make( + 200, + playlist.encode(), + { + "Content-Type": "application/vnd.apple.mpegurl", + "Cache-Control": "no-cache, no-store", + }, + ) + ctx.log.info( + f"HLS media playlist: {channel_id} (seq {seq_base})" + ) + + def _serve_segment( + self, + flow: http.HTTPFlow, + channel_id: str, + resource: str, + files_dir: Path, + segments_dir: str, + segment_duration: float, + ): + """Serve an audio segment file. + + Tries to find a real segment file on disk. Falls back to + generated silence if no file exists. This lets you test with + real audio when available, but always have a working stream. + """ + + # Try channel-specific segment + seg_path = files_dir / segments_dir / f"{channel_id}_{resource}" + if not seg_path.exists(): + # Try generic segment + seg_path = files_dir / segments_dir / resource + if not seg_path.exists(): + # Try any segment for the channel + channel_dir = files_dir / segments_dir + if channel_dir.exists(): + candidates = sorted(channel_dir.glob(f"{channel_id}_*.aac")) + if candidates: + # Rotate through available segments + idx = hash(resource) % len(candidates) + seg_path = candidates[idx] + + if seg_path.exists(): + body = seg_path.read_bytes() + ctx.log.debug(f"HLS segment (file): {resource}") + else: + # Generate silent segment as fallback + body = _generate_silent_segment(segment_duration) + ctx.log.debug(f"HLS segment (silence): {resource}") + + flow.response = http.Response.make( + 200, + body, + { + "Content-Type": "audio/aac", + "Cache-Control": "max-age=3600", + }, + ) diff --git a/python/packages/jumpstarter-driver-mitmproxy/examples/addons/mjpeg_stream.py b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/mjpeg_stream.py new file mode 100644 index 000000000..590127866 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/mjpeg_stream.py @@ -0,0 +1,313 @@ +""" +Custom addon: MJPEG video stream simulation. + +Serves a multipart MJPEG stream that simulates an IP camera feed. +The client's video player receives a continuous stream of JPEG frames +over HTTP, just like a real IP camera endpoint. + +Mock config entry:: + + "GET /streaming/video/camera/*": { + "addon": "mjpeg_stream", + "addon_config": { + "frames_dir": "video/frames", + "fps": 15, + "default_resolution": [640, 480], + "cameras": { + "rear": {"frames_dir": "video/rear"}, + "surround": {"frames_dir": "video/surround"} + } + } + } + +File layout:: + + mock-files/ + └── video/ + ├── frames/ ← default fallback frames + │ ├── frame_000.jpg + │ ├── frame_001.jpg + │ └── ... + ├── rear/ ← camera-specific frames + │ ├── frame_000.jpg + │ └── ... + └── test_pattern.jpg ← single-image fallback + +If no frame files exist, the addon generates a minimal JPEG test +pattern so the client's video pipeline still exercises its +full decode/render path. +""" + +from __future__ import annotations + +import io +import time +from pathlib import Path + +from mitmproxy import ctx, http + +# ── Minimal JPEG generator (no PIL needed) ────────────────── + +# A tiny valid JPEG: 8x8 pixels, solid gray. +# This is the smallest valid JFIF file that any decoder will accept. +MINIMAL_JPEG = bytes([ + 0xFF, 0xD8, 0xFF, 0xE0, # SOI + APP0 + 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, # JFIF header + 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, + 0xFF, 0xDB, 0x00, 0x43, 0x00, # DQT + *([0x08] * 64), # Quantization table (uniform) + 0xFF, 0xC0, 0x00, 0x0B, # SOF0 + 0x08, # 8-bit precision + 0x00, 0x08, 0x00, 0x08, # 8x8 pixels + 0x01, # 1 component (grayscale) + 0x01, 0x11, 0x00, # Component: ID=1, sampling=1x1, quant=0 + 0xFF, 0xC4, 0x00, 0x1F, 0x00, # DHT (DC) + 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, + 0xFF, 0xC4, 0x00, 0xB5, 0x10, # DHT (AC) + 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, + 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, + *([0x00] * 162), + 0xFF, 0xDA, 0x00, 0x08, # SOS + 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, + # Compressed data: single MCU, gray + 0x7B, 0x40, + 0xFF, 0xD9, # EOI +]) + + +def _generate_test_pattern_jpeg( + width: int = 640, height: int = 480, frame_num: int = 0, +) -> bytes: + """Generate a JPEG test pattern. + + If Pillow is available, generates a proper test pattern with + frame number overlay. Otherwise, returns the minimal JPEG. + """ + try: + from PIL import Image, ImageDraw, ImageFont + + img = Image.new("RGB", (width, height), color=(40, 40, 40)) + draw = ImageDraw.Draw(img) + + # Grid lines + for x in range(0, width, 80): + draw.line([(x, 0), (x, height)], fill=(80, 80, 80)) + for y in range(0, height, 80): + draw.line([(0, y), (width, y)], fill=(80, 80, 80)) + + # Color bars at top + bar_w = width // 7 + colors = [ + (255, 255, 255), (255, 255, 0), (0, 255, 255), + (0, 255, 0), (255, 0, 255), (255, 0, 0), (0, 0, 255), + ] + for i, color in enumerate(colors): + draw.rectangle( + [i * bar_w, 0, (i + 1) * bar_w, height // 4], + fill=color, + ) + + # Frame counter and timestamp + timestamp = time.strftime("%H:%M:%S") + try: + font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", 24) + except (OSError, IOError): + font = ImageFont.load_default() + + text = f"MOCK CAMERA Frame: {frame_num:06d} {timestamp}" + draw.text( + (20, height // 2 - 12), text, + fill=(0, 255, 0), font=font, + ) + + # Moving element (proves the stream is updating) + x_pos = (frame_num * 5) % width + draw.ellipse( + [x_pos - 15, height - 60, x_pos + 15, height - 30], + fill=(255, 0, 0), + ) + + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=75) + return buf.getvalue() + + except ImportError: + # Pillow not available, use minimal JPEG + return MINIMAL_JPEG + + +# ── MJPEG streaming handler ───────────────────────────────── + + +class Handler: + """MJPEG video stream mock handler. + + Serves: + - Stream: /streaming/video/camera/{camera_id}/stream.mjpeg + - Snapshot: /streaming/video/camera/{camera_id}/snapshot.jpg + + For the stream endpoint, mitmproxy's response streaming is used + to deliver frames continuously without buffering the entire + response. The stream uses multipart/x-mixed-replace, which is + the standard MJPEG-over-HTTP format supported by most embedded + video players. + """ + + def __init__(self): + self._frame_counters: dict[str, int] = {} + + def handle(self, flow: http.HTTPFlow, config: dict) -> bool: + """Route video requests to the appropriate handler.""" + path = flow.request.path + parts = path.rstrip("/").split("/") + + # Expected: /streaming/video/camera/{camera_id}/{resource} + if len(parts) < 6: + return False + + camera_id = parts[4] + resource = parts[5] + + cameras = config.get("cameras", {}) + camera_config = cameras.get(camera_id, {}) + fps = config.get("fps", 15) + resolution = config.get("default_resolution", [640, 480]) + frames_dir = camera_config.get( + "frames_dir", config.get("frames_dir", "video/frames"), + ) + files_dir = Path( + config.get("files_dir", "/opt/jumpstarter/mitmproxy/mock-files") + ) + + if resource == "snapshot.jpg": + self._serve_snapshot( + flow, camera_id, frames_dir, files_dir, resolution, + ) + return True + + elif resource == "stream.mjpeg": + self._serve_mjpeg_stream( + flow, camera_id, frames_dir, files_dir, resolution, fps, + ) + return True + + return False + + def _serve_snapshot( + self, + flow: http.HTTPFlow, + camera_id: str, + frames_dir: str, + files_dir: Path, + resolution: list[int], + ): + """Serve a single JPEG snapshot.""" + frame = self._get_frame(camera_id, frames_dir, files_dir, resolution) + + flow.response = http.Response.make( + 200, + frame, + { + "Content-Type": "image/jpeg", + "Cache-Control": "no-cache", + }, + ) + ctx.log.info(f"Camera snapshot: {camera_id}") + + def _serve_mjpeg_stream( + self, + flow: http.HTTPFlow, + camera_id: str, + frames_dir: str, + files_dir: Path, + resolution: list[int], + fps: int, + ): + """Serve a multipart MJPEG stream. + + This uses mitmproxy's chunked response to deliver a + continuous stream of JPEG frames. The client's video + player will receive: + + --frame + Content-Type: image/jpeg + Content-Length: {size} + + + + ...repeating for each frame. + + NOTE: mitmproxy buffers the full response by default. + For true streaming, this generates a limited burst of + frames (e.g., 5 seconds worth). For continuous streaming + in production, consider using the responseheaders hook + with flow.response.stream = True, or run a dedicated + MJPEG server alongside mitmproxy. + """ + boundary = "frame" + burst_duration_s = 10 # Generate 10 seconds of frames + num_frames = int(burst_duration_s * fps) + + parts = [] + for _ in range(num_frames): + frame = self._get_frame( + camera_id, frames_dir, files_dir, resolution, + ) + parts.append( + f"--{boundary}\r\n" + f"Content-Type: image/jpeg\r\n" + f"Content-Length: {len(frame)}\r\n" + f"\r\n".encode() + + frame + + b"\r\n" + ) + + body = b"".join(parts) + body += f"--{boundary}--\r\n".encode() + + flow.response = http.Response.make( + 200, + body, + { + "Content-Type": f"multipart/x-mixed-replace; boundary={boundary}", + "Cache-Control": "no-cache, no-store", + "Connection": "keep-alive", + }, + ) + ctx.log.info( + f"MJPEG stream: {camera_id} " + f"({num_frames} frames, {fps} fps)" + ) + + def _get_frame( + self, + camera_id: str, + frames_dir: str, + files_dir: Path, + resolution: list[int], + ) -> bytes: + """Get the next frame for a camera. + + Tries to load from disk, cycling through available files. + Falls back to generated test pattern. + """ + counter = self._frame_counters.get(camera_id, 0) + self._frame_counters[camera_id] = counter + 1 + + # Try loading from files directory + frame_dir = files_dir / frames_dir + + if frame_dir.exists(): + frames = sorted(frame_dir.glob("*.jpg")) + if not frames: + frames = sorted(frame_dir.glob("*.jpeg")) + if frames: + frame_path = frames[counter % len(frames)] + return frame_path.read_bytes() + + # Generate test pattern + return _generate_test_pattern_jpeg( + resolution[0], resolution[1], counter, + ) diff --git a/python/packages/jumpstarter-driver-mitmproxy/examples/conftest.py b/python/packages/jumpstarter-driver-mitmproxy/examples/conftest.py new file mode 100644 index 000000000..a8d90dbd4 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/conftest.py @@ -0,0 +1,132 @@ +""" +pytest fixtures for DUT HiL tests with mitmproxy. + +These fixtures integrate the mitmproxy Jumpstarter driver into +your test workflow. The proxy client is available as ``client.proxy`` +when using Jumpstarter's pytest plugin. + +Usage: + jmp start --exporter my-bench -- pytest tests/ -v +""" + +from __future__ import annotations + +import pytest +from jumpstarter_driver_mitmproxy.client import MitmproxyClient + +# -- Proxy session fixtures -------------------------------------------------- + + +@pytest.fixture(scope="session") +def proxy_session(client): + """Start proxy for the entire test session. + + Uses mock mode with the web UI enabled so engineers can + inspect traffic in the browser during test development. + """ + proxy: MitmproxyClient = client.proxy + proxy.start(mode="mock", web_ui=True) + + # Print the web UI URL for interactive debugging + info = proxy.status() + if info.get("web_ui_address"): + print(f"\n>>> mitmweb UI: {info['web_ui_address']}") + + yield proxy + proxy.stop() + + +@pytest.fixture +def proxy(proxy_session): + """Per-test proxy fixture that clears mocks between tests. + + Inherits the session-scoped proxy but ensures each test + starts with a clean mock slate. + """ + proxy_session.clear_mocks() + yield proxy_session + proxy_session.clear_mocks() + + +# -- Scenario fixtures ------------------------------------------------------- + + +@pytest.fixture +def mock_device_status(proxy): + """Mock the device status endpoint with standard test data.""" + with proxy.mock_endpoint( + "GET", "/api/v1/status", + body={ + "id": "device-001", + "status": "active", + "uptime_s": 86400, + "battery_pct": 85, + "firmware_version": "2.5.1", + "last_updated": "2026-02-13T10:00:00Z", + }, + ): + yield + + +@pytest.fixture +def mock_update_available(proxy): + """Mock the update endpoint to report an available update.""" + with proxy.mock_endpoint( + "GET", "/api/v1/updates/check", + body={ + "update_available": True, + "current_version": "2.5.1", + "latest_version": "2.6.0", + "download_url": "https://updates.example.com/v2.6.0.bin", + "release_notes": "Bug fixes and performance improvements", + "size_bytes": 524288000, + }, + ): + yield + + +@pytest.fixture +def mock_up_to_date(proxy): + """Mock the update endpoint to report system is up to date.""" + with proxy.mock_endpoint( + "GET", "/api/v1/updates/check", + body={ + "update_available": False, + "current_version": "2.5.1", + "latest_version": "2.5.1", + }, + ): + yield + + +@pytest.fixture +def mock_backend_down(proxy): + """Simulate all backend services returning 503.""" + with proxy.mock_endpoint( + "GET", "/api/v1/*", + status=503, + body={"error": "Service Unavailable"}, + ): + yield + + +@pytest.fixture +def mock_slow_backend(proxy): + """Simulate a gateway timeout (504) from the backend.""" + with proxy.mock_endpoint( + "GET", "/api/v1/*", + status=504, + body={"error": "Gateway Timeout"}, + ): + yield + + +@pytest.fixture +def mock_auth_expired(proxy): + """Simulate expired authentication token.""" + with proxy.mock_endpoint( + "GET", "/api/v1/*", + status=401, + body={"error": "Token expired", "code": "AUTH_EXPIRED"}, + ): + yield diff --git a/python/packages/jumpstarter-driver-mitmproxy/examples/exporter.yaml b/python/packages/jumpstarter-driver-mitmproxy/examples/exporter.yaml new file mode 100644 index 000000000..9e2870bc5 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/exporter.yaml @@ -0,0 +1,78 @@ +# Example Jumpstarter exporter configuration with mitmproxy integration. +# +# Install to: /etc/jumpstarter/exporters/my-bench.yaml +# +# This config exposes: +# - DUT Link for power control and storage mux +# - Serial console for shell access +# - Video capture for screenshot-based assertions +# - mitmproxy for HTTP(S) interception and backend mocking +# +# Usage: +# jmp exporter start my-bench +# jmp shell --exporter my-bench +# +# CLI commands available during a shell session (j proxy ): +# j proxy start [-m mock] [-w] # start the proxy +# j proxy stop # stop the proxy +# j proxy restart [-m passthrough] # restart with new mode +# j proxy status # show proxy status +# j proxy mock load happy.yaml # load mock scenario +# j proxy mock list # list configured mocks +# j proxy mock clear # remove all mocks +# j proxy flow list # list recorded flow files +# j proxy capture list # show captured requests +# j proxy capture clear # clear captured requests +# j proxy web [--port 9090] # forward mitmweb UI +# j proxy cert [output.pem] # download CA certificate + +export: + # -- Hardware interfaces --------------------------------------------------- + + dutlink: + type: jumpstarter_driver_dutlink.driver.Dutlink + config: {} + + serial: + type: jumpstarter_driver_pyserial.driver.PySerial + config: + url: "/dev/serial/by-id/usb-FTDI_debug_console-if00-port0" + baudrate: 115200 + + video: + type: jumpstarter_driver_ustreamer.driver.UStreamer + config: + args: + device: "/dev/video0" + + # -- Network proxy / mocking ----------------------------------------------- + + proxy: + type: jumpstarter_driver_mitmproxy.driver.MitmproxyDriver + config: + # Proxy listener (DUT connects here) + listen: + host: "0.0.0.0" + port: 8080 + + # Web UI (mitmweb) + web: + host: "0.0.0.0" + port: 8081 + + # Directory layout (subdirs default to {data}/) + directories: + data: /opt/jumpstarter/mitmproxy + conf: /etc/mitmproxy # override default {data}/conf + + # Skip upstream SSL verification (DUT talks to test infra) + ssl_insecure: true + + # Auto-load a scenario file on startup (relative to mocks dir) + # mock_scenario: happy-path.yaml + + # Inline mock definitions (loaded at startup, overlaid on scenario) + # mocks: + # GET /api/v1/health: + # status: 200 + # body: {ok: true} diff --git a/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/backend-degraded.yaml b/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/backend-degraded.yaml new file mode 100644 index 000000000..519595bd2 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/backend-degraded.yaml @@ -0,0 +1,56 @@ +# Backend-degraded scenario: simulates flaky and failing backend services. +# Tests the DUT's error handling, retry logic, and graceful degradation. + +config: + files_dir: /opt/jumpstarter/mitmproxy/mock-files + addons_dir: /opt/jumpstarter/mitmproxy/addons + default_latency_ms: 0 + +endpoints: + # Auth works 3 times, then starts failing (expired cert simulation) + GET /api/v1/auth/token: + sequence: + - status: 200 + body: {token: mock-token-001, expires_in: 3600} + repeat: 3 + - status: 401 + body: {error: Certificate expired, code: CERT_EXPIRED} + repeat: 2 + - status: 200 + body: {token: mock-token-002, expires_in: 3600} + + # Intermittent 503s with increasing latency + GET /api/v1/status: + sequence: + - status: 200 + body: {id: device-001, status: active} + latency_ms: 100 + repeat: 2 + - status: 503 + body: {error: Service temporarily unavailable} + latency_ms: 3000 + repeat: 1 + - status: 504 + body: {error: Gateway timeout} + latency_ms: 5000 + repeat: 1 + - status: 200 + body: {id: device-001, status: active} + latency_ms: 200 + + # All telemetry uploads time out -- tests retry logic + POST /api/v1/telemetry/upload: + status: 503 + body: {error: Backend overloaded} + latency_ms: 8000 + + # Update service is completely down + GET /api/v1/updates/check: + status: 500 + body: {error: Internal server error} + + # Search works but is very slow + GET /api/v1/search*: + status: 200 + body: {results: []} + latency_ms: 4000 diff --git a/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/full-scenario.yaml b/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/full-scenario.yaml new file mode 100644 index 000000000..13d321ded --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/full-scenario.yaml @@ -0,0 +1,118 @@ +# jumpstarter-driver-mitmproxy mock configuration v2 +# +# This file defines how the proxy responds to requests from the DUT. +# Place in your mock_dir (default: /opt/jumpstarter/mitmproxy/mock-responses/). + +config: + files_dir: /opt/jumpstarter/mitmproxy/mock-files + addons_dir: /opt/jumpstarter/mitmproxy/addons + default_latency_ms: 0 + default_content_type: application/json + +endpoints: + GET /api/v1/status: + status: 200 + body: + id: device-001 + status: active + uptime_s: 86400 + battery_pct: 85 + firmware_version: "2.5.1" + last_updated: "2026-02-13T10:00:00Z" + + # Simulate realistic command processing delay + POST /api/v1/command: + status: 200 + body: + command_id: cmd-mock-001 + status: accepted + estimated_completion_s: 15 + latency_ms: 800 + + # Wildcard: matches any path starting with /api/v1/search + GET /api/v1/search*: + status: 200 + body: + results: + - name: Test Location A + lat: 0.0 + lon: 0.0 + - name: Test Location B + lat: 0.001 + lon: 0.001 + + GET /api/v1/updates/check: + status: 200 + body: + update_available: false + current_version: "2.5.1" + latest_version: "2.5.1" + + # Serve a real binary file from disk + GET /api/v1/updates/download/v2.6.0.bin: + status: 200 + file: firmware/v2.6.0-test.bin + content_type: application/octet-stream + headers: + Content-Disposition: 'attachment; filename="v2.6.0.bin"' + + # Serve image files with pattern matching + GET /api/v1/media/album-art*: + status: 200 + file: images/default-album-art.jpg + content_type: image/jpeg + + # Serve a JSON file directly from disk + GET /api/v1/config/theme.json: + status: 200 + file: config/theme.json + content_type: application/json + + # Only matches if Content-Type header is present + POST /api/v1/telemetry/upload: + status: 202 + body: {accepted: true} + match: + headers: + Content-Type: application/json + + # Second rule for same path: missing content type -> 400 + POST /api/v1/telemetry/upload#reject: + status: 400 + body: {error: Content-Type required} + match: + headers_absent: [Content-Type] + priority: 10 + + # Template expressions are evaluated per-request for dynamic data + GET /api/v1/weather/current: + status: 200 + body_template: + temperature_f: "{{random_int(60, 95)}}" + condition: "{{random_choice('sunny', 'cloudy', 'rain', 'snow')}}" + humidity_pct: "{{random_int(30, 90)}}" + timestamp: "{{now_iso}}" + + # Stateful: first 3 requests succeed, 4th fails, then recovers + GET /api/v1/auth/token: + sequence: + - status: 200 + body: {token: mock-token-001, expires_in: 3600} + repeat: 3 + - status: 401 + body: {error: Token expired} + repeat: 1 + - status: 200 + body: {token: mock-token-002, expires_in: 3600} + + # Delegate to a custom addon script for HLS audio simulation + GET /streaming/audio/channel/*: + addon: hls_audio_stream + + # Delegate to a custom addon for MJPEG video streaming + GET /streaming/video/camera/*: + addon: mjpeg_stream + + # Delegate WebSocket connections to a custom addon + WEBSOCKET /api/v1/data/realtime: + addon: data_stream_websocket diff --git a/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/happy-path.yaml b/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/happy-path.yaml new file mode 100644 index 000000000..91967231f --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/happy-path.yaml @@ -0,0 +1,48 @@ +# Happy-path scenario: all endpoints return successful responses. +# Use this as the base scenario for normal DUT operation. + +config: + files_dir: /opt/jumpstarter/mitmproxy/mock-files + addons_dir: /opt/jumpstarter/mitmproxy/addons + default_latency_ms: 50 + +endpoints: + GET /api/v1/status: + status: 200 + body: + id: device-001 + status: active + uptime_s: 86400 + battery_pct: 85 + firmware_version: "2.5.1" + last_updated: "2026-02-13T10:00:00Z" + + GET /api/v1/updates/check: + status: 200 + body: + update_available: false + current_version: "2.5.1" + latest_version: "2.5.1" + + GET /api/v1/auth/token: + status: 200 + body: + token: mock-token-valid + expires_in: 3600 + + POST /api/v1/telemetry/upload: + status: 202 + body: + accepted: true + + GET /api/v1/weather/current: + status: 200 + body: + temperature_f: 72 + condition: sunny + humidity_pct: 45 + + GET /api/v1/search*: + status: 200 + body: + results: [] diff --git a/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/media-streaming.yaml b/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/media-streaming.yaml new file mode 100644 index 000000000..299a5bc32 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/media-streaming.yaml @@ -0,0 +1,53 @@ +# Media-streaming scenario: audio, video, and WebSocket streaming endpoints. +# Tests the DUT's media playback and real-time data capabilities. + +config: + files_dir: /opt/jumpstarter/mitmproxy/mock-files + addons_dir: /opt/jumpstarter/mitmproxy/addons + +endpoints: + GET /api/v1/status: + status: 200 + body: {id: device-001, status: active} + + GET /streaming/audio/channel/*: + addon: hls_audio_stream + addon_config: + segments_dir: audio/segments + segment_duration_s: 6 + channels: + ch101: {name: Classic Rock, bitrate: 128000} + ch202: {name: Jazz Classics, bitrate: 256000} + ch305: {name: News Talk, bitrate: 64000} + + GET /streaming/video/camera/*: + addon: mjpeg_stream + addon_config: + frames_dir: video/frames + fps: 15 + default_resolution: [640, 480] + cameras: + rear: {frames_dir: video/rear} + front: {frames_dir: video/front} + surround: {frames_dir: video/surround} + + WEBSOCKET /api/v1/data/realtime: + addon: data_stream_websocket + addon_config: + push_interval_ms: 100 + scenario: normal + + GET /api/v1/media/album-art/*: + status: 200 + file: images/default-album-art.jpg + content_type: image/jpeg + + GET /api/v1/media/now-playing: + status: 200 + body_template: + title: "{{random_choice('Hotel California', 'Bohemian Rhapsody', 'Take Five')}}" + artist: "{{random_choice('Eagles', 'Queen', 'Dave Brubeck')}}" + channel: ch101 + position_s: "{{random_int(0, 300)}}" + duration_s: 300 + album_art_url: /api/v1/media/album-art/current.jpg diff --git a/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/update-available.yaml b/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/update-available.yaml new file mode 100644 index 000000000..62790c7ba --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/update-available.yaml @@ -0,0 +1,38 @@ +# Update-available scenario: the update endpoint reports a new firmware version. +# Tests the DUT's OTA update detection and download flow. + +config: + files_dir: /opt/jumpstarter/mitmproxy/mock-files + addons_dir: /opt/jumpstarter/mitmproxy/addons + +endpoints: + GET /api/v1/updates/check: + status: 200 + body: + update_available: true + current_version: "2.5.1" + latest_version: "2.6.0" + release_notes: Bug fixes and performance improvements + size_bytes: 524288000 + checksum_sha256: abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 + + # Serve a real test firmware binary from disk + GET /api/v1/updates/download/v2.6.0.bin: + status: 200 + file: firmware/v2.6.0-test.bin + content_type: application/octet-stream + headers: + Content-Disposition: 'attachment; filename="v2.6.0.bin"' + X-Checksum-SHA256: abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 + + POST /api/v1/updates/report-status: + status: 200 + body: + acknowledged: true + + GET /api/v1/status: + status: 200 + body: + id: device-001 + status: active + battery_pct: 85 diff --git a/python/packages/jumpstarter-driver-mitmproxy/examples/test_device.py b/python/packages/jumpstarter-driver-mitmproxy/examples/test_device.py new file mode 100644 index 000000000..effaa26ce --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/test_device.py @@ -0,0 +1,161 @@ +""" +Example HiL tests for DUT connected services. + +Demonstrates how to use the mitmproxy Jumpstarter driver to mock +backend APIs and verify DUT behavior under different conditions. + +Run with: + jmp start --exporter my-bench -- pytest tests/ -v +""" + +from __future__ import annotations + +import time + + +class TestDeviceStatusDisplay: + """Verify the DUT displays status information correctly.""" + + def test_shows_device_info(self, client, proxy, mock_device_status): + """DUT should display device status from the API.""" + # Interact with DUT to navigate to the status screen. + # Replace with your device-specific interaction (serial, adb, etc.) + serial = client.serial + serial.write(b"open-status-screen\n") + time.sleep(5) + + # Capture screenshot for verification + screenshot = client.video.snapshot() + assert screenshot is not None + # TODO: Use jumpstarter-imagehash or OCR to verify display content + + def test_handles_backend_503(self, client, proxy, mock_backend_down): + """DUT should show a graceful error when backend is down.""" + serial = client.serial + serial.write(b"open-status-screen\n") + time.sleep(5) + + screenshot = client.video.snapshot() + assert screenshot is not None + # Verify retry/error UI is shown instead of a crash + + def test_handles_timeout(self, client, proxy, mock_slow_backend): + """DUT should handle gateway timeouts gracefully.""" + serial = client.serial + serial.write(b"open-status-screen\n") + time.sleep(10) # Longer wait for timeout handling + + screenshot = client.video.snapshot() + assert screenshot is not None + + def test_handles_auth_expiry(self, client, proxy, mock_auth_expired): + """DUT should prompt re-authentication on 401.""" + serial = client.serial + serial.write(b"open-status-screen\n") + time.sleep(5) + + screenshot = client.video.snapshot() + assert screenshot is not None + # Verify login/re-auth prompt is shown + + +class TestFirmwareUpdate: + """Verify the firmware update flow with mocked backend.""" + + def test_update_notification_shown( + self, client, proxy, mock_update_available, + ): + """DUT should notify user when an update is available.""" + serial = client.serial + serial.write(b"check-for-update\n") + time.sleep(10) + + screenshot = client.video.snapshot() + assert screenshot is not None + # Verify update notification dialog is visible + + def test_no_update_message( + self, client, proxy, mock_up_to_date, + ): + """DUT should show 'up to date' when no update exists.""" + serial = client.serial + serial.write(b"check-for-update\n") + time.sleep(10) + + screenshot = client.video.snapshot() + assert screenshot is not None + + +class TestDynamicMocking: + """Demonstrate runtime mock configuration within a test.""" + + def test_mock_then_unmock(self, client, proxy): + """Show how to set and remove mocks within a single test.""" + # Start with a healthy response + proxy.set_mock( + "GET", "/api/v1/status", + body={"status": "active", "battery_pct": 85}, + ) + + serial = client.serial + serial.write(b"open-status-screen\n") + time.sleep(5) + healthy_screenshot = client.video.snapshot() + + # Now simulate a failure + proxy.set_mock( + "GET", "/api/v1/status", + status=500, + body={"error": "Internal Server Error"}, + ) + + # Trigger a refresh + serial.write(b"refresh\n") + time.sleep(5) + error_screenshot = client.video.snapshot() + + # Remove the mock to restore passthrough + proxy.remove_mock("GET", "/api/v1/status") + + assert healthy_screenshot is not None + assert error_screenshot is not None + + def test_load_full_scenario(self, client, proxy): + """Load a complete mock scenario from a JSON file.""" + with proxy.mock_scenario("happy-path.json"): + serial = client.serial + serial.write(b"open-dashboard\n") + time.sleep(5) + screenshot = client.video.snapshot() + assert screenshot is not None + + +class TestTrafficRecording: + """Demonstrate recording and replaying DUT traffic.""" + + def test_record_golden_session(self, client, proxy): + """Record a session for later replay in CI.""" + with proxy.recording() as p: + serial = client.serial + + # Walk through a standard user flow + serial.write(b"open-status-screen\n") + time.sleep(5) + serial.write(b"go-back\n") + time.sleep(2) + + # Verify recording was saved + files = p.list_flow_files() + assert len(files) > 0 + print(f"Recorded to: {files[-1]['name']}") + + +class TestWebUIAccess: + """Verify the mitmweb UI is accessible for debugging.""" + + def test_web_ui_url_available(self, proxy): + """When started with web_ui=True, URL should be available.""" + url = proxy.web_ui_url + assert url is not None + assert ":8081" in url or ":18081" in url + print(f"\n>>> Open {url} in your browser to inspect traffic") diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/__init__.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/__init__.py new file mode 100644 index 000000000..7011bc90d --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/__init__.py @@ -0,0 +1 @@ +"""Jumpstarter driver for mitmproxy HTTP(S) interception and mocking.""" diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py new file mode 100644 index 000000000..8fddec8c6 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py @@ -0,0 +1,1234 @@ +""" +Enhanced mitmproxy addon for DUT backend mocking. + +Supports the v2 mock configuration format: + - JSON body responses + - File-based responses (binary, images, firmware, etc.) + - Response templates with dynamic expressions + - Stateful response sequences + - Request matching (headers, query params) + - Simulated latency + - Delegation to custom addon scripts (streaming, WebSocket, etc.) + +Loaded by mitmdump/mitmweb via: + mitmdump -s mock_addon.py + +Configuration is read from: + {mock_dir}/endpoints.json (v1 flat format) + {mock_dir}/*.json (v2 format with "endpoints" key) + +The addon hot-reloads config when the file changes on disk. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import importlib +import importlib.util +import json +import os +import random +import re +import socket as _socket +import time +from collections import defaultdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, urlparse + +from mitmproxy import ctx, http + +# ── Helpers ────────────────────────────────────────────────── + + +def _resolve_dotted_path(obj, path: str): + """Traverse a dotted path into a nested dict/list structure. + + Returns None if any segment is missing or the structure + doesn't support indexing. + + Examples:: + + _resolve_dotted_path({"a": {"b": 1}}, "a.b") # → 1 + _resolve_dotted_path({"x": [10, 20]}, "x.1") # → 20 + """ + for part in path.split("."): + if isinstance(obj, dict): + obj = obj.get(part) + elif isinstance(obj, list): + try: + obj = obj[int(part)] + except (ValueError, IndexError): + return None + else: + return None + return obj + + +_ARRAY_KEY_RE = re.compile(r'^(.+)\[(\d+)\]$') + + +def _deep_merge_patch(target, patch): + """Deep-merge a patch dict into a target dict/list in-place. + + - Dict patch values recurse into the matching target key. + - Keys with ``[N]`` suffix target array elements: ``"modules[0]"`` + navigates to ``target["modules"][0]``. Missing arrays and + out-of-range indices are auto-created (filled with empty dicts). + - Scalar/list patch values replace the target value. + - Errors on individual keys are logged and skipped so that one + failing key does not prevent remaining keys from being applied. + """ + for key, value in patch.items(): + try: + m = _ARRAY_KEY_RE.match(key) + if m: + base_key, index = m.group(1), int(m.group(2)) + # Create array if missing + if base_key not in target: + target[base_key] = [] + array = target[base_key] + # Extend array if index is out of range + while len(array) <= index: + array.append({}) + if isinstance(value, dict): + _deep_merge_patch(array[index], value) + else: + array[index] = value + elif isinstance(value, dict) and isinstance(target.get(key), dict): + _deep_merge_patch(target[key], value) + else: + target[key] = value + except (KeyError, IndexError, TypeError) as e: + try: + ctx.log.warn(f"Patch merge error on key {key!r}: {e}") + except AttributeError: + pass # ctx.log not available outside mitmproxy + + +def _apply_patches(body_bytes, patches, flow, state): + """Parse JSON body, render templates in patch values, deep-merge, re-serialize. + + Returns patched body as bytes, or None if the body is not valid JSON. + """ + try: + body = json.loads(body_bytes) + except (json.JSONDecodeError, UnicodeDecodeError): + return None + + rendered = TemplateEngine.render(patches, flow, state) + try: + _deep_merge_patch(body, rendered) + except (KeyError, IndexError, TypeError) as e: + try: + ctx.log.warn(f"Patch merge error (continuing with partial patch): {e}") + except AttributeError: + pass # ctx.log not available outside mitmproxy + + return json.dumps(body).encode() + + +# ── Template engine (lightweight, no dependencies) ────────── + + +class TemplateEngine: + """Simple template expression evaluator for dynamic mock bodies. + + Supported expressions: + {{now_iso}} → Current ISO 8601 timestamp + {{now_epoch}} → Current Unix timestamp + {{random_int(min, max)}} → Random integer in range + {{random_float(min, max)}} → Random float in range + {{random_choice(a, b, c)}} → Random selection from list + {{uuid}} → Random UUID v4 + {{counter(name)}} → Auto-incrementing counter + {{env(VAR_NAME)}} → Environment variable (allowlisted only) + {{request_path}} → The matched request path + {{request_header(name)}} → Value of a request header + {{request_body}} → Raw request body text + {{request_body_json(key)}} → JSON field from request body + {{request_query(param)}} → Query parameter value + {{request_path_segment(idx)}} → URL path segment by index + {{state(key)}} → Value from shared state store + {{state(key, default)}} → Value with fallback default + """ + + _counters: dict[str, int] = defaultdict(int) + + # Only these environment variables may be read via {{env(...)}} templates. + # This prevents mock configs from leaking secrets such as credentials or + # API keys. Extend this set when new env-driven behaviour is needed. + ALLOWED_ENV_VARS: set[str] = { + "JUMPSTARTER_ENV", + "JUMPSTARTER_DEVICE_ID", + "JUMPSTARTER_MOCK_PROFILE", + "JUMPSTARTER_REGION", + "NODE_ENV", + "MOCK_SCENARIO", + } + _pattern = re.compile(r"\{\{(.+?)\}\}") + + @classmethod + def render( + cls, + template: Any, + flow: http.HTTPFlow | None = None, + state: dict | None = None, + ) -> Any: + """Recursively render template expressions in a value.""" + if isinstance(template, str): + return cls._render_string(template, flow, state) + elif isinstance(template, dict): + return {k: cls.render(v, flow, state) for k, v in template.items()} + elif isinstance(template, list): + return [cls.render(v, flow, state) for v in template] + return template + + @classmethod + def _render_string( + cls, + s: str, + flow: http.HTTPFlow | None, + state: dict | None = None, + ) -> Any: + """Render a single string, resolving all {{...}} expressions.""" + # If the entire string is one expression, return native type + match = cls._pattern.fullmatch(s.strip()) + if match: + return cls._evaluate(match.group(1).strip(), flow, state) + + # Otherwise, substitute within the string + def replacer(m): + result = cls._evaluate(m.group(1).strip(), flow, state) + return str(result) + + return cls._pattern.sub(replacer, s) + + @classmethod + def _evaluate( + cls, + expr: str, + flow: http.HTTPFlow | None, + state: dict | None = None, + ) -> Any: + """Evaluate a single template expression.""" + result = cls._evaluate_builtin(expr) + if result is not None: + return result + + result = cls._evaluate_flow(expr, flow) + if result is not None: + return result + + if expr.startswith("state("): + return cls._evaluate_state(expr, state) + + ctx.log.warn(f"Unknown template expression: {{{{{expr}}}}}") + return f"{{{{{expr}}}}}" + + @classmethod + def _evaluate_builtin(cls, expr: str) -> Any | None: + """Evaluate built-in expressions (no flow needed).""" + if expr == "now_iso": + return datetime.now(timezone.utc).isoformat() + if expr == "now_epoch": + return int(time.time()) + if expr == "uuid": + import uuid + return str(uuid.uuid4()) + if expr.startswith("random_int("): + args = cls._parse_args(expr) + return random.randint(int(args[0]), int(args[1])) + if expr.startswith("random_float("): + args = cls._parse_args(expr) + return round(random.uniform(float(args[0]), float(args[1])), 2) + if expr.startswith("random_choice("): + args = cls._parse_args(expr) + return random.choice(args) + if expr.startswith("counter("): + args = cls._parse_args(expr) + name = args[0] + cls._counters[name] += 1 + return cls._counters[name] + if expr.startswith("env("): + args = cls._parse_args(expr) + var_name = args[0] + if var_name in cls.ALLOWED_ENV_VARS: + ctx.log.warn(f"env() template used: allowed variable '{var_name}'") + return os.environ.get(var_name, "") + ctx.log.warn(f"env() template blocked: variable '{var_name}' is not in ALLOWED_ENV_VARS") + return "" + return None + + @classmethod + def _evaluate_flow( + cls, expr: str, flow: http.HTTPFlow | None, + ) -> Any | None: + """Evaluate flow-dependent expressions.""" + if flow is None: + return None + if expr == "request_path": + return flow.request.path + if expr.startswith("request_header("): + args = cls._parse_args(expr) + return flow.request.headers.get(args[0], "") + if expr == "request_body": + return flow.request.get_text() or "" + if expr.startswith("request_body_json("): + args = cls._parse_args(expr) + try: + body_obj = json.loads(flow.request.get_text() or "{}") + except json.JSONDecodeError: + return None + return _resolve_dotted_path(body_obj, args[0]) + if expr.startswith("request_query("): + args = cls._parse_args(expr) + return flow.request.query.get(args[0], "") + if expr.startswith("request_path_segment("): + args = cls._parse_args(expr) + segments = [s for s in flow.request.path.split("/") if s] + try: + return segments[int(args[0])] + except (IndexError, ValueError): + return "" + return None + + @classmethod + def _evaluate_state( + cls, expr: str, state: dict | None, + ) -> Any: + """Evaluate state() expressions.""" + args = cls._parse_args(expr) + key = args[0] + default = args[1] if len(args) > 1 else None + if state is not None: + return state.get(key, default) + return default + + @staticmethod + def _parse_args(expr: str) -> list[str]: + """Parse arguments from an expression like 'func(a, b, c)'.""" + inner = expr[expr.index("(") + 1 : expr.rindex(")")] + args = [] + for arg in inner.split(","): + arg = arg.strip().strip("'\"") + args.append(arg) + return args + + +# ── Custom addon loader ───────────────────────────────────── + + +class AddonRegistry: + """Loads and manages custom addon scripts for complex mock behaviors. + + Custom addons are Python files in the addons directory that + implement a handler class. They're referenced from the mock + config by name (filename without .py extension). + + Example addon structure:: + + # addons/hls_audio_stream.py + class Handler: + def handle(self, flow: http.HTTPFlow, config: dict) -> bool: + # Return True if this addon handled the request + ... + """ + + def __init__(self, addons_dir: str): + self.addons_dir = Path(addons_dir) + self._handlers: dict[str, Any] = {} + + def get_handler(self, name: str) -> Any | None: + """Load and cache a custom addon handler by name.""" + if name in self._handlers: + return self._handlers[name] + + script_path = self.addons_dir / f"{name}.py" + if not script_path.exists(): + ctx.log.error(f"Addon script not found: {script_path}") + return None + + try: + spec = importlib.util.spec_from_file_location( + f"hil_addon_{name}", script_path, + ) + if spec is None or spec.loader is None: + ctx.log.error( + f"Failed to create import spec for addon '{name}' " + f"at {script_path}" + ) + return None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + if hasattr(module, "Handler"): + handler = module.Handler() + self._handlers[name] = handler + ctx.log.info(f"Loaded addon: {name}") + return handler + else: + ctx.log.error( + f"Addon {name} missing Handler class" + ) + return None + except Exception as e: + ctx.log.error(f"Failed to load addon {name}: {e}") + return None + + def reload(self, name: str): + """Force reload an addon (e.g., after file change).""" + if name in self._handlers: + del self._handlers[name] + return self.get_handler(name) + + +# ── Capture client ────────────────────────────────────────── + +CAPTURE_SOCKET = "/opt/jumpstarter/mitmproxy/capture.sock" +CAPTURE_SPOOL_DIR = "/opt/jumpstarter/mitmproxy/capture-spool" + +# Response bodies at or below this size are sent inline in capture events. +# Larger or binary bodies are spooled to disk and only the file path is sent. +_INLINE_BODY_LIMIT = 256 * 1024 # 256 KB + + +class CaptureClient: + """Sends captured request events to the driver via Unix socket. + + Connects lazily and reconnects once on failure. If the socket is + unavailable (e.g., driver not running), events are silently dropped. + """ + + def __init__(self, socket_path: str = CAPTURE_SOCKET): + self._socket_path = socket_path + self._sock: _socket.socket | None = None + + def _connect(self) -> bool: + try: + sock = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM) + sock.connect(self._socket_path) + self._sock = sock + return True + except OSError: + self._sock = None + return False + + def send_event(self, event: dict): + """Send a JSON event line. Reconnects once on failure.""" + payload = json.dumps(event) + "\n" + for attempt in range(2): + if self._sock is None: + if not self._connect(): + return + try: + self._sock.sendall(payload.encode()) + return + except OSError: + self.close() + if attempt == 0: + continue + return + + def close(self): + if self._sock is not None: + try: + self._sock.close() + except OSError: + pass + self._sock = None + + +# ── Main addon ────────────────────────────────────────────── + + +class MitmproxyMockAddon: + """Enhanced mock addon with file serving, templates, sequences, + and custom addon delegation. + + Configuration format (v2):: + + { + "config": { + "files_dir": "/opt/jumpstarter/mitmproxy/mock-files", + "addons_dir": "/opt/jumpstarter/mitmproxy/addons", + "default_latency_ms": 0 + }, + "endpoints": { + "GET /api/v1/status": { + "status": 200, + "body": {"ok": true} + }, + "GET /firmware.bin": { + "status": 200, + "file": "firmware/test.bin", + "content_type": "application/octet-stream" + }, + "GET /stream/audio*": { + "addon": "hls_audio_stream" + } + } + } + + Also supports the v1 flat format (just endpoints, no wrapper). + """ + + # Default config directory — overridden by env var or config + MOCK_DIR = os.environ.get( + "MITMPROXY_MOCK_DIR", "/opt/jumpstarter/mitmproxy/mock-responses" + ) + + def __init__(self): + self.config: dict = {} + self.endpoints: dict[str, dict] = {} + self.files_dir: Path = Path(self.MOCK_DIR) / "../mock-files" + self.addon_registry: AddonRegistry | None = None + self._config_mtime: float = 0 + self._config_path = Path(self.MOCK_DIR) / "endpoints.json" + self._sequence_state: dict[str, int] = defaultdict(int) + self._sequence_start: dict[str, float] = {} + self._capture_client = CaptureClient(CAPTURE_SOCKET) + self._state: dict = {} + self._state_mtime: float = 0 + self._state_path = Path(self.MOCK_DIR) / "state.json" + self._spool_dir = Path(CAPTURE_SPOOL_DIR) + self._spool_dir.mkdir(parents=True, exist_ok=True) + self._spool_counter = 0 + self._load_config() + + # ── Config loading ────────────────────────────────────── + + def _load_config(self): + """Load or reload config if the file has changed on disk.""" + if not self._config_path.exists(): + return + + try: + mtime = self._config_path.stat().st_mtime + if mtime <= self._config_mtime: + return # No changes + + with open(self._config_path) as f: + raw = json.load(f) + + # Detect v1 vs v2 format + if "endpoints" in raw: + # v2 format + self.config = raw.get("config", {}) + self.endpoints = raw["endpoints"] + else: + # v1 flat format (backward compatible) + self.config = {} + self.endpoints = raw + + # Apply config + files_dir = self.config.get("files_dir") + if files_dir: + self.files_dir = Path(files_dir) + else: + self.files_dir = Path(self.MOCK_DIR).parent / "mock-files" + + addons_dir = self.config.get( + "addons_dir", + str(Path(self.MOCK_DIR).parent / "addons"), + ) + self.addon_registry = AddonRegistry(addons_dir) + + self._config_mtime = mtime + ctx.log.info( + f"Loaded {len(self.endpoints)} endpoint(s) " + f"(files: {self.files_dir}, addons: {addons_dir})" + ) + + except Exception as e: + ctx.log.error(f"Failed to load config: {e}") + + def _load_state(self): + """Load or reload shared state if the file has changed on disk.""" + if not self._state_path.exists(): + return + + try: + mtime = self._state_path.stat().st_mtime + if mtime <= self._state_mtime: + return # No changes + + with open(self._state_path) as f: + self._state = json.load(f) + + self._state_mtime = mtime + except Exception as e: + ctx.log.error(f"Failed to load state: {e}") + + # ── Request matching ──────────────────────────────────── + + def _find_endpoint( + self, method: str, path: str, flow: http.HTTPFlow, + ) -> tuple[str, dict] | None: + """Find the best matching endpoint for a request. + + Matching priority: + 1. Exact match: "GET /api/v1/status" + 2. Wildcard match: "GET /api/v1/nav*" + 3. Priority field (higher = matched first) + 4. Match conditions (headers, query params) + + For WebSocket upgrades, also checks "WEBSOCKET /path". + """ + self._load_config() # Hot-reload + + candidates: list[tuple[int, str, dict]] = [] + + # Check for WebSocket upgrade + is_websocket = ( + flow.request.headers.get("Upgrade", "").lower() == "websocket" + ) + + check_keys = [f"{method} {path}"] + if is_websocket: + check_keys.append(f"WEBSOCKET {path}") + + for key in check_keys: + if key in self.endpoints: + ep = self.endpoints[key] + if self._matches_conditions(ep, flow): + priority = ep.get("priority", 0) + candidates.append((priority, key, ep)) + + # Wildcard matching + self._collect_wildcard_matches( + method, path, is_websocket, flow, candidates, + ) + + if not candidates: + return None + + # Sort by priority (highest first), then by specificity + # (longer patterns = more specific) + candidates.sort(key=lambda c: (-c[0], -len(c[1]))) + return (candidates[0][1], candidates[0][2]) + + def _collect_wildcard_matches( + self, + method: str, + path: str, + is_websocket: bool, + flow: http.HTTPFlow, + candidates: list[tuple[int, str, dict]], + ): + """Collect wildcard endpoint matches into candidates list.""" + for pattern, ep in self.endpoints.items(): + if not pattern.endswith("*"): + continue + + parts = pattern.split(" ", 1) + if len(parts) != 2: + continue + + pat_method, pat_path = parts + prefix = pat_path.rstrip("*") + + match_method = ( + pat_method == "*" + or pat_method == method + or (is_websocket and pat_method == "WEBSOCKET") + ) + + if match_method and path.startswith(prefix): + if self._matches_conditions(ep, flow): + priority = ep.get("priority", 0) + candidates.append((priority, pattern, ep)) + + def _matches_conditions( + self, endpoint: dict, flow: http.HTTPFlow, + ) -> bool: + """Check if a request matches the endpoint's conditions.""" + match_rules = endpoint.get("match") + if not match_rules: + return True + + return ( + self._check_headers(match_rules, flow) + and self._check_headers_absent(match_rules, flow) + and self._check_query(match_rules, flow) + and self._check_body_contains(match_rules, flow) + and self._check_body_json(match_rules, flow) + ) + + @staticmethod + def _check_headers(match_rules: dict, flow: http.HTTPFlow) -> bool: + """Check required header presence and values.""" + for header, value in match_rules.get("headers", {}).items(): + actual = flow.request.headers.get(header) + if actual is None: + return False + if value and actual != value: + return False + return True + + @staticmethod + def _check_headers_absent(match_rules: dict, flow: http.HTTPFlow) -> bool: + """Check that certain headers are absent.""" + for header in match_rules.get("headers_absent", []): + if header in flow.request.headers: + return False + return True + + @staticmethod + def _check_query(match_rules: dict, flow: http.HTTPFlow) -> bool: + """Check required query parameter presence and values.""" + for param, value in match_rules.get("query", {}).items(): + actual = flow.request.query.get(param) + if actual is None: + return False + if value and actual != value: + return False + return True + + @staticmethod + def _check_body_contains(match_rules: dict, flow: http.HTTPFlow) -> bool: + """Check that request body contains a substring.""" + body_contains = match_rules.get("body_contains") + if body_contains: + body = flow.request.get_text() or "" + if body_contains not in body: + return False + return True + + @staticmethod + def _check_body_json(match_rules: dict, flow: http.HTTPFlow) -> bool: + """Check exact match on parsed JSON fields in request body.""" + body_json_match = match_rules.get("body_json", {}) + if body_json_match: + try: + body_obj = json.loads(flow.request.get_text() or "{}") + except json.JSONDecodeError: + return False + for field_path, expected in body_json_match.items(): + actual = _resolve_dotted_path(body_obj, field_path) + if actual != expected: + return False + return True + + # ── Response generation ───────────────────────────────── + + async def request(self, flow: http.HTTPFlow): + """Main request hook: find and apply mock responses.""" + self._load_state() + + # Strip conditional headers so upstream always returns 200 with full body. + # Enabled by setting state key "strip_conditional_headers" to true. + if self._state.get("strip_conditional_headers"): + for h in ("If-Modified-Since", "If-None-Match", "If-Range"): + flow.request.headers.pop(h, None) + + # Strip query string for endpoint key matching; query params + # remain available in flow for _matches_conditions. + path = flow.request.path.split("?")[0] + + result = self._find_endpoint( + flow.request.method, path, flow, + ) + + if result is None: + return # No mock, passthrough to real server + + key, endpoint = result + + # Delegate to custom addon + if "addon" in endpoint: + self._handle_addon(flow, endpoint) + return + + # Handle conditional rules (multiple response variants) + if "rules" in endpoint: + await self._handle_rules(flow, key, endpoint) + return + + # Handle response sequences (stateful) + if "sequence" in endpoint: + await self._handle_sequence(flow, key, endpoint) + return + + # Handle patch mode: passthrough to real server, patch response later + if "patch" in endpoint: + flow.metadata["_jmp_patch"] = endpoint["patch"] + if "headers" in endpoint: + flow.metadata["_jmp_patch_headers"] = endpoint["headers"] + return + + # Handle regular response + await self._send_response(flow, endpoint) + + async def _send_response(self, flow: http.HTTPFlow, endpoint: dict): + """Build and send a mock response from an endpoint definition.""" + status = int(endpoint.get("status", 200)) + content_type = endpoint.get( + "content_type", + self.config.get("default_content_type", "application/json"), + ) + + # Simulated latency + latency_ms = endpoint.get( + "latency_ms", + self.config.get("default_latency_ms", 0), + ) + if latency_ms > 0: + await asyncio.sleep(latency_ms / 1000.0) + + # Build response headers + resp_headers = {"Content-Type": content_type} + resp_headers.update(endpoint.get("headers", {})) + + # Determine body source + if "file" in endpoint: + body = self._read_file(endpoint["file"]) + if body is None: + flow.response = http.Response.make( + 500, + json.dumps({ + "error": f"Mock file not found: {endpoint['file']}" + }).encode(), + {"Content-Type": "application/json"}, + ) + return + elif "body_template" in endpoint: + rendered = TemplateEngine.render( + endpoint["body_template"], flow, self._state, + ) + body = json.dumps(rendered).encode() + elif "body" in endpoint: + body_val = endpoint["body"] + if isinstance(body_val, (dict, list)): + body = json.dumps(body_val).encode() + elif isinstance(body_val, str): + body = body_val.encode() + else: + body = str(body_val).encode() + else: + body = b"" + + ctx.log.info( + f"Mock: {flow.request.method} {flow.request.path} " + f"→ {status} ({len(body)} bytes)" + ) + + flow.response = http.Response.make(status, body, resp_headers) + flow.metadata["_jmp_mocked"] = True + + async def _handle_sequence( + self, flow: http.HTTPFlow, key: str, endpoint: dict, + ): + """Handle stateful response sequences. + + Supports two modes: + + **Time-based** (when entries have ``delay_ms``): The addon + records when the first request arrived and serves the latest + step whose ``delay_ms`` has elapsed. This lets captured + traffic replay with realistic timing. + + **Count-based** (legacy, when entries have ``repeat``): The + addon counts calls and advances through the sequence. + """ + sequence = endpoint["sequence"] + has_delays = any("delay_ms" in step for step in sequence) + + if has_delays: + self._handle_sequence_timed(flow, key, sequence) + else: + await self._handle_sequence_counted(flow, key, sequence) + + def _handle_sequence_timed( + self, flow: http.HTTPFlow, key: str, sequence: list[dict], + ): + """Serve the latest step whose delay_ms has elapsed.""" + now = time.time() + if key not in self._sequence_start: + self._sequence_start[key] = now + + elapsed_ms = (now - self._sequence_start[key]) * 1000 + + # Walk forward through steps; the last one whose delay has + # elapsed is the active response. + active_step = sequence[0] + for step in sequence: + if step.get("delay_ms", 0) <= elapsed_ms: + active_step = step + else: + break + + # Use _send_response synchronously-safe path: build the + # response inline (delay_ms is about *when* the step + # activates, not per-request latency). + status = int(active_step.get("status", 200)) + self._build_mock_response(flow, active_step, status) + + def _build_mock_response( + self, flow: http.HTTPFlow, step: dict, status: int, + ): + """Build a mock response from a sequence step (sync helper).""" + content_type = step.get( + "content_type", + self.config.get("default_content_type", "application/json"), + ) + resp_headers = {"Content-Type": content_type} + resp_headers.update(step.get("headers", {})) + + if "file" in step: + body = self._read_file(step["file"]) + if body is None: + body = b"" + elif "body" in step: + body_val = step["body"] + if isinstance(body_val, (dict, list)): + body = json.dumps(body_val).encode() + elif isinstance(body_val, str): + body = body_val.encode() + else: + body = str(body_val).encode() + else: + body = b"" + + flow.response = http.Response.make(status, body, resp_headers) + flow.metadata["_jmp_mocked"] = True + + async def _handle_sequence_counted( + self, flow: http.HTTPFlow, key: str, sequence: list[dict], + ): + """Legacy count-based sequence progression.""" + call_num = self._sequence_state[key] + + position = 0 + for step in sequence: + repeat = step.get("repeat", float("inf")) + if call_num < position + repeat: + await self._send_response(flow, step) + self._sequence_state[key] += 1 + return + position += repeat + + # Past the end of the sequence: use last entry + await self._send_response(flow, sequence[-1]) + self._sequence_state[key] += 1 + + async def _handle_rules( + self, flow: http.HTTPFlow, key: str, endpoint: dict, + ): + """Handle conditional mock rules. + + Evaluates rules in order. First rule whose ``match`` conditions + are satisfied wins. A rule with no ``match`` key is a default + fallback. + """ + rules = endpoint["rules"] + + for rule in rules: + if self._matches_conditions(rule, flow): + if "sequence" in rule: + await self._handle_sequence(flow, key, rule) + else: + await self._send_response(flow, rule) + return + + # No rule matched — passthrough + ctx.log.info( + f"No conditional rule matched for {key}, passing through" + ) + + def _handle_addon(self, flow: http.HTTPFlow, endpoint: dict): + """Delegate request handling to a custom addon script.""" + addon_name = endpoint["addon"] + addon_config = endpoint.get("addon_config", {}) + + # Inject files_dir so addons can locate data files without hardcoding + if "files_dir" not in addon_config: + addon_config["files_dir"] = str(self.files_dir) + + if self.addon_registry is None: + ctx.log.error("Addon registry not initialized") + return + + handler = self.addon_registry.get_handler(addon_name) + if handler is None: + flow.response = http.Response.make( + 500, + json.dumps({ + "error": f"Addon not found: {addon_name}" + }).encode(), + {"Content-Type": "application/json"}, + ) + return + + try: + handled = handler.handle(flow, addon_config) + if handled: + flow.metadata["_jmp_mocked"] = True + else: + ctx.log.warn( + f"Addon {addon_name} did not handle request" + ) + except Exception as e: + ctx.log.error(f"Addon {addon_name} error: {e}") + flow.response = http.Response.make( + 500, + json.dumps({ + "error": f"Addon error: {e}" + }).encode(), + {"Content-Type": "application/json"}, + ) + + # ── File serving ──────────────────────────────────────── + + def _read_file(self, relative_path: str) -> bytes | None: + """Read a file from the files directory. + + Args: + relative_path: Path relative to files_dir. + + Returns: + File contents as bytes, or None if not found. + """ + file_path = self.files_dir / relative_path + + # Security: prevent path traversal + try: + file_path = file_path.resolve() + files_dir_resolved = self.files_dir.resolve() + if not file_path.is_relative_to(files_dir_resolved): + ctx.log.error(f"Path traversal blocked: {relative_path}") + return None + except (OSError, ValueError): + return None + + if not file_path.exists(): + ctx.log.error(f"Mock file not found: {file_path}") + return None + + try: + return file_path.read_bytes() + except OSError as e: + ctx.log.error(f"Failed to read {file_path}: {e}") + return None + + # ── WebSocket handling ────────────────────────────────── + + def websocket_message(self, flow: http.HTTPFlow): + """Route WebSocket messages to custom addons if configured.""" + result = self._find_endpoint( + "WEBSOCKET", flow.request.path, flow, + ) + if result is None: + return + + _, endpoint = result + if "addon" not in endpoint: + return + + addon_name = endpoint["addon"] + if self.addon_registry is None: + return + + handler = self.addon_registry.get_handler(addon_name) + if handler and hasattr(handler, "websocket_message"): + try: + handler.websocket_message(flow, endpoint.get("addon_config", {})) + except Exception as e: + ctx.log.error( + f"Addon {addon_name} websocket error: {e}" + ) + + def _classify_response_body( + self, flow: http.HTTPFlow, + ) -> dict: + """Classify the response body for capture event inclusion. + + Text/JSON bodies within the inline limit are returned directly. + Large or binary bodies are spooled to disk and only the file + path is included in the event. + + Returns a dict with keys: response_body, response_body_file, + response_content_type, response_headers, response_is_binary. + """ + resp = flow.response + if resp is None: + return { + "response_body": None, + "response_body_file": None, + "response_content_type": None, + "response_headers": {}, + "response_is_binary": False, + } + + content_type = resp.headers.get("content-type", "") + base_ct = content_type.split(";")[0].strip().lower() + # The stored body is already decompressed by mitmproxy, so strip + # content-encoding to avoid confusing downstream consumers. + resp_headers = { + k: v for k, v in resp.headers.items() + if k.lower() != "content-encoding" + } + + # Determine if content is text-like + text_types = ( + "application/json", "text/", "application/xml", + "application/javascript", "application/x-www-form-urlencoded", + ) + is_text = any(base_ct.startswith(t) for t in text_types) + + raw_body = resp.get_content() + body_size = len(raw_body) if raw_body else 0 + + if is_text and body_size <= _INLINE_BODY_LIMIT: + # Inline text body + try: + body_text = raw_body.decode("utf-8") if raw_body else "" + except UnicodeDecodeError: + body_text = raw_body.decode("latin-1") if raw_body else "" + return { + "response_body": body_text, + "response_body_file": None, + "response_content_type": base_ct, + "response_headers": resp_headers, + "response_is_binary": False, + } + + if body_size == 0: + return { + "response_body": "", + "response_body_file": None, + "response_content_type": base_ct, + "response_headers": resp_headers, + "response_is_binary": not is_text, + } + + # Spool large or binary body to disk + self._spool_counter += 1 + url_hash = hashlib.sha256( + flow.request.pretty_url.encode() + ).hexdigest()[:12] + spool_name = f"{self._spool_counter:06d}_{url_hash}.bin" + spool_path = self._spool_dir / spool_name + try: + spool_path.write_bytes(raw_body) + except OSError as e: + ctx.log.error(f"Failed to spool response body: {e}") + return { + "response_body": None, + "response_body_file": None, + "response_content_type": base_ct, + "response_headers": resp_headers, + "response_is_binary": not is_text, + } + + return { + "response_body": None, + "response_body_file": str(spool_path), + "response_content_type": base_ct, + "response_headers": resp_headers, + "response_is_binary": not is_text, + } + + def _build_capture_event( + self, flow: http.HTTPFlow, response_status: int, + ) -> dict: + """Build a capture event dict from a flow.""" + parsed = urlparse(flow.request.pretty_url) + body_info = self._classify_response_body(flow) + # Compute request duration from mitmproxy timestamps + duration_ms = 0 + if ( + flow.response + and hasattr(flow.response, "timestamp_end") + and hasattr(flow.request, "timestamp_start") + and flow.response.timestamp_end + and flow.request.timestamp_start + ): + duration_ms = round( + (flow.response.timestamp_end - flow.request.timestamp_start) * 1000, + ) + + # Compute response size + response_size = 0 + if flow.response: + raw = flow.response.get_content() + response_size = len(raw) if raw else 0 + + event = { + "timestamp": time.time(), + "method": flow.request.method, + "url": flow.request.pretty_url, + "path": flow.request.path, + "headers": dict(flow.request.headers), + "query": parse_qs(parsed.query), + "body": flow.request.get_text() or "", + "response_status": response_status, + "was_mocked": bool(flow.metadata.get("_jmp_mocked")), + "was_patched": bool(flow.metadata.get("_jmp_patched")), + "duration_ms": duration_ms, + "response_size": response_size, + } + event.update(body_info) + return event + + def response(self, flow: http.HTTPFlow): + """Log all responses, apply patches, and emit capture events.""" + if flow.response: + # Apply patch if this flow was marked for patching + patch_data = flow.metadata.get("_jmp_patch") + if patch_data: + content_type = flow.response.headers.get("content-type", "") + if "json" in content_type.lower(): + patched = _apply_patches( + flow.response.get_content(), + patch_data, + flow, + self._state, + ) + if patched is not None: + flow.response.set_content(patched) + flow.metadata["_jmp_mocked"] = True + flow.metadata["_jmp_patched"] = True + ctx.log.info( + f"Patched: {flow.request.method} " + f"{flow.request.pretty_url}" + ) + else: + ctx.log.warn( + f"Patch skipped (invalid JSON body): " + f"{flow.request.method} {flow.request.pretty_url}" + ) + else: + ctx.log.warn( + f"Patch skipped (non-JSON content-type: " + f"{content_type}): {flow.request.method} " + f"{flow.request.pretty_url}" + ) + + # Inject response headers from patch mode + patch_headers = flow.metadata.get("_jmp_patch_headers") + if patch_headers: + for k, v in patch_headers.items(): + flow.response.headers[k] = v + flow.metadata["_jmp_mocked"] = True + + ctx.log.debug( + f"{flow.request.method} {flow.request.pretty_url} " + f"→ {flow.response.status_code}" + ) + event = self._build_capture_event( + flow, flow.response.status_code, + ) + self._capture_client.send_event(event) + + def error(self, flow: http.HTTPFlow): + """Emit a capture event for upstream connection failures.""" + event = self._build_capture_event(flow, 0) + self._capture_client.send_event(event) + + +# ── Entry point ───────────────────────────────────────────── + +addons = [MitmproxyMockAddon()] diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py new file mode 100644 index 000000000..8714fe9a2 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py @@ -0,0 +1,1530 @@ +""" +Jumpstarter client for the mitmproxy driver. + +This module runs on the test client side and communicates with the +MitmproxyDriver running on the exporter host via Jumpstarter's gRPC +transport. It provides a Pythonic API for controlling the proxy, +configuring mock endpoints, and managing traffic recordings. + +Usage in pytest:: + + def test_device_status(client): + proxy = client.proxy # MitmproxyClient instance + + proxy.start(mode="mock", web_ui=True) + + proxy.set_mock( + "GET", "/api/v1/status", + body={"id": "device-001", "status": "online"}, + ) + + # ... interact with DUT ... + + proxy.stop() + +Or with context managers for cleaner test code:: + + def test_update_check(client): + proxy = client.proxy + + with proxy.session(mode="mock", web_ui=True): + with proxy.mock_endpoint("GET", "/api/v1/updates/check", + body={"update_available": True}): + # ... test update notification flow ... + pass +""" + +from __future__ import annotations + +import base64 +import fnmatch +import json +from contextlib import contextmanager +from ipaddress import IPv6Address, ip_address +from pathlib import Path +from threading import Event +from typing import Any, Generator + +import click +import yaml + +from jumpstarter.client import DriverClient +from jumpstarter.client.decorators import driver_click_group + + +class CaptureContext: + """Context for a capture session. + + Returned by :meth:`MitmproxyClient.capture`. While inside the + ``with`` block, ``requests`` returns live data from the driver. + After exiting, it returns a frozen snapshot taken at exit time. + + Example:: + + with proxy.capture() as cap: + # ... interact with DUT ... + pass + assert cap.requests # frozen snapshot + """ + + def __init__(self, client: "MitmproxyClient"): + self._client = client + self._snapshot: list[dict] | None = None + + @property + def requests(self) -> list[dict]: + """Captured requests (live while in context, frozen after exit).""" + if self._snapshot is not None: + return self._snapshot + return self._client.get_captured_requests() + + def assert_request_made(self, method: str, path: str) -> dict: + """Assert that a matching request was captured. + + Raises: + AssertionError: If no matching request is found. + """ + return self._client.assert_request_made(method, path) + + def wait_for_request( + self, method: str, path: str, timeout: float = 10.0, + ) -> dict: + """Wait for a matching request. + + Raises: + TimeoutError: If no match within timeout. + """ + return self._client.wait_for_request(method, path, timeout) + + def _freeze(self): + """Take a snapshot (called on context exit).""" + self._snapshot = self._client.get_captured_requests() + + +class MitmproxyClient(DriverClient): + """Client for controlling mitmproxy on the exporter host. + + All methods delegate to the corresponding ``@export``-decorated + methods on ``MitmproxyDriver`` via Jumpstarter's RPC mechanism. + """ + + # ── Lifecycle ─────────────────────────────────────────────── + + def start(self, mode: str = "mock", web_ui: bool = False, + replay_file: str = "", port: int = 0) -> str: + """Start the proxy in the specified mode. + + Args: + mode: One of "mock", "passthrough", "record", "replay". + web_ui: Launch mitmweb (browser UI) instead of mitmdump. + replay_file: Flow file path for replay mode. + port: Override the listen port (0 uses the configured default). + + Returns: + Status message with connection details. + """ + return self.call("start", mode, web_ui, replay_file, port) + + def stop(self) -> str: + """Stop the proxy process. + + Returns: + Status message. + """ + return self.call("stop") + + def restart(self, mode: str = "", web_ui: bool = False, + replay_file: str = "", port: int = 0) -> str: + """Restart the proxy (optionally with new config). + + Args: + mode: New mode (empty string keeps current mode). + web_ui: Enable/disable web UI. + replay_file: Flow file for replay mode. + port: Override the listen port (0 keeps current port). + + Returns: + Status message from start(). + """ + return self.call("restart", mode, web_ui, replay_file, port) + + # ── Status ────────────────────────────────────────────────── + + def status(self) -> dict: + """Get proxy status as a dict. + + Returns: + Dict with keys: running, mode, pid, proxy_address, + web_ui_enabled, web_ui_address, mock_count, flow_file. + """ + return json.loads(self.call("status")) + + def is_running(self) -> bool: + """Check if the proxy process is alive.""" + return self.call("is_running") + + @property + def web_ui_url(self) -> str | None: + """Get the mitmweb UI URL if available.""" + info = self.status() + return info.get("web_ui_address") + + # ── CLI (jmp shell) ──────────────────────────────────────── + + def cli(self): # noqa: C901 + @driver_click_group(self) + def base(): + """Mitmproxy driver""" + pass + + # ── Lifecycle commands ───────────────────────────────── + + @base.command("start") + @click.option( + "--mode", "-m", + type=click.Choice(["mock", "passthrough", "record", "replay"]), + default="mock", show_default=True, + ) + @click.option("--web-ui", "-w", is_flag=True, help="Enable mitmweb browser UI.") + @click.option("--replay-file", default="", help="Flow file for replay mode.") + @click.option("--port", "-p", default=0, type=int, + help="Override listen port (default: from exporter config).") + def start_cmd(mode: str, web_ui: bool, replay_file: str, port: int): + """Start the mitmproxy process.""" + click.echo(self.start(mode=mode, web_ui=web_ui, replay_file=replay_file, port=port)) + + @base.command("stop") + def stop_cmd(): + """Stop the mitmproxy process.""" + click.echo(self.stop()) + + @base.command("restart") + @click.option( + "--mode", "-m", + type=click.Choice(["mock", "passthrough", "record", "replay"]), + default=None, + help="New mode (keeps current mode if omitted).", + ) + @click.option("--web-ui", "-w", is_flag=True, help="Enable mitmweb browser UI.") + @click.option("--replay-file", default="", help="Flow file for replay mode.") + @click.option("--port", "-p", default=0, type=int, + help="Override listen port (default: keeps current port).") + def restart_cmd(mode: str | None, web_ui: bool, replay_file: str, port: int): + """Stop and restart the mitmproxy process.""" + click.echo(self.restart(mode=mode or "", web_ui=web_ui, + replay_file=replay_file, port=port)) + + # ── Status command ───────────────────────────────────── + + @base.command("status") + def status_cmd(): + """Show proxy status.""" + info = self.status() + running = info.get("running", False) + mode = info.get("mode", "unknown") + pid = info.get("pid") + + if not running: + click.echo("Proxy is not running.") + return + + click.echo(f"Proxy is running (PID {pid})") + click.echo(f" Mode: {mode}") + click.echo(f" Listen: {info.get('proxy_address')}") + if info.get("web_ui_enabled"): + click.echo(f" Web UI: {info.get('web_ui_address')}") + click.echo(f" Mocks: {info.get('mock_count', 0)} endpoint(s)") + if info.get("flow_file"): + click.echo(f" Flow: {info.get('flow_file')}") + + # ── Mock management commands ─────────────────────────── + + @base.group("mock") + def mock_group(): + """Mock endpoint management.""" + pass + + @mock_group.command("list") + def mock_list_cmd(): + """List configured mock endpoints.""" + mocks = self.list_mocks() + if not mocks: + click.echo("No mocks configured.") + return + for key, defn in mocks.items(): + summary = _mock_summary(defn) + click.echo(f" {key} -> {summary}") + + @mock_group.command("clear") + def mock_clear_cmd(): + """Remove all mock endpoint definitions.""" + click.echo(self.clear_mocks()) + + @mock_group.command("load") + @click.argument("scenario_file") + def mock_load_cmd(scenario_file: str): + """Load a mock scenario from a YAML/JSON file or a directory. + + SCENARIO_FILE is a path to a local scenario file, or a + directory produced by 'capture save' (scenario.yaml is + loaded automatically from inside the directory). Any + companion files referenced by 'file:' entries are uploaded + automatically. + """ + local_path = Path(scenario_file) + if local_path.is_dir(): + local_path = local_path / "scenario.yaml" + if not local_path.exists(): + click.echo(f"File not found: {local_path}") + return + try: + content = local_path.read_text() + except OSError as e: + click.echo(f"Error reading file: {e}") + return + + # Upload companion files referenced by file: entries + _upload_scenario_files(self, local_path, content) + + click.echo( + self.load_mock_scenario_content(local_path.name, content) + ) + + # ── Flow file commands ───────────────────────────────── + + @base.group("flow") + def flow_group(): + """Recorded flow file management.""" + pass + + @flow_group.command("list") + def flow_list_cmd(): + """List recorded flow files on the exporter.""" + files = self.list_flow_files() + if not files: + click.echo("No flow files found.") + return + for f in files: + size = _human_size(f.get("size_bytes", 0)) + click.echo(f" {f['name']} ({size}, {f.get('modified', '')})") + + @flow_group.command("save") + @click.argument("name") + @click.argument("output", default=None, required=False) + def flow_save_cmd(name: str, output: str | None): + """Download a flow file from the exporter to a local file. + + NAME is the filename as shown by 'j proxy flow list'. + OUTPUT defaults to NAME in the current directory. + """ + dest = Path(output) if output else Path(name) + data = self.get_flow_file(name) + dest.write_bytes(data) + click.echo(f"Flow file saved to: {dest.resolve()}") + + # ── Capture commands ─────────────────────────────────── + + @base.group("capture", invoke_without_command=True) + @click.option( + "-f", "--filter", + "filter_pattern", + default="", + help="Path glob filter for watch (e.g. '/api/v1/*').", + ) + @click.pass_context + def capture_group(ctx, filter_pattern: str): + """Request capture management. + + When invoked without a subcommand, streams live requests + (equivalent to 'capture watch'). + """ + if ctx.invoked_subcommand is not None: + return + click.echo("Watching captured requests (Ctrl+C to stop)...") + try: + for event in self.watch_captured_requests(): + if filter_pattern: + path = event.get("path", "").split("?")[0] + if not fnmatch.fnmatch(path, filter_pattern): + continue + click.echo(_format_capture_entry(event)) + except KeyboardInterrupt: + pass + + @capture_group.command("list") + def capture_list_cmd(): + """Show captured requests.""" + reqs = self.get_captured_requests() + if not reqs: + click.echo("No captured requests.") + return + click.echo(f"{len(reqs)} captured request(s):") + for r in reqs: + click.echo(_format_capture_entry(r)) + + @capture_group.command("clear") + def capture_clear_cmd(): + """Clear all captured requests.""" + click.echo(self.clear_captured_requests()) + + @capture_group.command("watch") + @click.option( + "-f", "--filter", + "filter_pattern", + default="", + help="Path glob filter (e.g. '/api/v1/*').", + ) + def capture_watch_cmd(filter_pattern: str): + """Watch captured requests in real-time. + + Streams live requests to the terminal as they flow through + the proxy. Press Ctrl+C to stop. + """ + click.echo("Watching captured requests (Ctrl+C to stop)...") + try: + for event in self.watch_captured_requests(): + if filter_pattern: + path = event.get("path", "").split("?")[0] + if not fnmatch.fnmatch(path, filter_pattern): + continue + click.echo(_format_capture_entry(event)) + except KeyboardInterrupt: + pass + + @capture_group.command("save") + @click.argument("directory", type=click.Path()) + @click.option( + "-f", "--filter", + "filter_pattern", + default="", + help="Path glob filter (e.g. '/api/v1/*').", + ) + @click.option( + "--exclude-mocked", + is_flag=True, + help="Skip requests served by the mock addon.", + ) + def capture_save_cmd(directory: str, filter_pattern: str, + exclude_mocked: bool): + """Save captured traffic as a scenario to DIRECTORY. + + Generates a v2 scenario from captured requests, suitable for + loading with 'j proxy mock load'. JSON response bodies are + included inline; binary/large bodies are saved as companion + files under responses/ preserving the URL path structure. + + Creates DIRECTORY and writes scenario.yaml plus any response + files inside it. + """ + yaml_str, file_paths = self.export_captured_scenario( + filter_pattern=filter_pattern, + exclude_mocked=exclude_mocked, + ) + + out_dir = Path(directory) + out_dir.mkdir(parents=True, exist_ok=True) + yaml_path = out_dir / "scenario.yaml" + yaml_path.write_text(yaml_str) + click.echo(f"Scenario written to: {yaml_path.resolve()}") + + # Download companion files from the exporter + for rel_path in file_paths: + data = self.get_captured_file(rel_path) + file_path = out_dir / rel_path + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_bytes(data) + click.echo(f" {rel_path}") + + # Clean up spool files after successful export + click.echo(self.clean_capture_spool()) + + # ── Web UI forwarding ────────────────────────────────── + + @base.command("web") + @click.option("--address", default="localhost", show_default=True) + @click.option("--port", default=8081, show_default=True, type=int) + def web_cmd(address: str, port: int): + """Forward mitmweb UI to a local TCP port. + + Opens a local listener that tunnels to the mitmweb web UI + running on the exporter host. + """ + from contextlib import asynccontextmanager + from functools import partial + + from jumpstarter.client.adapters import blocking + from jumpstarter.common import TemporaryTcpListener + from jumpstarter.streams.common import forward_stream + + async def handler(client, method, conn): + async with conn: + async with client.stream_async(method) as stream: + async with forward_stream(conn, stream): + pass + + @blocking + @asynccontextmanager + async def portforward(*, client, method, local_host, local_port): + async with TemporaryTcpListener( + partial(handler, client, method), + local_host=local_host, + local_port=local_port, + ) as addr: + yield addr + + with portforward( + client=self, + method="connect_web", + local_host=address, + local_port=port, + ) as addr: + host = ip_address(addr[0]) + actual_port = addr[1] + if isinstance(host, IPv6Address): + url = f"http://[{host}]:{actual_port}" + else: + url = f"http://{host}:{actual_port}" + auth_url = f"{url}/?token=jumpstarter" + click.echo(f"mitmweb UI available at: {auth_url}") + click.echo("Press Ctrl+C to stop forwarding.") + try: + # Loop with a timeout so the main thread can + # receive and handle KeyboardInterrupt promptly. + stop = Event() + while not stop.wait(timeout=0.5): + pass + except KeyboardInterrupt: + click.echo("\nStopping...") + # The portforward context manager teardown may block + # waiting for active connections to drain. Install a + # handler so a second Ctrl+C force-exits immediately. + import os as _os + import signal as _signal + _signal.signal( + _signal.SIGINT, lambda *_: _os._exit(0), + ) + + # ── CA certificate ───────────────────────────────────── + + @base.command("cert") + @click.argument("output", default="mitmproxy-ca-cert.pem") + def cert_cmd(output: str): + """Download the mitmproxy CA certificate to a local file. + + OUTPUT is the local file path to write the PEM certificate to. + Defaults to mitmproxy-ca-cert.pem in the current directory. + """ + + pem = self.get_ca_cert() + out = Path(output) + out.write_text(pem) + click.echo(f"CA certificate written to: {out.resolve()}") + + return base + + # ── Mock management ───────────────────────────────────────── + + def set_mock(self, method: str, path: str, status: int = 200, + body: dict | list | str = "", + content_type: str = "application/json", + headers: dict | None = None) -> str: + """Add or update a mock endpoint. + + Args: + method: HTTP method (GET, POST, PUT, DELETE, etc.) + path: URL path to match. Append "*" for prefix matching. + status: HTTP status code to return. + body: Response body. Dicts/lists are JSON-serialized. + content_type: Response Content-Type header. + headers: Additional response headers. + + Returns: + Confirmation message. + + Example:: + + proxy.set_mock( + "GET", "/api/v1/status", + body={"id": "device-001", "status": "online"}, + ) + """ + if isinstance(body, (dict, list)): + body = json.dumps(body) + elif not body: + body = "{}" + + headers_str = json.dumps(headers or {}) + + return self.call( + "set_mock", method, path, status, body, content_type, + headers_str, + ) + + def remove_mock(self, method: str, path: str) -> str: + """Remove a mock endpoint. + + Args: + method: HTTP method. + path: URL path. + + Returns: + Confirmation or not-found message. + """ + return self.call("remove_mock", method, path) + + def clear_mocks(self) -> str: + """Remove all mock endpoint definitions.""" + return self.call("clear_mocks") + + def list_mocks(self) -> dict: + """List all configured mock endpoints. + + Returns: + Dict of mock definitions keyed by "METHOD /path". + """ + return json.loads(self.call("list_mocks")) + + # ── V2: File, latency, sequence, template, addon ──────── + + def set_mock_file(self, method: str, path: str, + file_path: str, + content_type: str = "", + status: int = 200, + headers: dict | None = None) -> str: + """Mock an endpoint to serve a file from disk. + + Args: + method: HTTP method. + path: URL path. + file_path: Path relative to files_dir on the exporter. + content_type: MIME type (auto-detected if empty). + status: HTTP status code. + headers: Additional response headers. + + Example:: + + proxy.set_mock_file( + "GET", "/api/v1/downloads/firmware.bin", + "firmware/test.bin", + ) + """ + return self.call( + "set_mock_file", method, path, file_path, + content_type, status, json.dumps(headers or {}), + ) + + def set_mock_patch(self, method: str, path: str, + patches: dict, + headers: dict | None = None) -> str: + """Mock an endpoint in patch mode (passthrough + field overwrite). + + The request passes through to the real server. When the response + comes back, the specified fields are deep-merged into the JSON + body before delivery to the DUT. + + Args: + method: HTTP method (GET, POST, etc.). Use ``*`` to match + any method (wildcard). + path: URL path to match. + patches: Dict to deep-merge into the response body. Use + ``key[N]`` syntax for array indexing. + headers: Extra response headers to inject. + + Returns: + Confirmation message. + + Example:: + + proxy.set_mock_patch( + "GET", "/rest/v3/experience/modules/nonPII", + {"ModuleListResponse": {"moduleList": {"modules[0]": { + "status": "Inactive" + }}}}, + ) + """ + return self.call( + "set_mock_patch", method, path, json.dumps(patches), + json.dumps(headers or {}), + ) + + def set_mock_with_latency(self, method: str, path: str, + status: int = 200, + body: dict | list | str = "", + latency_ms: int = 1000, + content_type: str = "application/json") -> str: + """Mock an endpoint with simulated network latency. + + Example:: + + proxy.set_mock_with_latency( + "GET", "/api/v1/status", + body={"status": "online"}, + latency_ms=3000, # 3-second delay + ) + """ + if isinstance(body, (dict, list)): + body = json.dumps(body) + elif not body: + body = "{}" + return self.call( + "set_mock_with_latency", method, path, status, + body, latency_ms, content_type, + ) + + def set_mock_sequence(self, method: str, path: str, + sequence: list[dict]) -> str: + """Mock an endpoint with a stateful response sequence. + + Args: + sequence: List of response steps. Each step has: + - status (int) + - body (dict) + - repeat (int, optional — last entry repeats forever) + + Example:: + + proxy.set_mock_sequence("GET", "/api/v1/auth/token", [ + {"status": 200, "body": {"token": "aaa"}, "repeat": 3}, + {"status": 401, "body": {"error": "expired"}, "repeat": 1}, + {"status": 200, "body": {"token": "bbb"}}, + ]) + """ + return self.call( + "set_mock_sequence", method, path, + json.dumps(sequence), + ) + + def set_mock_template(self, method: str, path: str, + template: dict, + status: int = 200) -> str: + """Mock with a dynamic body template (evaluated per-request). + + Supported expressions: ``{{now_iso}}``, ``{{uuid}}``, + ``{{random_int(min, max)}}``, ``{{random_choice(a, b)}}``, + ``{{counter(name)}}``, etc. + + Example:: + + proxy.set_mock_template("GET", "/api/v1/weather", { + "temp_f": "{{random_int(60, 95)}}", + "condition": "{{random_choice('sunny', 'rain')}}", + "timestamp": "{{now_iso}}", + }) + """ + return self.call( + "set_mock_template", method, path, + json.dumps(template), status, + ) + + def set_mock_addon(self, method: str, path: str, + addon_name: str, + addon_config: dict | None = None) -> str: + """Delegate an endpoint to a custom addon script. + + The addon must be a .py file in the addons directory with + a ``Handler`` class implementing ``handle(flow, config)``. + + Example:: + + proxy.set_mock_addon( + "GET", "/streaming/audio/channel/*", + "hls_audio_stream", + addon_config={ + "segment_duration_s": 6, + "channels": {"ch101": {"name": "Rock"}}, + }, + ) + """ + return self.call( + "set_mock_addon", method, path, addon_name, + json.dumps(addon_config or {}), + ) + + def list_addons(self) -> list[str]: + """List available addon scripts on the exporter.""" + return json.loads(self.call("list_addons")) + + def load_mock_scenario(self, scenario_file: str) -> str: + """Load a mock scenario from a JSON or YAML file on the exporter. + + Replaces all current mocks. Files with ``.yaml`` or ``.yml`` + extensions are parsed as YAML; all others as JSON. + + Args: + scenario_file: Filename (relative to mock_dir) or + absolute path (.json, .yaml, .yml). + + Returns: + Status message with endpoint count. + """ + return self.call("load_mock_scenario", scenario_file) + + def load_mock_scenario_content(self, filename: str, content: str) -> str: + """Upload and load a mock scenario from raw file content. + + Reads a local scenario file on the client side and sends its + contents to the exporter for parsing and activation. + + Args: + filename: Original filename (extension determines parser). + content: Raw file content as a string. + + Returns: + Status message with endpoint count. + """ + return self.call("load_mock_scenario_content", filename, content) + + # ── V2: Conditional mocks ────────────────────────────────── + + def set_mock_conditional(self, method: str, path: str, + rules: list[dict]) -> str: + """Mock an endpoint with conditional response rules. + + Rules are evaluated in order. First match wins. A rule with + no ``match`` key is the default fallback. + + Args: + method: HTTP method. + path: URL path. + rules: List of rule dicts, each with optional ``match`` + conditions and response fields (``status``, ``body``, + ``body_template``, ``headers``, etc.). + + Returns: + Confirmation message. + + Example:: + + proxy.set_mock_conditional("POST", "/api/auth", [ + { + "match": {"body_json": {"username": "admin", + "password": "secret"}}, + "status": 200, + "body": {"token": "mock-token-001"}, + }, + {"status": 401, "body": {"error": "unauthorized"}}, + ]) + """ + return self.call( + "set_mock_conditional", method, path, + json.dumps(rules), + ) + + @contextmanager + def mock_conditional( + self, + method: str, + path: str, + rules: list[dict], + ) -> Generator[None, None, None]: + """Context manager for a temporary conditional mock. + + Sets up conditional rules on entry and removes the mock + on exit. + + Args: + method: HTTP method. + path: URL path. + rules: Conditional rules list. + + Example:: + + with proxy.mock_conditional("POST", "/api/auth", [ + {"match": {"body_json": {"user": "admin"}}, + "status": 200, "body": {"token": "t1"}}, + {"status": 401, "body": {"error": "denied"}}, + ]): + # test auth flow + pass + """ + self.set_mock_conditional(method, path, rules) + try: + yield + finally: + self.remove_mock(method, path) + + # ── State store ──────────────────────────────────────────── + + def set_state(self, key: str, value: Any) -> str: + """Set a key in the shared state store. + + Accepts any JSON-serializable value. + + Args: + key: State key name. + value: Any JSON-serializable value. + + Returns: + Confirmation message. + """ + return self.call("set_state", key, json.dumps(value)) + + def get_state(self, key: str) -> Any: + """Get a value from the shared state store. + + Args: + key: State key name. + + Returns: + The deserialized value, or None if not found. + """ + return json.loads(self.call("get_state", key)) + + def clear_state(self) -> str: + """Clear all keys from the shared state store. + + Returns: + Confirmation message. + """ + return self.call("clear_state") + + def get_all_state(self) -> dict: + """Get the entire shared state store. + + Returns: + Dict of all state key-value pairs. + """ + return json.loads(self.call("get_all_state")) + + # ── Flow file management ──────────────────────────────────── + + def list_flow_files(self) -> list[dict]: + """List recorded flow files on the exporter. + + Returns: + List of dicts with name, path, size_bytes, modified. + """ + return json.loads(self.call("list_flow_files")) + + def get_flow_file(self, name: str) -> bytes: + """Download a recorded flow file from the exporter via streaming. + + Uses chunked streaming transfer so files of any size can be + downloaded without hitting gRPC message limits. + + Args: + name: Filename as returned by :meth:`list_flow_files` + (e.g. ``capture_20260101.bin``). + + Returns: + Raw file content. + """ + chunks = [] + for b64_chunk in self.streamingcall("get_flow_file", name): + chunks.append(base64.b64decode(b64_chunk)) + return b"".join(chunks) + + # ── CA certificate ────────────────────────────────────────── + + def get_ca_cert_path(self) -> str: + """Get the path to the mitmproxy CA certificate on the exporter. + + This certificate must be installed on the DUT for HTTPS + interception. + + Returns: + Path to the PEM certificate file. + """ + return self.call("get_ca_cert_path") + + def get_ca_cert(self) -> str: + """Read the mitmproxy CA certificate from the exporter. + + Returns the PEM-encoded certificate contents so it can be + saved locally or pushed to the DUT. + + Returns: + PEM-encoded CA certificate string. + + Raises: + RuntimeError: If the certificate has not been generated yet + (start the proxy once to create it). + + Example:: + + pem = proxy.get_ca_cert() + Path("/tmp/mitmproxy-ca.pem").write_text(pem) + """ + result = self.call("get_ca_cert") + if result.startswith("Error:"): + raise RuntimeError(result) + return result + + # ── Capture management ────────────────────────────────────── + + def get_captured_requests(self) -> list[dict]: + """Return all captured requests. + + Returns: + List of captured request dicts. + """ + return json.loads(self.call("get_captured_requests")) + + def watch_captured_requests(self) -> Generator[dict, None, None]: + """Stream captured requests as they arrive. + + Yields existing requests first, then new ones in real-time. + + Yields: + Parsed capture event dicts. + """ + for event_json in self.streamingcall("watch_captured_requests"): + yield json.loads(event_json) + + def clear_captured_requests(self) -> str: + """Clear all captured requests. + + Returns: + Message with the count of cleared requests. + """ + return self.call("clear_captured_requests") + + def export_captured_scenario( + self, filter_pattern: str = "", exclude_mocked: bool = False, + ) -> tuple[str, list[str]]: + """Export captured requests as a v2 scenario YAML string. + + Deduplicates by ``METHOD /path`` (last response wins). JSON + bodies are rendered as native YAML. Large/binary bodies are + written to files on the exporter and listed for download. + + Args: + filter_pattern: Optional path glob (e.g. ``/api/v1/*``). + exclude_mocked: Skip requests served by the mock addon. + + Returns: + Tuple of (yaml_string, file_paths_list) where file_paths + are relative paths that can be fetched with + :meth:`get_captured_file`. + """ + result = json.loads(self.call( + "export_captured_scenario", filter_pattern, exclude_mocked, + )) + return result["yaml"], result.get("files", []) + + def get_captured_file(self, relative_path: str) -> bytes: + """Download a captured file from the exporter via streaming. + + Uses chunked streaming transfer so files of any size can be + downloaded without hitting gRPC message limits. + + Args: + relative_path: Path relative to files_dir on the exporter. + + Returns: + Raw file content. + """ + chunks = [] + for b64_chunk in self.streamingcall("get_captured_file", relative_path): + chunks.append(base64.b64decode(b64_chunk)) + return b"".join(chunks) + + def clean_capture_spool(self) -> str: + """Remove spooled response body files on the exporter. + + Call after exporting a scenario to free disk space. + + Returns: + Message with the count of removed files. + """ + return self.call("clean_capture_spool") + + def upload_mock_file(self, relative_path: str, data: bytes) -> str: + """Upload a binary file to the exporter's mock files directory. + + Used when loading a scenario that has ``file:`` references + pointing to local files on the client. + + Args: + relative_path: Path relative to files_dir on the exporter. + data: Raw file content. + + Returns: + Confirmation message. + """ + return self.call( + "upload_mock_file", relative_path, + base64.b64encode(data).decode("ascii"), + ) + + def wait_for_request(self, method: str, path: str, + timeout: float = 10.0) -> dict: + """Wait for a matching request to be captured. + + Args: + method: HTTP method to match. + path: URL path to match (supports ``*`` suffix wildcard). + timeout: Maximum seconds to wait. + + Returns: + The matching captured request dict. + + Raises: + TimeoutError: If no match is found within timeout. + """ + result = json.loads( + self.call("wait_for_request", method, path, timeout) + ) + if "error" in result: + raise TimeoutError(result["error"]) + return result + + def assert_request_made(self, method: str, path: str) -> dict: + """Assert that a matching request has been captured. + + Args: + method: HTTP method to match. + path: URL path to match (supports ``*`` suffix wildcard). + + Returns: + The first matching captured request dict. + + Raises: + AssertionError: If no matching request is found, with a + helpful message listing all captured paths. + """ + captured = self.get_captured_requests() + for req in captured: + if req.get("method") == method: + req_path = req.get("path", "") + if path.endswith("*"): + if req_path.startswith(path[:-1]): + return req + elif req_path == path: + return req + + paths = [ + f" {r.get('method')} {r.get('path')}" for r in captured + ] + path_list = "\n".join(paths) if paths else " (none)" + raise AssertionError( + f"Expected {method} {path} but it was not captured.\n" + f"Captured requests:\n{path_list}" + ) + + @contextmanager + def capture(self) -> Generator[CaptureContext, None, None]: + """Context manager for capturing requests. + + Clears captured requests on entry, freezes a snapshot on exit. + + Yields: + A :class:`CaptureContext` for inspecting captured requests. + + Example:: + + with proxy.capture() as cap: + # ... DUT makes HTTP requests through the proxy ... + cap.wait_for_request("GET", "/api/v1/status") + + # After the block, cap.requests is a frozen snapshot + assert len(cap.requests) == 1 + """ + self.clear_captured_requests() + ctx = CaptureContext(self) + try: + yield ctx + finally: + ctx._freeze() + + # ── Context managers ──────────────────────────────────────── + + @contextmanager + def session( + self, + mode: str = "mock", + web_ui: bool = False, + replay_file: str = "", + ) -> Generator[MitmproxyClient, None, None]: + """Context manager for a proxy session. + + Starts the proxy on entry and stops it on exit, ensuring + clean teardown even if the test fails. + + Args: + mode: Operational mode. + web_ui: Enable mitmweb browser UI. + replay_file: Flow file for replay mode. + + Yields: + This client instance. + + Example:: + + with proxy.session(mode="mock", web_ui=True) as p: + p.set_mock("GET", "/api/health", body={"ok": True}) + # ... run test ... + """ + self.start(mode=mode, web_ui=web_ui, replay_file=replay_file) + try: + yield self + finally: + self.stop() + + @contextmanager + def mock_endpoint( + self, + method: str, + path: str, + status: int = 200, + body: dict | list | str = "", + content_type: str = "application/json", + headers: dict | None = None, + ) -> Generator[None, None, None]: + """Context manager for a temporary mock endpoint. + + Sets up the mock on entry and removes it on exit. Useful for + test-specific overrides on top of a base scenario. + + Args: + method: HTTP method. + path: URL path. + status: HTTP status code. + body: Response body. + content_type: Content-Type header. + headers: Additional headers. + + Example:: + + with proxy.mock_endpoint( + "GET", "/api/v1/updates/check", + body={"update_available": True, "version": "2.6.0"}, + ): + # DUT will see the update + trigger_update_check() + assert_update_dialog_shown() + # mock is automatically cleaned up + """ + self.set_mock( + method, path, status, body, content_type, headers, + ) + try: + yield + finally: + self.remove_mock(method, path) + + @contextmanager + def mock_patch_endpoint( + self, + method: str, + path: str, + patches: dict, + ) -> Generator[None, None, None]: + """Context manager for a temporary patch mock endpoint. + + Sets up a patch mock on entry and removes it on exit. + + Args: + method: HTTP method. + path: URL path. + patches: Dict to deep-merge into the response body. + + Example:: + + with proxy.mock_patch_endpoint( + "GET", "/rest/v3/experience/modules/nonPII", + {"ModuleListResponse": {"moduleList": {"modules[0]": { + "status": "Inactive" + }}}}, + ): + # real server response is patched + pass + # patch is automatically cleaned up + """ + self.set_mock_patch(method, path, patches) + try: + yield + finally: + self.remove_mock(method, path) + + @contextmanager + def mock_scenario( + self, scenario_file: str, + ) -> Generator[None, None, None]: + """Context manager for a complete mock scenario. + + Loads a scenario file on entry and clears all mocks on exit. + + Args: + scenario_file: Path to a scenario file (.json, .yaml, .yml) + or a scenario directory containing ``scenario.yaml``. + + Example:: + + with proxy.mock_scenario("update-available"): + # all endpoints from the scenario are active + test_full_update_flow() + """ + self.load_mock_scenario(scenario_file) + try: + yield + finally: + self.clear_mocks() + + @contextmanager + def recording(self) -> Generator[MitmproxyClient, None, None]: + """Context manager for recording traffic. + + Starts in record mode and stops when done. The flow file + path is available via ``status()["flow_file"]``. + + Example:: + + with proxy.recording() as p: + # drive through a test scenario on the DUT + run_golden_path_scenario() + + # flow file saved, check status for path + files = p.list_flow_files() + """ + self.start(mode="record") + try: + yield self + finally: + self.stop() + + # ── Convenience methods ───────────────────────────────────── + + def mock_error(self, method: str, path: str, + status: int = 503, + message: str = "Service Unavailable") -> str: + """Shortcut to mock an error response. + + Args: + method: HTTP method. + path: URL path. + status: Error HTTP status code (default 503). + message: Error message body. + + Returns: + Confirmation message. + """ + return self.set_mock( + method, path, status, + body={"error": message, "status": status}, + ) + + def mock_timeout(self, method: str, path: str) -> str: + """Mock a gateway timeout (504) response. + + Useful for testing DUT timeout/retry behavior. + + Args: + method: HTTP method. + path: URL path. + + Returns: + Confirmation message. + """ + return self.set_mock( + method, path, 504, + body={"error": "Gateway Timeout"}, + ) + + +# ── CLI helpers ──────────────────────────────────────────────── + + +# Fixed column widths (characters): +# 2 (indent) + 8 (timestamp) + 1 + 7 (method) + 1 + PATH + 1 + 3 (status) +# + 1 + 7 (duration) + 1 + 8 (size) + 1 + 13 (tag) = 54 + PATH +_FIXED_COLS_WIDTH = 54 +_MIN_PATH_WIDTH = 20 + +_METHOD_COLORS: dict[str, str] = { + "GET": "green", + "POST": "blue", + "PUT": "yellow", + "PATCH": "yellow", + "DELETE": "red", + "HEAD": "cyan", + "OPTIONS": "cyan", +} + + +def _style_status(status: str | int) -> str: + """Color a status code string by HTTP status class.""" + text = str(status).rjust(3) + code = int(status) if str(status).isdigit() else 0 + if code >= 500: + return click.style(text, fg="red", bold=True) + if code >= 400: + return click.style(text, fg="yellow") + if code >= 300: + return click.style(text, fg="cyan") + if code >= 200: + return click.style(text, fg="green") + return click.style(text, fg="white") + + +def _format_capture_entry(entry: dict) -> str: + """Format a captured request entry for terminal display. + + Shows timestamp, method, path, status, duration, size, and mock tag + in fixed-width columns for consistent alignment. + """ + method = entry.get("method", "") + path = entry.get("path", "") + status = entry.get("response_status", "") + was_mocked = entry.get("was_mocked", False) + timestamp = entry.get("timestamp", 0) + duration_ms = entry.get("duration_ms", 0) + response_size = entry.get("response_size", 0) + + # Format timestamp as HH:MM:SS + import time as _time + if timestamp: + ts_str = click.style( + _time.strftime("%H:%M:%S", _time.localtime(timestamp)), + fg="bright_black", + ) + else: + ts_str = click.style("--:--:--", fg="bright_black") + + # Color-code HTTP method (padded to 7 chars — length of "OPTIONS") + styled_method = click.style( + method.ljust(7), fg=_METHOD_COLORS.get(method, "white"), bold=True, + ) + + # Compute path column width from terminal size, giving path all remaining space + import shutil as _shutil + term_width = _shutil.get_terminal_size((100, 24)).columns + path_width = max(term_width - _FIXED_COLS_WIDTH, _MIN_PATH_WIDTH) + + # Pad or truncate path to computed column width + if len(path) > path_width: + path_col = path[: path_width - 1] + "\u2026" + else: + path_col = path.ljust(path_width) + + # Color-code status by class (padded to 3 chars) + styled_status = _style_status(status) if status else click.style(" -", fg="bright_black") + + # Format duration (fixed 7-char column) + if duration_ms: + if duration_ms >= 1000: + dur_text = f"{duration_ms / 1000:.1f}s" + else: + dur_text = f"{duration_ms}ms" + else: + dur_text = "-" + dur_str = click.style(dur_text.rjust(7), fg="bright_black") + + # Format response size (fixed 8-char column) + size_str = click.style(_human_size(response_size).rjust(8), fg="bright_black") + + # Mock/patched/passthrough tag (fixed 13-char column — length of "[passthrough]") + was_patched = entry.get("was_patched", False) + if was_patched: + tag = click.style("[patched]".ljust(13), fg="yellow") + elif was_mocked: + tag = click.style("[mocked]".ljust(13), fg="green") + else: + tag = click.style("[passthrough]", fg="bright_black") + + return f" {ts_str} {styled_method} {path_col} {styled_status} {dur_str} {size_str} {tag}" + + +def _mock_summary(defn: dict) -> str: + """One-line summary of a mock endpoint definition.""" + if "rules" in defn: + return f"{len(defn['rules'])} rule(s)" + if "sequence" in defn: + return f"{len(defn['sequence'])} step(s)" + if "body_template" in defn: + return f"{defn.get('status', 200)} (template)" + if "addon" in defn: + return f"addon:{defn['addon']}" + if "file" in defn: + return f"{defn.get('status', 200)} file:{defn['file']}" + status = defn.get("status", 200) + latency = defn.get("latency_ms") + s = str(status) + if latency: + s += f" (+{latency}ms)" + return s + + +def _human_size(nbytes: int) -> str: + """Format a byte count as a human-readable string.""" + for unit in ("B", "KB", "MB", "GB"): + if nbytes < 1024: + return f"{nbytes:.0f} {unit}" if unit == "B" else f"{nbytes:.1f} {unit}" + nbytes /= 1024 + return f"{nbytes:.1f} TB" + + +def _collect_entries_from_item(item: dict) -> list[dict]: + """Return the item and any nested dicts that might contain a ``file`` key.""" + entries = [item] + if isinstance(item.get("response"), dict): + entries.append(item["response"]) + for rule in item.get("rules", []): + if isinstance(rule, dict): + entries.append(rule) + for step in item.get("sequence", []): + if isinstance(step, dict): + entries.append(step) + return entries + + +def _collect_file_entries(endpoints: dict) -> list[dict]: + """Collect all dicts that might contain a ``file`` key from a scenario. + + Walks top-level endpoints plus nested ``rules`` and ``sequence`` + entries. Also handles URL-keyed list values (from ``mocks:`` key + format) where each list item is an endpoint dict. + """ + entries: list[dict] = [] + for ep in endpoints.values(): + if isinstance(ep, list): + items = ep + elif isinstance(ep, dict): + items = [ep] + else: + continue + for item in items: + if isinstance(item, dict): + entries.extend(_collect_entries_from_item(item)) + return entries + + +def _upload_scenario_files( + client: MitmproxyClient, scenario_path: Path, content: str, +) -> None: + """Scan a scenario for ``file:`` references and upload them. + + Reads each referenced file relative to the scenario file's parent + directory and uploads it to the exporter's mock files directory. + Handles files at the endpoint level and inside ``rules`` entries. + """ + try: + if scenario_path.suffix in (".yaml", ".yml"): + raw = yaml.safe_load(content) + else: + raw = json.loads(content) + except (yaml.YAMLError, json.JSONDecodeError): + return + + if not isinstance(raw, dict): + return + + if "endpoints" in raw: + endpoints = raw["endpoints"] + elif "mocks" in raw: + endpoints = raw["mocks"] + else: + endpoints = raw + if not isinstance(endpoints, dict): + return + + base_dir = scenario_path.parent + + for entry in _collect_file_entries(endpoints): + if "file" not in entry: + continue + file_ref = entry["file"] + file_path = base_dir / file_ref + if file_path.exists(): + click.echo(f" uploading {file_ref}") + client.upload_mock_file(file_ref, file_path.read_bytes()) diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py new file mode 100644 index 000000000..f08de9719 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py @@ -0,0 +1,2077 @@ +""" +Jumpstarter exporter driver for mitmproxy. + +Manages mitmdump/mitmweb as a subprocess on the exporter host, providing +HTTP(S) interception, traffic recording, server replay, and API endpoint +mocking for DUT (device under test) HiL testing. + +The driver supports four operational modes: + +- **mock**: Intercept traffic and return mock responses for configured + API endpoints. This is the primary mode for DUT testing where + you need deterministic backend responses. + +- **passthrough**: Transparent proxy that logs traffic without + modifying it. Useful for debugging what the DUT is actually + sending to production servers. + +- **record**: Capture all traffic to a binary flow file for later + replay. Use this to record a "golden" session against a real + backend, then replay it deterministically in CI. + +- **replay**: Serve responses from a previously recorded flow file. + Combined with record mode, this enables fully offline testing. + +Each mode can optionally run with the mitmweb UI for interactive +debugging, or headless via mitmdump for CI/CD pipelines. +""" + +from __future__ import annotations + +import asyncio +import base64 +import fnmatch +import json +import logging +import os +import signal +import socket +import subprocess +import tempfile +import threading +import time +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from pathlib import Path +from urllib.parse import urlparse + +import yaml +from pydantic import BaseModel, model_validator + +from jumpstarter.driver import Driver, export, exportstream + +logger = logging.getLogger(__name__) + +# ── Capture export helpers ─────────────────────────────────── + +# Content-type → file extension mapping for captured responses. +_CONTENT_TYPE_EXTENSIONS: dict[str, str] = { + "application/json": ".json", + "application/xml": ".xml", + "application/pdf": ".pdf", + "application/zip": ".zip", + "application/gzip": ".gz", + "application/octet-stream": ".bin", + "application/javascript": ".js", + "application/x-protobuf": ".pb", + "application/x-tar": ".tar", + "application/x-firmware": ".fw", + "image/png": ".png", + "image/jpeg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", + "image/svg+xml": ".svg", + "audio/mpeg": ".mp3", + "audio/aac": ".aac", + "audio/ogg": ".ogg", + "video/mp4": ".mp4", + "video/webm": ".webm", + "video/mpeg": ".mpg", + "text/html": ".html", + "text/css": ".css", + "text/plain": ".txt", + "text/xml": ".xml", + "text/csv": ".csv", +} + + +def _content_type_to_ext(content_type: str) -> str: + """Map a base content-type to a file extension. + + Checks the explicit table first, then ``mimetypes.guess_extension``, + and finally returns ``.bin``. + """ + if not content_type: + return ".bin" + ct = content_type.split(";")[0].strip().lower() + if ct in _CONTENT_TYPE_EXTENSIONS: + return _CONTENT_TYPE_EXTENSIONS[ct] + import mimetypes + ext = mimetypes.guess_extension(ct, strict=False) + if ext: + return ext + return ".bin" + + +class _LiteralStr(str): + """String subclass that YAML renders as a literal block scalar (``|``). + + When used as a value in a dict passed to ``yaml.dump()``, the string + is emitted with the ``|`` indicator so multi-line content (like + pretty-printed JSON) is preserved verbatim. + """ + + +class _ScenarioDumper(yaml.SafeDumper): + """YAML dumper that supports :class:`_LiteralStr` block scalars.""" + + +_ScenarioDumper.add_representer( + _LiteralStr, + lambda dumper, data: dumper.represent_scalar( + "tag:yaml.org,2002:str", data, style="|", + ), +) + + +def _flatten_entry(entry: dict) -> tuple[str, dict]: + """Extract method and flatten a URL-keyed endpoint entry. + + Removes ``method``, un-nests ``response`` fields, and returns + ``(method, flat_dict)`` suitable for the addon. + + Entries with a ``patch`` key (instead of ``response``) are left + as-is since the patch dict is consumed directly by the addon. + """ + entry = dict(entry) + method = entry.pop("method", "GET") + if "response" in entry: + response = entry.pop("response") + entry.update(response) + return method, entry + + +def _convert_url_endpoints(endpoints: dict) -> dict: + """Convert URL-keyed endpoints to ``METHOD /path`` format for the addon. + + Exported scenarios use full URLs as keys with each value being a + **list** of response definitions (each containing a ``method`` + field). The addon expects ``METHOD /path`` keys. This function + detects the URL-keyed format and converts it. + + Supported input formats: + - **List** (current): ``{url: [{method: GET, ...}, ...]}`` + - **Dict with rules** (legacy v2): ``{url: {rules: [...]}}`` + - **Dict** (legacy v2 single): ``{url: {method: GET, ...}}`` + - **Legacy** ``METHOD /path`` keys: passed through unchanged. + """ + converted: dict[str, dict] = {} + for key, ep in endpoints.items(): + if not key.startswith(("http://", "https://")): + # Legacy format — keep as-is + converted[key] = ep + continue + + parsed = urlparse(key) + path = parsed.path + + # Normalise to a list of response entries + if isinstance(ep, list): + entries = ep + elif isinstance(ep, dict) and "rules" in ep: + entries = ep["rules"] + elif isinstance(ep, dict): + entries = [ep] + else: + continue + + # Group entries by method → separate addon endpoints + by_method: dict[str, list[dict]] = {} + for entry in entries: + method, flat = _flatten_entry(entry) + by_method.setdefault(method, []).append(flat) + + for method, method_entries in by_method.items(): + new_key = f"{method} {path}" + if len(method_entries) == 1: + converted[new_key] = method_entries[0] + elif any("match" in e for e in method_entries): + # Different match conditions → conditional rules + converted[new_key] = {"rules": method_entries} + else: + # Same method, no match conditions → sequential replay + converted[new_key] = {"sequence": method_entries} + + return converted + + +def _write_captured_file( + method: str, url_path: str, ext: str, data: bytes, + endpoint: dict, files_dir: Path, +) -> str: + """Write a captured response body to files_dir and set ``file:`` on endpoint. + + The file path preserves the URL structure under ``responses/{METHOD}/``, + e.g. ``("GET", "/api/v1/status")`` → ``responses/GET/api/v1/status.json``. + + Returns the relative path for the client to download later. + """ + clean = url_path.lstrip("/") + # Sanitise characters that are unsafe in filenames but keep slashes + clean = "".join(c if (c.isalnum() or c in "/-_.") else "_" for c in clean) + clean = clean.strip("_") + if clean.endswith(ext): + clean = clean[:-len(ext)] + rel = f"responses/{method}/{clean}{ext}" + dest = files_dir / rel + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(data) + endpoint["file"] = rel + return rel + + +def _flatten_query(query: dict) -> dict[str, str]: + """Flatten a ``parse_qs`` query dict for use as ``match.query``. + + ``parse_qs`` returns ``{"key": ["val1", "val2"]}``. For match + conditions, we want ``{"key": "val1"}`` (first value only) for + single-value params, or keep the list for multi-value ones. + Returns an empty dict if there are no query params. + """ + if not query: + return {} + flat: dict[str, str] = {} + for k, v in query.items(): + if isinstance(v, list): + flat[k] = v[0] if len(v) == 1 else ", ".join(v) + else: + flat[k] = str(v) + return flat + + +def _query_file_suffix(query: dict) -> str: + """Build a filename-safe suffix from query parameters. + + Example: ``{"id": ["ch101"]}`` → ``"_id-ch101"`` + ``{"type": ["audio"], "fmt": ["mp3"]}`` → ``"_type-audio_fmt-mp3"`` + Returns an empty string if there are no query params. + """ + if not query: + return "" + parts = [] + for k in sorted(query): + v = query[k] + val = v[0] if isinstance(v, list) and v else str(v) + # Keep only filename-safe characters + safe_val = "".join(c if c.isalnum() or c in "-." else "-" for c in str(val)) + safe_key = "".join(c if c.isalnum() or c in "-." else "-" for c in str(k)) + parts.append(f"{safe_key}-{safe_val}") + return "_" + "_".join(parts) + + +class ListenConfig(BaseModel): + """Proxy listener address configuration.""" + + host: str = "127.0.0.1" + port: int = 8080 + + +class WebConfig(BaseModel): + """mitmweb UI address configuration.""" + + host: str = "127.0.0.1" + port: int = 8081 + + +class DirectoriesConfig(BaseModel): + """Directory layout configuration. + + All subdirectories default to ``{data}/`` when left empty. + When ``data`` is not set explicitly, a per-user temp directory is used + (``$TMPDIR/jumpstarter-mitmproxy-/``). + """ + + data: str = "" + conf: str = "" + flows: str = "" + addons: str = "" + mocks: str = "" + files: str = "" + + @model_validator(mode="after") + def _resolve_defaults(self) -> "DirectoriesConfig": + if not self.data: + import getpass + import tempfile + self.data = str( + Path(tempfile.gettempdir()) / f"jumpstarter-mitmproxy-{getpass.getuser()}" + ) + if not self.conf: + self.conf = str(Path(self.data) / "conf") + if not self.flows: + self.flows = str(Path(self.data) / "flows") + if not self.addons: + self.addons = str(Path(self.data) / "addons") + if not self.mocks: + self.mocks = str(Path(self.data) / "mock-responses") + if not self.files: + self.files = str(Path(self.data) / "mock-files") + return self + + +@dataclass(kw_only=True) +class MitmproxyDriver(Driver): + """Jumpstarter exporter driver for mitmproxy. + + Manages a mitmdump or mitmweb process on the exporter host, exposing + proxy control, mock configuration, and traffic recording APIs to + the Jumpstarter client. + + Configuration fields are automatically populated from the exporter + YAML config under the ``config:`` key. + + Example exporter config:: + + export: + proxy: + type: jumpstarter_driver_mitmproxy.driver.MitmproxyDriver + config: + listen: + port: 8080 + web: + port: 8081 + directories: + data: /tmp/jumpstarter-mitmproxy-myuser # optional, auto-generated if omitted + ssl_insecure: true + mock_scenario: happy-path # directory with scenario.yaml + # mock_scenario: happy-path.yaml # or a raw YAML file + mocks: + GET /api/v1/health: + status: 200 + body: {ok: true} + """ + + # ── Configuration (from exporter YAML) ────────────────────── + + listen: ListenConfig | dict = field(default_factory=dict) + """Proxy listener address (host/port). See :class:`ListenConfig`.""" + + web: WebConfig | dict = field(default_factory=dict) + """mitmweb UI address (host/port). See :class:`WebConfig`.""" + + directories: DirectoriesConfig | dict = field(default_factory=dict) + """Directory layout. See :class:`DirectoriesConfig`.""" + + ssl_insecure: bool = True + """Skip upstream SSL certificate verification (useful for dev/test).""" + + mock_scenario: str = "" + """Scenario to auto-load on startup. + + Accepts either a directory containing ``scenario.yaml`` or a direct + path to a ``.yaml`` / ``.json`` file. Relative paths are resolved + against the mocks directory. + """ + + mocks: dict = field(default_factory=dict) + """Inline mock endpoint definitions, loaded at startup.""" + + def __post_init__(self): + if hasattr(super(), "__post_init__"): + super().__post_init__() + if not isinstance(self.listen, ListenConfig): + self.listen = ListenConfig.model_validate(self.listen) + if not isinstance(self.web, WebConfig): + self.web = WebConfig.model_validate(self.web) + if not isinstance(self.directories, DirectoriesConfig): + self.directories = DirectoriesConfig.model_validate(self.directories) + + # ── Internal state (not from config) ──────────────────────── + + _process: subprocess.Popen | None = field( + default=None, init=False, repr=False + ) + _mock_endpoints: dict = field(default_factory=dict, init=False) + _state_store: dict = field(default_factory=dict, init=False) + _current_mode: str = field(default="stopped", init=False) + _web_ui_enabled: bool = field(default=False, init=False) + _current_flow_file: str | None = field(default=None, init=False) + + # Capture infrastructure + _capture_socket_path: str | None = field( + default=None, init=False, repr=False + ) + _capture_server_sock: socket.socket | None = field( + default=None, init=False, repr=False + ) + _capture_server_thread: threading.Thread | None = field( + default=None, init=False, repr=False + ) + _capture_reader_thread: threading.Thread | None = field( + default=None, init=False, repr=False + ) + _captured_requests: list = field(default_factory=list, init=False) + _capture_lock: threading.Lock = field( + default_factory=threading.Lock, init=False + ) + _capture_running: bool = field(default=False, init=False) + _stderr_thread: threading.Thread | None = field( + default=None, init=False, repr=False + ) + + @classmethod + def client(cls) -> str: + """Return the import path of the corresponding client class.""" + return "jumpstarter_driver_mitmproxy.client.MitmproxyClient" + + # ── Lifecycle ─────────────────────────────────────────────── + + def close(self): + """Clean up resources when the session ends. + + Called automatically by the Jumpstarter session context manager + (e.g., when the shell exits). Ensures the mitmproxy subprocess + and capture server are stopped so ports are released. + """ + if self._process is not None and self._process.poll() is None: + logger.info("Stopping mitmproxy on session teardown (PID %s)", self._process.pid) + self.stop() + super().close() + + @export + def start(self, mode: str = "mock", web_ui: bool = False, + replay_file: str = "", port: int = 0) -> str: + """Start the mitmproxy process. + + Args: + mode: Operational mode. One of: + - ``"mock"``: Intercept and mock configured endpoints + - ``"passthrough"``: Transparent logging proxy + - ``"record"``: Capture traffic to a flow file + - ``"replay"``: Serve from a recorded flow file + web_ui: If True, start mitmweb (browser UI) instead of + mitmdump (headless CLI). + replay_file: Path to a flow file (required for replay mode). + Can be absolute or relative to ``flow_dir``. + port: Override the listen port (0 uses the configured default). + + Returns: + Status message with proxy and (optionally) web UI URLs. + """ + if port: + self.listen.port = port + if self._process is not None and self._process.poll() is None: + return ( + f"Already running in '{self._current_mode}' mode " + f"(PID {self._process.pid}). Stop first." + ) + + if mode not in ("mock", "passthrough", "record", "replay"): + return ( + f"Unknown mode '{mode}'. " + f"Use: mock, passthrough, record, replay" + ) + + if mode == "replay" and not replay_file: + return "Error: replay_file is required for replay mode" + + # Ensure directories exist + Path(self.directories.flows).mkdir(parents=True, exist_ok=True) + Path(self.directories.mocks).mkdir(parents=True, exist_ok=True) + + # Start capture server (before addon generation so socket path is set) + self._start_capture_server() + + cmd = self._build_base_cmd(web_ui) + + # Mode-specific flags + error = self._apply_mode_flags(cmd, mode, replay_file) + if error: + return error + + # passthrough: no extra flags needed + + binary = cmd[0] + logger.info("Starting %s: %s", binary, " ".join(cmd)) + + self._process = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + + # Wait for startup + time.sleep(2) + + if self._process.poll() is not None: + exit_code = self._process.returncode + stderr = self._process.stderr.read().decode() if self._process.stderr else "" + self._process = None + self._stop_capture_server() + # Check if the port is in use (common cause of startup failure) + port_hint = "" + if self._is_port_in_use(self.listen.host, self.listen.port): + port_hint = ( + f" (port {self.listen.port} is already in use" + " — is another mitmproxy instance running?)" + ) + elif web_ui and self._is_port_in_use(self.web.host, self.web.port): + port_hint = ( + f" (web UI port {self.web.port} is already in use" + " — is another mitmproxy instance running?)" + ) + logger.error( + "mitmproxy exited during startup (exit code %s)%s: %s", + exit_code, port_hint, stderr.strip(), + ) + return f"Failed to start{port_hint}: {stderr[:500]}" + + # Drain stderr in a background thread to prevent pipe buffer deadlock + self._stderr_thread = threading.Thread( + target=self._drain_stderr, daemon=True, + ) + self._stderr_thread.start() + + self._current_mode = mode + self._web_ui_enabled = web_ui + + return self._build_start_message(mode, web_ui) + + def _build_base_cmd(self, web_ui: bool) -> list[str]: + """Build the base mitmproxy command with common flags.""" + binary = "mitmweb" if web_ui else "mitmdump" + cmd = [ + binary, + "--listen-host", self.listen.host, + "--listen-port", str(self.listen.port), + "--set", f"confdir={self.directories.conf}", + "--quiet", + ] + + if web_ui: + cmd.extend([ + "--web-host", self.web.host, + "--web-port", str(self.web.port), + "--set", "web_open_browser=false", + "--set", "web_password=jumpstarter", + ]) + + if self.ssl_insecure: + cmd.extend(["--set", "ssl_insecure=true"]) + + return cmd + + def _apply_mode_flags( + self, cmd: list[str], mode: str, replay_file: str, + ) -> str | None: + """Append mode-specific flags to cmd. Returns error string or None.""" + if mode == "mock": + self._load_startup_mocks() + self._write_mock_config() + + elif mode == "record": + timestamp = time.strftime("%Y%m%d_%H%M%S") + flow_file = str( + Path(self.directories.flows) / f"capture_{timestamp}.bin" + ) + cmd.extend(["-w", flow_file]) + self._current_flow_file = flow_file + + elif mode == "replay": + replay_path = Path(replay_file) + if not replay_path.is_absolute(): + replay_path = Path(self.directories.flows) / replay_path + if not replay_path.exists(): + self._stop_capture_server() + return f"Replay file not found: {replay_path}" + cmd.extend([ + "--server-replay", str(replay_path), + "--server-replay-nopop", + ]) + + # Always attach the bundled addon for capture event emission. + # In mock mode it also serves mock responses; in other modes + # it finds no matching endpoints and passes requests through + # while still recording traffic via the response() hook. + # Regenerate every session so the capture socket path is current. + addon_path = Path(self.directories.addons) / "mock_addon.py" + self._generate_default_addon(addon_path) + cmd.extend(["-s", str(addon_path)]) + + return None + + def _build_start_message(self, mode: str, web_ui: bool) -> str: + """Build the status message after successful startup.""" + msg = ( + f"Started in '{mode}' mode on " + f"{self.listen.host}:{self.listen.port} " + f"(PID {self._process.pid})" + ) + + if web_ui: + msg += ( + f" | Web UI: http://{self.web.host}:{self.web.port}" + f"/?token=jumpstarter" + ) + + if mode == "record" and self._current_flow_file: + msg += f" | Recording to: {self._current_flow_file}" + + return msg + + @staticmethod + def _is_port_in_use(host: str, port: int) -> bool: + """Check whether a TCP port is already bound.""" + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(1) + s.bind((host, port)) + return False + except OSError: + return True + + def _drain_stderr(self): + """Read and discard stderr from the mitmproxy process. + + Runs in a daemon thread to prevent the pipe buffer from filling + up, which would block (deadlock) the subprocess. + """ + proc = self._process + if proc is None or proc.stderr is None: + return + try: + for _line in proc.stderr: + pass # discard; prevents pipe buffer from filling + except (OSError, ValueError): + pass # pipe closed or process terminated + + @export + def stop(self) -> str: + """Stop the running mitmproxy process. + + Sends SIGINT for a graceful shutdown, then SIGTERM if needed. + + Returns: + Status message. + """ + if self._process is None or self._process.poll() is not None: + self._process = None + self._current_mode = "stopped" + self._web_ui_enabled = False + return "Not running" + + pid = self._process.pid + + # Graceful shutdown via SIGINT (flushes flow files) + self._process.send_signal(signal.SIGINT) + try: + self._process.wait(timeout=10) + except subprocess.TimeoutExpired: + logger.warning("Graceful shutdown timed out, sending SIGTERM") + self._process.terminate() + try: + self._process.wait(timeout=5) + except subprocess.TimeoutExpired: + logger.error("SIGTERM timed out, sending SIGKILL") + self._process.kill() + self._process.wait() + + prev_mode = self._current_mode + flow_file = self._current_flow_file + + # Join the stderr drain thread now that the process has exited + if self._stderr_thread is not None: + self._stderr_thread.join(timeout=5) + self._stderr_thread = None + + self._process = None + self._current_mode = "stopped" + self._web_ui_enabled = False + self._current_flow_file = None + + # Stop capture server (do NOT clear _captured_requests — tests may + # read captures after stop) + self._stop_capture_server() + + # Clean spool directories so temp files don't accumulate across + # stop/start cycles. The directories themselves are recreated by + # _start_capture_server / export_captured_scenario on next start. + self._clean_spool_dirs() + + msg = f"Stopped (was '{prev_mode}' mode, PID {pid})" + if prev_mode == "record" and flow_file: + msg += f" | Flow saved to: {flow_file}" + + return msg + + @export + def restart(self, mode: str = "", web_ui: bool = False, + replay_file: str = "", port: int = 0) -> str: + """Stop and restart with the given (or previous) configuration. + + If mode is empty, restarts with the same mode as before. + + Returns: + Status message from start(). + """ + restart_mode = mode if mode else self._current_mode + restart_web = web_ui or self._web_ui_enabled + + if restart_mode == "stopped": + restart_mode = "mock" + + self.stop() + time.sleep(1) + return self.start(restart_mode, restart_web, replay_file, port) + + # ── Status ────────────────────────────────────────────────── + + @export + def status(self) -> str: + """Get the current status of the proxy. + + Returns: + JSON string with status details. + """ + running = ( + self._process is not None + and self._process.poll() is None + ) + info = { + "running": running, + "mode": self._current_mode, + "pid": self._process.pid if running else None, + "proxy_address": ( + f"{self.listen.host}:{self.listen.port}" + if running else None + ), + "web_ui_enabled": self._web_ui_enabled, + "web_ui_address": ( + f"http://{self.web.host}:{self.web.port}" + f"/?token=jumpstarter" + if running and self._web_ui_enabled else None + ), + "mock_count": len(self._mock_endpoints), + "flow_file": self._current_flow_file, + } + return json.dumps(info) + + @export + def is_running(self) -> bool: + """Check if the mitmproxy process is alive. + + Returns: + True if the process is running. + """ + return ( + self._process is not None + and self._process.poll() is None + ) + + @exportstream + @asynccontextmanager + async def connect_web(self): + """Stream a TCP connection to the mitmweb UI.""" + from anyio import connect_tcp + + async with await connect_tcp( + remote_host=self.web.host, + remote_port=self.web.port, + ) as stream: + yield stream + + # ── Mock management ───────────────────────────────────────── + + @export + def set_mock(self, method: str, path: str, status: int, + body: str, + content_type: str = "application/json", + headers: str = "{}") -> str: + """Add or update a mock endpoint. + + The addon script reads mock definitions from a JSON file on + disk. This method updates that file and, if the proxy is + running in mock mode, the addon will pick up changes on the + next request (it watches the file modification time). + + Args: + method: HTTP method (GET, POST, PUT, DELETE, etc.) + path: URL path to match (e.g., "/api/v1/status"). + Append ``*`` for prefix matching. + status: HTTP status code to return. + body: Response body as a JSON string. + content_type: Response Content-Type header. + headers: Additional response headers as a JSON string. + + Returns: + Confirmation message. + """ + key = f"{method.upper()} {path}" + self._mock_endpoints[key] = { + "status": int(status), + "body": json.loads(body) if body else {}, + "content_type": content_type, + "headers": json.loads(headers) if headers else {}, + } + self._write_mock_config() + return f"Mock set: {key} → {int(status)}" + + @export + def remove_mock(self, method: str, path: str) -> str: + """Remove a mock endpoint. + + Args: + method: HTTP method. + path: URL path. + + Returns: + Confirmation or not-found message. + """ + key = f"{method.upper()} {path}" + if key in self._mock_endpoints: + del self._mock_endpoints[key] + self._write_mock_config() + return f"Removed mock: {key}" + return f"Mock not found: {key}" + + @export + def clear_mocks(self) -> str: + """Remove all mock endpoint definitions. + + Returns: + Confirmation message. + """ + count = len(self._mock_endpoints) + self._mock_endpoints.clear() + self._write_mock_config() + return f"Cleared {count} mock(s)" + + @export + def list_mocks(self) -> str: + """List all currently configured mock endpoints. + + Returns: + JSON string with all mock definitions. + """ + return json.dumps(self._mock_endpoints, indent=2) + + @export + def set_mock_file(self, method: str, path: str, + file_path: str, + content_type: str = "", + status: int = 200, + headers: str = "{}") -> str: + """Mock an endpoint to serve a file from disk. + + The file path is relative to the files directory + (default: ``{data}/mock-files/``). + + Args: + method: HTTP method (GET, POST, etc.) + path: URL path to match. + file_path: Path to file, relative to files_dir. + content_type: Response Content-Type. Auto-detected from + extension if empty. + status: HTTP status code. + headers: Additional response headers as JSON string. + + Returns: + Confirmation message. + + Example:: + + proxy.set_mock_file( + "GET", "/api/v1/downloads/firmware.bin", + "firmware/test.bin", + content_type="application/octet-stream", + ) + """ + import mimetypes as _mt + + if not content_type: + guessed, _ = _mt.guess_type(file_path) + content_type = guessed or "application/octet-stream" + + key = f"{method.upper()} {path}" + endpoint: dict = { + "status": int(status), + "file": file_path, + "content_type": content_type, + } + extra_headers = json.loads(headers) if headers else {} + if extra_headers: + endpoint["headers"] = extra_headers + + self._mock_endpoints[key] = endpoint + self._write_mock_config() + return f"File mock set: {key} → {file_path} ({content_type})" + + @export + def set_mock_patch(self, method: str, path: str, + patches_json: str, + headers: str = "{}") -> str: + """Mock an endpoint in patch mode (passthrough + field overwrite). + + The request passes through to the real server. When the response + comes back, the specified fields are deep-merged into the JSON + body before delivery to the DUT. + + Args: + method: HTTP method (GET, POST, etc.). Use ``*`` to match + any method (wildcard). + path: URL path to match. + patches_json: JSON string of the patch dict to deep-merge + into the response body. + headers: JSON string of extra response headers to inject. + + Returns: + Confirmation message. + """ + try: + patches = json.loads(patches_json) + except json.JSONDecodeError: + return "Invalid JSON for patches" + if not isinstance(patches, dict): + return "Patches must be a JSON object" + + key = f"{method.upper()} {path}" + ep: dict = {"patch": patches} + extra_headers = json.loads(headers) if headers else {} + if extra_headers: + ep["headers"] = extra_headers + self._mock_endpoints[key] = ep + self._write_mock_config() + return f"Patch mock set: {key}" + + @export + def set_mock_with_latency(self, method: str, path: str, + status: int, body: str, + latency_ms: int, + content_type: str = "application/json") -> str: + """Mock an endpoint with simulated network latency. + + Args: + method: HTTP method. + path: URL path. + status: HTTP status code. + body: Response body as JSON string. + latency_ms: Delay in milliseconds before responding. + content_type: Response Content-Type. + + Returns: + Confirmation message. + """ + key = f"{method.upper()} {path}" + self._mock_endpoints[key] = { + "status": int(status), + "body": json.loads(body) if body else {}, + "content_type": content_type, + "latency_ms": int(latency_ms), + } + self._write_mock_config() + return f"Mock set: {key} → {int(status)} (+{int(latency_ms)}ms)" + + @export + def set_mock_sequence(self, method: str, path: str, + sequence_json: str) -> str: + """Mock an endpoint with a stateful response sequence. + + Each call to the endpoint advances through the sequence. + Entries with ``"repeat": N`` are returned N times before + advancing. The last entry repeats indefinitely. + + Args: + method: HTTP method. + path: URL path. + sequence_json: JSON array of response steps, e.g.:: + + [ + {"status": 200, "body": {"ok": true}, "repeat": 3}, + {"status": 503, "body": {"error": "down"}, "repeat": 1}, + {"status": 200, "body": {"ok": true}} + ] + + Returns: + Confirmation message. + """ + key = f"{method.upper()} {path}" + try: + sequence = json.loads(sequence_json) + except json.JSONDecodeError as e: + return f"Invalid JSON: {e}" + + if not isinstance(sequence, list) or len(sequence) == 0: + return "Sequence must be a non-empty JSON array" + + self._mock_endpoints[key] = {"sequence": sequence} + self._write_mock_config() + return ( + f"Sequence mock set: {key} → " + f"{len(sequence)} step(s)" + ) + + @export + def set_mock_template(self, method: str, path: str, + template_json: str, + status: int = 200) -> str: + """Mock an endpoint with a dynamic body template. + + Template expressions are evaluated per-request:: + + {{now_iso}} → ISO 8601 timestamp + {{random_int(10, 99)}} → random integer + {{random_choice(a, b)}} → random selection + {{uuid}} → UUID v4 + {{counter(name)}} → auto-incrementing counter + {{request_path}} → matched URL path + + Args: + method: HTTP method. + path: URL path. + template_json: JSON object with template expressions. + status: HTTP status code. + + Returns: + Confirmation message. + """ + key = f"{method.upper()} {path}" + try: + template = json.loads(template_json) + except json.JSONDecodeError as e: + return f"Invalid JSON: {e}" + + self._mock_endpoints[key] = { + "status": int(status), + "body_template": template, + } + self._write_mock_config() + return f"Template mock set: {key} → {int(status)}" + + @export + def set_mock_addon(self, method: str, path: str, + addon_name: str, + addon_config_json: str = "{}") -> str: + """Delegate an endpoint to a custom addon script. + + The addon script must exist in the addons directory as + ``{addon_name}.py`` and contain a ``Handler`` class with + a ``handle(flow, config) -> bool`` method. + + Args: + method: HTTP method (use "WEBSOCKET" for WebSocket). + path: URL path (wildcards supported). + addon_name: Name of the addon (filename without .py). + addon_config_json: JSON config passed to the handler. + + Returns: + Confirmation message. + """ + key = f"{method.upper()} {path}" + try: + addon_config = json.loads(addon_config_json) + except json.JSONDecodeError as e: + return f"Invalid JSON: {e}" + + endpoint: dict = {"addon": addon_name} + if addon_config: + endpoint["addon_config"] = addon_config + + self._mock_endpoints[key] = endpoint + self._write_mock_config() + return f"Addon mock set: {key} → {addon_name}" + + @export + def set_mock_conditional(self, method: str, path: str, + rules_json: str) -> str: + """Mock an endpoint with conditional response rules. + + Rules are evaluated in order; the first rule whose ``match`` + conditions are satisfied wins. A rule with no ``match`` key + acts as a default fallback. + + Args: + method: HTTP method (GET, POST, etc.) + path: URL path to match. + rules_json: JSON array of rule objects, each containing + optional ``match`` conditions and response fields + (``status``, ``body``, ``body_template``, ``headers``, + ``content_type``, ``latency_ms``, ``sequence``). + + Returns: + Confirmation message. + + Example rules_json:: + + [ + { + "match": {"body_json": {"username": "admin"}}, + "status": 200, + "body": {"token": "abc"} + }, + { + "status": 401, + "body": {"error": "unauthorized"} + } + ] + """ + key = f"{method.upper()} {path}" + try: + rules = json.loads(rules_json) + except json.JSONDecodeError as e: + return f"Invalid JSON: {e}" + + if not isinstance(rules, list) or len(rules) == 0: + return "Rules must be a non-empty JSON array" + + self._mock_endpoints[key] = {"rules": rules} + self._write_mock_config() + return ( + f"Conditional mock set: {key} → " + f"{len(rules)} rule(s)" + ) + + # ── State store ──────────────────────────────────────────── + + @export + def set_state(self, key: str, value_json: str) -> str: + """Set a key in the shared state store. + + The value is stored as a decoded JSON value (any type: str, + int, dict, list, bool, null). The state is written to + ``state.json`` alongside ``endpoints.json`` so the addon + can hot-reload it. + + Args: + key: State key name. + value_json: JSON-encoded value. + + Returns: + Confirmation message. + """ + value = json.loads(value_json) + self._state_store[key] = value + self._write_state() + return f"State set: {key}" + + @export + def get_state(self, key: str) -> str: + """Get a value from the shared state store. + + Args: + key: State key name. + + Returns: + JSON-encoded value, or ``"null"`` if not found. + """ + return json.dumps(self._state_store.get(key)) + + @export + def clear_state(self) -> str: + """Clear all keys from the shared state store. + + Returns: + Confirmation message. + """ + count = len(self._state_store) + self._state_store.clear() + self._write_state() + return f"Cleared {count} state key(s)" + + @export + def get_all_state(self) -> str: + """Get the entire shared state store. + + Returns: + JSON-encoded dict of all state key-value pairs. + """ + return json.dumps(self._state_store) + + @export + def list_addons(self) -> str: + """List available addon scripts in the addons directory. + + Returns: + JSON array of addon names (without .py extension). + """ + addon_path = Path(self.directories.addons) + if not addon_path.exists(): + return json.dumps([]) + + addons = [ + f.stem for f in sorted(addon_path.glob("*.py")) + if not f.name.startswith("_") + ] + return json.dumps(addons) + + @export + def load_mock_scenario(self, scenario_file: str) -> str: + """Load a complete mock scenario from a JSON or YAML file. + + Replaces all current mocks with the contents of the file. + Files with ``.yaml`` or ``.yml`` extensions are parsed as YAML; + all other extensions are parsed as JSON. + + Args: + scenario_file: Filename (relative to mock_dir) or absolute + path to a mock definitions file (.json, .yaml, .yml). + + Returns: + Status message with count of loaded endpoints. + """ + path = Path(scenario_file) + if not path.is_absolute(): + path = Path(self.directories.mocks) / path + if path.is_dir(): + path = path / "scenario.yaml" + + if not path.exists(): + return f"Scenario file not found: {path}" + + try: + with open(path) as f: + if path.suffix in (".yaml", ".yml"): + raw = yaml.safe_load(f) + else: + raw = json.load(f) + except (json.JSONDecodeError, yaml.YAMLError, OSError) as e: + return f"Failed to load scenario: {e}" + + # Handle v2 format (with "endpoints" wrapper), "mocks" key, + # or v1 flat format + if "endpoints" in raw: + endpoints = raw["endpoints"] + elif "mocks" in raw: + endpoints = raw["mocks"] + else: + endpoints = raw + + # Convert URL-keyed endpoints to METHOD /path format + self._mock_endpoints = _convert_url_endpoints(endpoints) + + self._write_mock_config() + return ( + f"Loaded {len(self._mock_endpoints)} endpoint(s) " + f"from {path.name}" + ) + + @export + def load_mock_scenario_content(self, filename: str, content: str) -> str: + """Load a mock scenario from raw file content. + + Used by the client CLI to upload a local scenario file to the + exporter. The content is parsed according to the file extension + and applied the same way as :meth:`load_mock_scenario`. + + Args: + filename: Original filename (used to determine YAML vs JSON + parsing from the extension). + content: Raw file content as a string. + + Returns: + Status message with count of loaded endpoints. + """ + try: + if filename.endswith((".yaml", ".yml")): + raw = yaml.safe_load(content) + else: + raw = json.loads(content) + except (json.JSONDecodeError, yaml.YAMLError) as e: + return f"Failed to parse scenario: {e}" + + if not isinstance(raw, dict): + return "Invalid scenario: expected a JSON/YAML object" + + # Handle v2 format (with "endpoints" wrapper), "mocks" key, + # or v1 flat format + if "endpoints" in raw: + endpoints = raw["endpoints"] + elif "mocks" in raw: + endpoints = raw["mocks"] + else: + endpoints = raw + + # Convert URL-keyed endpoints to METHOD /path format + self._mock_endpoints = _convert_url_endpoints(endpoints) + + self._write_mock_config() + return ( + f"Loaded {len(self._mock_endpoints)} endpoint(s) " + f"from {filename}" + ) + + # ── Flow file management ──────────────────────────────────── + + @export + def list_flow_files(self) -> str: + """List recorded flow files in the flow directory. + + Returns: + JSON array of flow file info (name, size, modified time). + """ + flow_path = Path(self.directories.flows) + files = [] + for f in sorted(flow_path.glob("*.bin")): + stat = f.stat() + files.append({ + "name": f.name, + "path": str(f), + "size_bytes": stat.st_size, + "modified": time.strftime( + "%Y-%m-%dT%H:%M:%S", + time.localtime(stat.st_mtime), + ), + }) + return json.dumps(files, indent=2) + + @export + async def get_flow_file(self, name: str) -> AsyncGenerator[str, None]: + """Stream a recorded flow file from the exporter in chunks. + + Yields base64-encoded chunks so large files transfer without + hitting the gRPC message size limit. + + Args: + name: Filename as returned by :meth:`list_flow_files` + (e.g. ``capture_20260101.bin``). + + Yields: + Base64-encoded chunks of file content. + + Raises: + ValueError: If ``name`` contains path traversal sequences. + FileNotFoundError: If the flow file does not exist. + """ + flow_path = Path(self.directories.flows) + src = (flow_path / name).resolve() + # Guard against path traversal + if not str(src).startswith(str(flow_path.resolve())): + raise ValueError(f"Invalid flow file name: {name!r}") + if not src.exists(): + raise FileNotFoundError(f"Flow file not found: {name}") + chunk_size = 2 * 1024 * 1024 + with open(src, "rb") as f: + while True: + chunk = f.read(chunk_size) + if not chunk: + break + yield base64.b64encode(chunk).decode("ascii") + + # ── CA certificate access ─────────────────────────────────── + + @export + def get_ca_cert_path(self) -> str: + """Get the path to the mitmproxy CA certificate (PEM). + + This certificate must be installed on the DUT for HTTPS + interception to work without certificate errors. + + Returns: + Absolute path to the CA certificate file. + """ + cert_path = Path(self.directories.conf) / "mitmproxy-ca-cert.pem" + if cert_path.exists(): + return str(cert_path) + return f"CA cert not found at {cert_path}. Start proxy once to generate." + + @export + def get_ca_cert(self) -> str: + """Read and return the mitmproxy CA certificate (PEM). + + Returns: + The PEM-encoded CA certificate contents, or an error + message starting with ``"Error:"`` if not found. + """ + cert_path = Path(self.directories.conf) / "mitmproxy-ca-cert.pem" + if not cert_path.exists(): + return ( + f"Error: CA cert not found at {cert_path}. " + f"Start proxy once to generate." + ) + return cert_path.read_text() + + # ── Capture management ──────────────────────────────────── + + @export + def get_captured_requests(self) -> str: + """Return all captured requests as a JSON array. + + Returns: + JSON string of captured request records. + """ + with self._capture_lock: + return json.dumps(self._captured_requests) + + @export + async def watch_captured_requests(self) -> AsyncGenerator[str, None]: + """Stream captured requests as they arrive. + + First yields all existing captured requests as individual JSON + events, then polls for new entries every 0.3s and yields them + as they appear. Runs until the client disconnects. + + Yields: + JSON string of each capture event. + """ + with self._capture_lock: + last_index = len(self._captured_requests) + for req in self._captured_requests: + yield json.dumps(req) + + while True: + await asyncio.sleep(0.3) + with self._capture_lock: + new_count = len(self._captured_requests) + if new_count > last_index: + for req in self._captured_requests[last_index:new_count]: + yield json.dumps(req) + last_index = new_count + + @export + def clear_captured_requests(self) -> str: + """Clear all captured requests. + + Returns: + Message with the number of cleared requests. + """ + with self._capture_lock: + count = len(self._captured_requests) + self._captured_requests.clear() + return f"Cleared {count} captured request(s)" + + @export + def export_captured_scenario(self, filter_pattern: str = "", + exclude_mocked: bool = False) -> str: + """Export captured requests as a v2 scenario. + + Deduplicates by ``METHOD /path`` (last response wins, query + strings stripped). JSON response bodies are included as native + YAML mappings. Binary/large response bodies are base64-encoded + and returned alongside the YAML for the client to write locally. + + Args: + filter_pattern: Optional path glob filter (e.g. ``/api/v1/*``). + Only endpoints whose path matches are included. + exclude_mocked: If True, skip requests that were served by + the mock addon (``was_mocked=True``). + + Returns: + JSON string with keys: + - ``yaml``: v2 scenario YAML string + - ``files``: list of relative file paths on the exporter + that the client should download via ``get_captured_file`` + """ + with self._capture_lock: + requests = list(self._captured_requests) + + filtered = self._filter_captured_requests( + requests, filter_pattern, exclude_mocked, + ) + + empty = {"yaml": yaml.dump({"endpoints": {}}, default_flow_style=False), "files": []} + if not filtered: + return json.dumps(empty) + + # Group requests by URL base (scheme + domain + path, no + # query and no method). All requests are kept in order so + # repeated calls to the same endpoint become sequential + # entries in the exported array. + groups: dict[str, list[dict]] = {} + for req in filtered: + url = req.get("url", "") + base_path = req.get("path", "").split("?")[0] + parsed_url = urlparse(url) + url_base = f"{parsed_url.scheme}://{parsed_url.netloc}{base_path}" + groups.setdefault(url_base, []).append(req) + + files_dir = Path(self.directories.files) + endpoints, file_paths = self._build_grouped_endpoints( + groups, files_dir, + ) + + scenario_yaml = yaml.dump( + {"endpoints": endpoints}, + Dumper=_ScenarioDumper, + default_flow_style=False, sort_keys=False, + ) + return json.dumps({"yaml": scenario_yaml, "files": file_paths}) + + @staticmethod + def _filter_captured_requests( + requests: list[dict], + filter_pattern: str, + exclude_mocked: bool, + ) -> list[dict]: + """Filter captured requests without deduplication. + + All matching requests are returned in their original order so + repeated calls to the same endpoint are preserved as sequential + entries in the exported scenario. + """ + result: list[dict] = [] + for req in requests: + if exclude_mocked and req.get("was_mocked"): + continue + + base_path = req.get("path", "").split("?")[0] + if filter_pattern and not fnmatch.fnmatch(base_path, filter_pattern): + continue + + result.append(req) + return result + + @staticmethod + def _build_grouped_endpoints( + groups: dict[str, list[dict]], files_dir: Path, + ) -> tuple[dict[str, list], list[str]]: + """Convert grouped captured requests into v2 scenario endpoints. + + .. note:: + Also consumed by ``SiriusXmDriver`` (jumpstarter-driver-mimosa) + at runtime for IP capture export. Avoid renaming without updating. + + Keys are full URLs (scheme + domain + path). Each endpoint + value is a **list** of response definitions, each containing a + ``method`` field. This handles multiple HTTP methods and query + variants for the same URL cleanly. + + Returns: + ``(endpoints_dict, file_paths_list)`` + """ + endpoints: dict[str, list] = {} + file_paths: list[str] = [] + + for ep_key, reqs in groups.items(): + parsed_ep = urlparse(ep_key) + url_path = parsed_ep.path + first_ts = reqs[0].get("timestamp", 0) if reqs else 0 + + responses = [] + for req in reqs: + method = req.get("method", "GET") + query = req.get("query", {}) + file_key = url_path + _query_file_suffix(query) + resp_dict, fpath = MitmproxyDriver._build_scenario_response( + method, file_key, req, files_dir, + ) + + # Assemble full entry with deliberate field ordering: + # method → match → delay_ms → response + entry: dict = {"method": method} + + query_match = _flatten_query(query) + if query_match: + entry["match"] = {"query": query_match} + + ts = req.get("timestamp", 0) + delay = round((ts - first_ts) * 1000) if first_ts else 0 + entry["delay_ms"] = max(delay, 0) + + entry["response"] = resp_dict + + responses.append(entry) + if fpath: + file_paths.append(fpath) + + endpoints[ep_key] = responses + + return endpoints, file_paths + + @staticmethod + def _build_scenario_response( + method: str, file_key: str, req: dict, files_dir: Path, + ) -> tuple[dict, str | None]: + """Build the response portion of a scenario endpoint entry. + + Returns only response-related fields (``status``, ``file``, + ``content_type``, ``headers``). The caller is responsible for + adding request-level fields (``method``, ``match``, + ``delay_ms``) and nesting this under a ``response`` key. + + Returns: + ``(response_dict, relative_file_path_or_None)`` + """ + status = req.get("response_status", 200) + content_type = req.get("response_content_type", "application/json") + response_body = req.get("response_body") + response_body_file = req.get("response_body_file") + + response: dict = {"status": status} + file_path: str | None = None + + if content_type and content_type != "application/json": + response["content_type"] = content_type + + # Always write response bodies to files for consistency. + # Users can manually inline bodies in hand-written scenarios. + if response_body_file and Path(response_body_file).exists(): + file_path = MitmproxyDriver._export_body_to_file( + method, file_key, + Path(response_body_file).read_bytes(), + content_type, response, files_dir, + ) + elif response_body is not None and response_body != "": + file_path = MitmproxyDriver._export_body_to_file( + method, file_key, + response_body.encode("utf-8"), + content_type, response, files_dir, + ) + + # Include response headers, omitting standard framework-managed ones + resp_headers = req.get("response_headers", {}) + filtered_headers = { + k: v for k, v in resp_headers.items() + if k.lower() not in ( + "content-type", "content-length", "content-encoding", + "transfer-encoding", "connection", "date", "server", + ) + } + if filtered_headers: + response["headers"] = filtered_headers + + return response, file_path + + @staticmethod + def _export_body_to_file( + method: str, file_key: str, raw: bytes, + content_type: str, endpoint: dict, files_dir: Path, + ) -> str: + """Write a response body to a file, formatting JSON if possible. + + Always writes to a file — never inlines into the YAML. JSON + bodies are pretty-printed for readability. + + Returns the relative file path for the client to download. + """ + # Try to decode and pretty-print JSON + try: + text = raw.decode("utf-8") + parsed = json.loads(text) + data = json.dumps(parsed, indent=2, ensure_ascii=False).encode() + return _write_captured_file( + method, file_key, ".json", data, endpoint, files_dir, + ) + except (UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError): + pass + + # Non-JSON — write as-is with an appropriate extension + ext = _content_type_to_ext(content_type) + return _write_captured_file( + method, file_key, ext, raw, endpoint, files_dir, + ) + + @export + def clean_capture_spool(self) -> str: + """Remove all spooled response body files. + + Call this after exporting a scenario to free disk space. + + Returns: + Message with the number of removed files. + """ + spool_dir = Path(self.directories.data) / "capture-spool" + if not spool_dir.exists(): + return "No spool directory found" + + count = 0 + for f in spool_dir.iterdir(): + if f.is_file(): + f.unlink() + count += 1 + return f"Removed {count} spool file(s)" + + @export + async def get_captured_file(self, relative_path: str) -> AsyncGenerator[str, None]: + """Stream a captured file from the files directory in chunks. + + Yields base64-encoded chunks so large files transfer without + hitting the gRPC message size limit. + + Args: + relative_path: Path relative to files_dir (e.g. + ``captured-files/GET__endpoint.json``). + + Yields: + Base64-encoded chunks of file content. + """ + src = Path(self.directories.files) / relative_path + if not src.exists(): + return + # 2 MB raw → ~2.7 MB base64, well under the 4 MB gRPC limit + chunk_size = 2 * 1024 * 1024 + with open(src, "rb") as f: + while True: + chunk = f.read(chunk_size) + if not chunk: + break + yield base64.b64encode(chunk).decode("ascii") + + @export + def upload_mock_file(self, relative_path: str, content_base64: str) -> str: + """Upload a binary file to the mock files directory. + + Used by the client to transfer files referenced by ``file:`` + entries in scenario YAML files. + + Args: + relative_path: Path relative to files_dir (e.g. + ``captured-files/GET__firmware.bin``). + content_base64: Base64-encoded file content. + + Returns: + Confirmation message. + """ + dest = Path(self.directories.files) / relative_path + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(base64.b64decode(content_base64)) + return f"Uploaded: {relative_path} ({dest.stat().st_size} bytes)" + + @export + def wait_for_request(self, method: str, path: str, + timeout: float = 10.0) -> str: + """Wait for a matching request to be captured. + + Polls the capture buffer at 0.2s intervals until a matching + request is found or the timeout expires. + + Args: + method: HTTP method to match (e.g., "GET"). + path: URL path to match. Append ``*`` for prefix matching. + timeout: Maximum time to wait in seconds. + + Returns: + JSON string of the matching request, or a JSON object + with an "error" key on timeout. + """ + deadline = time.monotonic() + float(timeout) + while time.monotonic() < deadline: + with self._capture_lock: + for req in self._captured_requests: + if self._request_matches(req, method, path): + return json.dumps(req) + time.sleep(0.2) + return json.dumps({ + "error": f"Timed out waiting for {method} {path} " + f"after {timeout}s" + }) + + # ── Capture internals ────────────────────────────────────── + + def _start_capture_server(self): + """Create a Unix domain socket for receiving capture events.""" + # Ensure the spool directory exists for response body capture + Path(self.directories.data, "capture-spool").mkdir( + parents=True, exist_ok=True, + ) + + # Use a short path to avoid the ~104-char AF_UNIX limit on macOS. + # Try {data}/capture.sock first; fall back to a temp file. + preferred = str(Path(self.directories.data) / "capture.sock") + if len(preferred) < 100: + sock_path = preferred + Path(sock_path).parent.mkdir(parents=True, exist_ok=True) + else: + fd, sock_path = tempfile.mkstemp( + prefix="jmp_cap_", suffix=".sock", + ) + os.close(fd) + + # Remove stale socket / temp placeholder + try: + Path(sock_path).unlink() + except FileNotFoundError: + pass + + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server.bind(sock_path) + server.listen(1) + server.settimeout(1.0) + + self._capture_socket_path = sock_path + self._capture_server_sock = server + self._capture_running = True + + self._capture_server_thread = threading.Thread( + target=self._capture_accept_loop, + daemon=True, + ) + self._capture_server_thread.start() + logger.debug("Capture server listening on %s", sock_path) + + def _capture_accept_loop(self): + """Accept connections on the capture socket.""" + while self._capture_running: + try: + conn, _ = self._capture_server_sock.accept() + self._capture_reader_thread = threading.Thread( + target=self._capture_read_loop, + args=(conn,), + daemon=True, + ) + self._capture_reader_thread.start() + except socket.timeout: + continue + except OSError: + break + + def _capture_read_loop(self, conn: socket.socket): + """Read newline-delimited JSON events from a capture connection.""" + buf = b"" + try: + while self._capture_running: + try: + data = conn.recv(65536) + except OSError: + break + if not data: + break + buf += data + while b"\n" in buf: + line, buf = buf.split(b"\n", 1) + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + with self._capture_lock: + self._captured_requests.append(event) + except json.JSONDecodeError: + logger.debug("Bad capture JSON: %s", line[:200]) + finally: + conn.close() + + def _stop_capture_server(self): + """Shut down the capture socket and background threads.""" + self._capture_running = False + + if self._capture_server_sock is not None: + try: + self._capture_server_sock.close() + except OSError: + pass + self._capture_server_sock = None + + if self._capture_server_thread is not None: + self._capture_server_thread.join(timeout=5) + self._capture_server_thread = None + + if self._capture_reader_thread is not None: + self._capture_reader_thread.join(timeout=5) + self._capture_reader_thread = None + + if self._capture_socket_path is not None: + try: + Path(self._capture_socket_path).unlink() + except FileNotFoundError: + pass + self._capture_socket_path = None + + def _clean_spool_dirs(self) -> None: + """Remove all files from spool directories. + + Called on ``stop()`` so temp files from capture/export don't + accumulate across stop/start cycles. + """ + import shutil + + for dirname in ("capture-spool",): + spool = Path(self.directories.data) / dirname + if spool.is_dir(): + shutil.rmtree(spool, ignore_errors=True) + logger.debug("Cleaned spool directory: %s", spool) + + files_dir = Path(self.directories.files) + if files_dir.is_dir(): + shutil.rmtree(files_dir, ignore_errors=True) + logger.debug("Cleaned files directory: %s", files_dir) + + @staticmethod + def _request_matches(req: dict, method: str, path: str) -> bool: + """Check if a captured request matches method and path. + + Supports exact match and wildcard (``*`` suffix) prefix matching. + """ + if req.get("method") != method: + return False + req_path = req.get("path", "") + if path.endswith("*"): + return req_path.startswith(path[:-1]) + return req_path == path + + # ── Internal helpers ──────────────────────────────────────── + + def _load_startup_mocks(self): + """Load mock_scenario file and inline mocks at startup. + + The scenario file is loaded first as a base layer, then inline + ``mocks`` from the exporter config are overlaid on top (higher + priority). + """ + if self.mock_scenario: + scenario_path = Path(self.mock_scenario) + if not scenario_path.is_absolute(): + scenario_path = Path(self.directories.mocks) / scenario_path + if scenario_path.is_dir(): + scenario_path = scenario_path / "scenario.yaml" + if scenario_path.exists(): + with open(scenario_path) as f: + if scenario_path.suffix in (".yaml", ".yml"): + raw = yaml.safe_load(f) + else: + raw = json.load(f) + if "endpoints" in raw: + endpoints = raw["endpoints"] + elif "mocks" in raw: + endpoints = raw["mocks"] + else: + endpoints = raw + # Convert URL-keyed endpoints to METHOD /path format + self._mock_endpoints = _convert_url_endpoints(endpoints) + + if self.mocks: + self._mock_endpoints.update(self.mocks) + + def _write_mock_config(self): + """Write mock endpoint definitions to disk in v2 format.""" + mock_path = Path(self.directories.mocks) + mock_path.mkdir(parents=True, exist_ok=True) + config_file = mock_path / "endpoints.json" + + v2_config = { + "config": { + "files_dir": self.directories.files, + "addons_dir": self.directories.addons, + "default_latency_ms": 0, + "default_content_type": "application/json", + }, + "endpoints": self._mock_endpoints, + } + + with open(config_file, "w") as f: + json.dump(v2_config, f, indent=2) + logger.debug( + "Wrote %d mock(s) to %s", + len(self._mock_endpoints), + config_file, + ) + + def _write_state(self): + """Write shared state store to disk for addon hot-reload.""" + mock_path = Path(self.directories.mocks) + mock_path.mkdir(parents=True, exist_ok=True) + state_file = mock_path / "state.json" + + with open(state_file, "w") as f: + json.dump(self._state_store, f, indent=2) + logger.debug( + "Wrote %d state key(s) to %s", + len(self._state_store), + state_file, + ) + + def _generate_default_addon(self, path: Path): + """Install the bundled v2 mitmproxy addon script. + + Copies the full-featured MitmproxyMockAddon that supports + file serving, templates, sequences, and custom addon delegation. + If the bundled addon isn't available (e.g., running outside the + package), falls back to generating a minimal v2-compatible addon. + """ + path.parent.mkdir(parents=True, exist_ok=True) + + # Try to copy the bundled addon + bundled = Path(__file__).parent / "bundled_addon.py" + if bundled.exists(): + import shutil + shutil.copy2(bundled, path) + # Patch the MOCK_DIR to match this driver's config + content = path.read_text() + content = content.replace( + '/opt/jumpstarter/mitmproxy/mock-responses', + self.directories.mocks, + ) + content = content.replace( + '/opt/jumpstarter/mitmproxy/capture.sock', + self._capture_socket_path or '', + ) + content = content.replace( + '/opt/jumpstarter/mitmproxy/capture-spool', + str(Path(self.directories.data) / "capture-spool"), + ) + path.write_text(content) + logger.info("Installed bundled v2 addon: %s", path) + return + + # Fallback: generate minimal v2-compatible addon inline + addon_code = f'''\ +""" +Auto-generated mitmproxy addon (v2 format) for DUT backend mocking. +Reads from: {self.directories.mocks}/endpoints.json +Managed by jumpstarter-driver-mitmproxy. +""" +import json, os, time +from pathlib import Path +from mitmproxy import http, ctx + +class MitmproxyMockAddon: + MOCK_DIR = "{self.directories.mocks}" + def __init__(self): + self.config = {{}} + self.endpoints = {{}} + self.files_dir = Path(self.MOCK_DIR).parent / "mock-files" + self._config_mtime = 0.0 + self._config_path = Path(self.MOCK_DIR) / "endpoints.json" + self._load_config() + + def _load_config(self): + if not self._config_path.exists(): + return + try: + mtime = self._config_path.stat().st_mtime + if mtime <= self._config_mtime: + return + with open(self._config_path) as f: + raw = json.load(f) + if "endpoints" in raw: + self.config = raw.get("config", {{}}) + self.endpoints = raw["endpoints"] + else: + self.endpoints = raw + if self.config.get("files_dir"): + self.files_dir = Path(self.config["files_dir"]) + self._config_mtime = mtime + ctx.log.info(f"Loaded {{len(self.endpoints)}} endpoint(s)") + except Exception as e: + ctx.log.error(f"Config load failed: {{e}}") + + def request(self, flow: http.HTTPFlow): + self._load_config() + method, path = flow.request.method, flow.request.path + ep = self.endpoints.get(f"{{method}} {{path}}") + if ep is None: + for pat, e in self.endpoints.items(): + if pat.endswith("*"): + pm, pp = pat.split(" ", 1) + if method == pm and path.startswith(pp.rstrip("*")): + ep = e + break + if ep is None: + return + latency = ep.get("latency_ms", self.config.get("default_latency_ms", 0)) + if latency > 0: + time.sleep(latency / 1000.0) + status = int(ep.get("status", 200)) + ct = ep.get("content_type", "application/json") + hdrs = {{"Content-Type": ct}} + hdrs.update(ep.get("headers", {{}})) + if "file" in ep: + fp = self.files_dir / ep["file"] + body = fp.read_bytes() if fp.exists() else b"file not found" + elif "body" in ep: + b = ep["body"] + body = json.dumps(b).encode() if isinstance(b, (dict, list)) else str(b).encode() + else: + body = b"" + ctx.log.info(f"Mock: {{method}} {{path}} -> {{status}}") + flow.response = http.Response.make(status, body, hdrs) + + def response(self, flow: http.HTTPFlow): + if flow.response: + ctx.log.debug(f"{{flow.request.method}} {{flow.request.pretty_url}} -> {{flow.response.status_code}}") + +addons = [MitmproxyMockAddon()] +''' + with open(path, "w") as f: + f.write(addon_code) + logger.info("Generated fallback v2 addon: %s", path) + diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver_integration_test.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver_integration_test.py new file mode 100644 index 000000000..745dc7d27 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver_integration_test.py @@ -0,0 +1,809 @@ +""" +Integration tests for the mitmproxy Jumpstarter driver. + +These tests start a real mitmdump subprocess, configure mock endpoints, +and make actual HTTP requests through the proxy to verify the full +roundtrip: client -> gRPC (local mode) -> driver -> mitmdump -> HTTP. + +Requires mitmdump to be installed and on PATH. +""" + +from __future__ import annotations + +import socket +import threading +import time + +import pytest +import requests + +from .driver import MitmproxyDriver +from jumpstarter.common.utils import serve + + +def _free_port() -> int: + """Bind to port 0 and return the OS-assigned port.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _wait_for_port(host: str, port: int, timeout: float = 10) -> bool: + """TCP retry loop to confirm a port is accepting connections.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with socket.create_connection((host, port), timeout=1): + return True + except OSError: + time.sleep(0.2) + return False + + +def _can_reach_internet() -> bool: + """Quick TCP probe to check internet connectivity.""" + try: + with socket.create_connection(("httpbin.org", 80), timeout=3): + return True + except OSError: + return False + + +def _is_mitmdump_available() -> bool: + import shutil + return shutil.which("mitmdump") is not None + + +# Skip the entire module if mitmdump isn't available +pytestmark = pytest.mark.skipif( + not _is_mitmdump_available(), + reason="mitmdump not found on PATH", +) + + +@pytest.fixture +def proxy_port(): + return _free_port() + + +@pytest.fixture +def web_port(): + return _free_port() + + +@pytest.fixture +def client(tmp_path, proxy_port, web_port): + """Create a MitmproxyDriver wrapped in Jumpstarter's local serve harness.""" + instance = MitmproxyDriver( + listen={"host": "127.0.0.1", "port": proxy_port}, + web={"host": "127.0.0.1", "port": web_port}, + directories={ + "data": str(tmp_path / "data"), + "conf": str(tmp_path / "confdir"), + "flows": str(tmp_path / "flows"), + "addons": str(tmp_path / "addons"), + "mocks": str(tmp_path / "mocks"), + "files": str(tmp_path / "files"), + }, + ssl_insecure=True, + ) + with serve(instance) as client: + yield client + + +def _start_mock_with_endpoints(client, proxy_port, mocks): + """Set mocks before starting the proxy so the addon loads them on init. + + This avoids any hot-reload timing considerations: endpoints are + on disk when mitmdump first reads the config. + """ + for method, path, kwargs in mocks: + client.set_mock(method, path, **kwargs) + client.start("mock") + assert _wait_for_port("127.0.0.1", proxy_port), ( + f"mitmdump did not start on port {proxy_port}" + ) + + +class TestProxyLifecycle: + """Start/stop with a real mitmdump process.""" + + def test_start_mock_mode_and_status(self, client, proxy_port): + result = client.start("mock") + assert "mock" in result + assert str(proxy_port) in result + + status = client.status() + assert status["running"] is True + assert status["mode"] == "mock" + assert status["pid"] is not None + + client.stop() + + def test_stop_proxy(self, client, proxy_port): + client.start("mock") + assert client.is_running() is True + + result = client.stop() + assert "Stopped" in result + + status = client.status() + assert status["running"] is False + assert status["mode"] == "stopped" + + def test_start_passthrough_mode(self, client, proxy_port): + result = client.start("passthrough") + assert "passthrough" in result + assert _wait_for_port("127.0.0.1", proxy_port), ( + f"mitmdump did not start on port {proxy_port}" + ) + + status = client.status() + assert status["running"] is True + assert status["mode"] == "passthrough" + + client.stop() + + +class TestMockEndpoints: + """Mock configuration + real HTTP requests through the proxy.""" + + def test_simple_mock_response(self, client, proxy_port): + _start_mock_with_endpoints(client, proxy_port, [ + ("GET", "/api/v1/status", { + "body": {"id": "test-001", "online": True}, + }), + ]) + + try: + response = requests.get( + "http://example.com/api/v1/status", + proxies={"http": f"http://127.0.0.1:{proxy_port}"}, + timeout=10, + ) + assert response.status_code == 200 + data = response.json() + assert data["id"] == "test-001" + assert data["online"] is True + finally: + client.stop() + + def test_multiple_mock_endpoints(self, client, proxy_port): + _start_mock_with_endpoints(client, proxy_port, [ + ("GET", "/api/v1/health", {"body": {"ok": True}}), + ("POST", "/api/v1/telemetry", { + "status": 202, "body": {"accepted": True}, + }), + ]) + + try: + proxies = {"http": f"http://127.0.0.1:{proxy_port}"} + + resp_get = requests.get( + "http://example.com/api/v1/health", + proxies=proxies, timeout=10, + ) + assert resp_get.status_code == 200 + assert resp_get.json()["ok"] is True + + resp_post = requests.post( + "http://example.com/api/v1/telemetry", + proxies=proxies, timeout=10, + ) + assert resp_post.status_code == 202 + assert resp_post.json()["accepted"] is True + finally: + client.stop() + + def test_mock_error_status_codes(self, client, proxy_port): + _start_mock_with_endpoints(client, proxy_port, [ + ("GET", "/api/v1/missing", { + "status": 404, "body": {"error": "not found"}, + }), + ("GET", "/api/v1/broken", { + "status": 500, "body": {"error": "internal error"}, + }), + ]) + + try: + proxies = {"http": f"http://127.0.0.1:{proxy_port}"} + + resp_404 = requests.get( + "http://example.com/api/v1/missing", + proxies=proxies, timeout=10, + ) + assert resp_404.status_code == 404 + assert resp_404.json()["error"] == "not found" + + resp_500 = requests.get( + "http://example.com/api/v1/broken", + proxies=proxies, timeout=10, + ) + assert resp_500.status_code == 500 + assert resp_500.json()["error"] == "internal error" + finally: + client.stop() + + def test_clear_mocks(self, client, proxy_port): + client.set_mock("GET", "/a", body={"x": 1}) + client.set_mock("GET", "/b", body={"x": 2}) + client.start("mock") + + try: + result = client.clear_mocks() + assert "Cleared 2" in result + + mocks = client.list_mocks() + assert len(mocks) == 0 + finally: + client.stop() + + def test_remove_single_mock(self, client, proxy_port): + client.set_mock("GET", "/keep", body={"x": 1}) + client.set_mock("GET", "/remove", body={"x": 2}) + client.start("mock") + + try: + client.remove_mock("GET", "/remove") + + mocks = client.list_mocks() + assert "GET /keep" in mocks + assert "GET /remove" not in mocks + finally: + client.stop() + + def test_context_manager_mock_endpoint(self, client, proxy_port): + _start_mock_with_endpoints(client, proxy_port, [ + ("GET", "/api/v1/base", {"body": {"base": True}}), + ]) + + try: + proxies = {"http": f"http://127.0.0.1:{proxy_port}"} + + # Verify base mock works + resp_base = requests.get( + "http://example.com/api/v1/base", + proxies=proxies, timeout=10, + ) + assert resp_base.status_code == 200 + assert resp_base.json()["base"] is True + + # Use mock_endpoint context manager to add a temporary mock + with client.mock_endpoint( + "GET", "/api/v1/temp", + body={"temporary": True}, + ): + # Allow addon to detect config change + time.sleep(1) + response = requests.get( + "http://example.com/api/v1/temp", + proxies=proxies, timeout=10, + ) + assert response.status_code == 200 + assert response.json()["temporary"] is True + + # After exiting the context manager, mock should be removed + mocks = client.list_mocks() + assert "GET /api/v1/temp" not in mocks + finally: + client.stop() + + def test_hot_reload_mocks(self, client, proxy_port): + """Verify that mocks added after start are picked up via hot-reload.""" + client.start("mock") + assert _wait_for_port("127.0.0.1", proxy_port) + + try: + # Set mock after proxy is already running + client.set_mock( + "GET", "/api/v1/hotreload", + body={"reloaded": True}, + ) + # Give the addon time to detect the file change on next request + time.sleep(1) + + response = requests.get( + "http://example.com/api/v1/hotreload", + proxies={"http": f"http://127.0.0.1:{proxy_port}"}, + timeout=10, + ) + assert response.status_code == 200 + assert response.json()["reloaded"] is True + finally: + client.stop() + + +@pytest.mark.skipif( + not _can_reach_internet(), + reason="No internet connectivity (httpbin.org unreachable)", +) +class TestPassthrough: + """Real HTTP through proxy to the internet.""" + + def test_passthrough_to_public_api(self, client, proxy_port): + client.start("passthrough") + assert _wait_for_port("127.0.0.1", proxy_port), ( + f"mitmdump did not start on port {proxy_port}" + ) + + try: + response = requests.get( + "http://httpbin.org/get", + proxies={"http": f"http://127.0.0.1:{proxy_port}"}, + timeout=15, + ) + assert response.status_code == 200 + data = response.json() + assert "headers" in data + finally: + client.stop() + + +class TestRequestCapture: + """End-to-end tests for request capture via the proxy.""" + + def test_captured_requests_appear(self, client, proxy_port): + _start_mock_with_endpoints(client, proxy_port, [ + ("GET", "/api/v1/status", { + "body": {"id": "test-001", "online": True}, + }), + ]) + + try: + client.clear_captured_requests() + + requests.get( + "http://example.com/api/v1/status", + proxies={"http": f"http://127.0.0.1:{proxy_port}"}, + timeout=10, + ) + # Wait for the capture event to arrive + result = client.wait_for_request("GET", "/api/v1/status", 5.0) + assert result["method"] == "GET" + assert result["path"] == "/api/v1/status" + assert result["response_status"] == 200 + assert result["was_mocked"] is True + finally: + client.stop() + + def test_clear_captured_requests(self, client, proxy_port): + _start_mock_with_endpoints(client, proxy_port, [ + ("GET", "/api/v1/health", {"body": {"ok": True}}), + ]) + + try: + requests.get( + "http://example.com/api/v1/health", + proxies={"http": f"http://127.0.0.1:{proxy_port}"}, + timeout=10, + ) + # Wait for capture + client.wait_for_request("GET", "/api/v1/health", 5.0) + + result = client.clear_captured_requests() + assert "Cleared" in result + + captured = client.get_captured_requests() + assert len(captured) == 0 + finally: + client.stop() + + def test_wait_for_request(self, client, proxy_port): + _start_mock_with_endpoints(client, proxy_port, [ + ("GET", "/api/v1/delayed", {"body": {"ok": True}}), + ]) + + try: + client.clear_captured_requests() + + # Send request after a short delay in background + def delayed_request(): + time.sleep(1) + requests.get( + "http://example.com/api/v1/delayed", + proxies={"http": f"http://127.0.0.1:{proxy_port}"}, + timeout=10, + ) + + t = threading.Thread(target=delayed_request) + t.start() + + result = client.wait_for_request("GET", "/api/v1/delayed", 10.0) + assert result["method"] == "GET" + assert result["path"] == "/api/v1/delayed" + + t.join(timeout=5) + finally: + client.stop() + + def test_wait_for_request_timeout(self, client, proxy_port): + _start_mock_with_endpoints(client, proxy_port, [ + ("GET", "/api/v1/status", {"body": {"ok": True}}), + ]) + + try: + client.clear_captured_requests() + with pytest.raises(TimeoutError): + client.wait_for_request("GET", "/api/nonexistent", 1.0) + finally: + client.stop() + + def test_capture_context_manager(self, client, proxy_port): + _start_mock_with_endpoints(client, proxy_port, [ + ("GET", "/api/v1/status", { + "body": {"id": "test-001"}, + }), + ]) + + try: + with client.capture() as cap: + requests.get( + "http://example.com/api/v1/status", + proxies={"http": f"http://127.0.0.1:{proxy_port}"}, + timeout=10, + ) + cap.wait_for_request("GET", "/api/v1/status", 5.0) + + # After exit, snapshot is frozen + assert len(cap.requests) >= 1 + assert cap.requests[0]["method"] == "GET" + finally: + client.stop() + + def test_assert_request_made(self, client, proxy_port): + _start_mock_with_endpoints(client, proxy_port, [ + ("GET", "/api/v1/health", {"body": {"ok": True}}), + ]) + + try: + client.clear_captured_requests() + + requests.get( + "http://example.com/api/v1/health", + proxies={"http": f"http://127.0.0.1:{proxy_port}"}, + timeout=10, + ) + # Wait for capture to arrive + client.wait_for_request("GET", "/api/v1/health", 5.0) + + # Should pass + result = client.assert_request_made("GET", "/api/v1/health") + assert result["method"] == "GET" + + # Should fail + with pytest.raises(AssertionError, match="not captured"): + client.assert_request_made("POST", "/api/v1/missing") + finally: + client.stop() + + def test_multiple_requests_captured_in_order(self, client, proxy_port): + _start_mock_with_endpoints(client, proxy_port, [ + ("GET", "/api/v1/first", {"body": {"n": 1}}), + ("GET", "/api/v1/second", {"body": {"n": 2}}), + ("GET", "/api/v1/third", {"body": {"n": 3}}), + ]) + + try: + client.clear_captured_requests() + proxies = {"http": f"http://127.0.0.1:{proxy_port}"} + + requests.get( + "http://example.com/api/v1/first", + proxies=proxies, timeout=10, + ) + requests.get( + "http://example.com/api/v1/second", + proxies=proxies, timeout=10, + ) + requests.get( + "http://example.com/api/v1/third", + proxies=proxies, timeout=10, + ) + + # Wait for the last request to be captured + client.wait_for_request("GET", "/api/v1/third", 5.0) + + captured = client.get_captured_requests() + assert len(captured) >= 3 + + paths = [r["path"] for r in captured] + assert "/api/v1/first" in paths + assert "/api/v1/second" in paths + assert "/api/v1/third" in paths + + # Verify ordering: first should appear before second, + # second before third + idx_first = paths.index("/api/v1/first") + idx_second = paths.index("/api/v1/second") + idx_third = paths.index("/api/v1/third") + assert idx_first < idx_second < idx_third + finally: + client.stop() + + +class TestConditionalMocks: + """Conditional mock rules with real HTTP requests through the proxy.""" + + def test_conditional_body_json_match(self, client, proxy_port): + """POST with matching JSON body → 200, non-matching → 401.""" + client.set_mock_conditional("POST", "/api/auth", [ + { + "match": {"body_json": {"username": "admin", + "password": "secret"}}, + "status": 200, + "body": {"token": "mock-token-001"}, + }, + {"status": 401, "body": {"error": "unauthorized"}}, + ]) + client.start("mock") + assert _wait_for_port("127.0.0.1", proxy_port) + + try: + proxies = {"http": f"http://127.0.0.1:{proxy_port}"} + + # Matching credentials → 200 + resp_ok = requests.post( + "http://example.com/api/auth", + json={"username": "admin", "password": "secret"}, + proxies=proxies, timeout=10, + ) + assert resp_ok.status_code == 200 + assert resp_ok.json()["token"] == "mock-token-001" + + # Wrong credentials → 401 (fallback) + resp_fail = requests.post( + "http://example.com/api/auth", + json={"username": "hacker", "password": "wrong"}, + proxies=proxies, timeout=10, + ) + assert resp_fail.status_code == 401 + assert resp_fail.json()["error"] == "unauthorized" + finally: + client.stop() + + def test_conditional_header_match(self, client, proxy_port): + """GET with matching header → 200, without → 401.""" + client.set_mock_conditional("GET", "/api/data", [ + { + "match": {"headers": {"Authorization": "Bearer tok123"}}, + "status": 200, + "body": {"items": [1, 2, 3]}, + }, + {"status": 401, "body": {"error": "unauthorized"}}, + ]) + client.start("mock") + assert _wait_for_port("127.0.0.1", proxy_port) + + try: + proxies = {"http": f"http://127.0.0.1:{proxy_port}"} + + # With correct auth header → 200 + resp_ok = requests.get( + "http://example.com/api/data", + headers={"Authorization": "Bearer tok123"}, + proxies=proxies, timeout=10, + ) + assert resp_ok.status_code == 200 + assert resp_ok.json()["items"] == [1, 2, 3] + + # Without auth header → 401 + resp_fail = requests.get( + "http://example.com/api/data", + proxies=proxies, timeout=10, + ) + assert resp_fail.status_code == 401 + finally: + client.stop() + + def test_conditional_query_match(self, client, proxy_port): + """GET with matching query param → 200, without → default.""" + client.set_mock_conditional("GET", "/api/search", [ + { + "match": {"query": {"q": "hello"}}, + "status": 200, + "body": {"results": ["hello world"]}, + }, + {"status": 200, "body": {"results": []}}, + ]) + client.start("mock") + assert _wait_for_port("127.0.0.1", proxy_port) + + try: + proxies = {"http": f"http://127.0.0.1:{proxy_port}"} + + # Matching query param + resp_match = requests.get( + "http://example.com/api/search?q=hello", + proxies=proxies, timeout=10, + ) + assert resp_match.status_code == 200 + assert resp_match.json()["results"] == ["hello world"] + + # No query param → fallback + resp_default = requests.get( + "http://example.com/api/search", + proxies=proxies, timeout=10, + ) + assert resp_default.status_code == 200 + assert resp_default.json()["results"] == [] + finally: + client.stop() + + def test_conditional_with_template(self, client, proxy_port): + """Rule containing body_template with dynamic expressions.""" + client.set_mock_conditional("GET", "/api/echo", [ + { + "match": {"headers": {"X-Mode": "dynamic"}}, + "status": 200, + "body_template": { + "path": "{{request_path}}", + "mode": "dynamic", + }, + }, + {"status": 200, "body": {"mode": "static"}}, + ]) + client.start("mock") + assert _wait_for_port("127.0.0.1", proxy_port) + + try: + proxies = {"http": f"http://127.0.0.1:{proxy_port}"} + + # With dynamic header → template response + resp = requests.get( + "http://example.com/api/echo", + headers={"X-Mode": "dynamic"}, + proxies=proxies, timeout=10, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["mode"] == "dynamic" + assert "/api/echo" in data["path"] + + # Without header → static fallback + resp_static = requests.get( + "http://example.com/api/echo", + proxies=proxies, timeout=10, + ) + assert resp_static.json()["mode"] == "static" + finally: + client.stop() + + +class TestEnhancedTemplates: + """Tests for enhanced template expressions with real HTTP requests.""" + + def test_request_body_json_in_template(self, client, proxy_port): + """Echo a JSON field from request body via template.""" + client.set_mock_template( + "POST", "/api/echo", + template={"echoed_name": "{{request_body_json(name)}}"}, + ) + client.start("mock") + assert _wait_for_port("127.0.0.1", proxy_port) + + try: + proxies = {"http": f"http://127.0.0.1:{proxy_port}"} + + resp = requests.post( + "http://example.com/api/echo", + json={"name": "Alice", "age": 30}, + proxies=proxies, timeout=10, + ) + assert resp.status_code == 200 + assert resp.json()["echoed_name"] == "Alice" + finally: + client.stop() + + def test_request_query_in_template(self, client, proxy_port): + """Echo a query param in the response via template.""" + client.set_mock_template( + "GET", "/api/greet", + template={"greeting": "Hello, {{request_query(name)}}!"}, + ) + client.start("mock") + assert _wait_for_port("127.0.0.1", proxy_port) + + try: + proxies = {"http": f"http://127.0.0.1:{proxy_port}"} + + resp = requests.get( + "http://example.com/api/greet?name=Bob", + proxies=proxies, timeout=10, + ) + assert resp.status_code == 200 + assert resp.json()["greeting"] == "Hello, Bob!" + finally: + client.stop() + + def test_state_in_template(self, client, proxy_port): + """Set state then read it via {{state(key)}} in template.""" + client.set_state("current_user", "Alice") + client.set_mock_template( + "GET", "/api/whoami", + template={"user": "{{state(current_user)}}"}, + ) + client.start("mock") + assert _wait_for_port("127.0.0.1", proxy_port) + + try: + proxies = {"http": f"http://127.0.0.1:{proxy_port}"} + + resp = requests.get( + "http://example.com/api/whoami", + proxies=proxies, timeout=10, + ) + assert resp.status_code == 200 + assert resp.json()["user"] == "Alice" + finally: + client.stop() + + +class TestAuthScenario: + """Full auth token flow using conditional rules.""" + + def test_auth_token_flow(self, client, proxy_port): + """Login with credentials → get token → use token for data.""" + # Auth endpoint: correct creds → token, else 401 + client.set_mock_conditional("POST", "/api/auth", [ + { + "match": {"body_json": {"username": "admin", + "password": "secret"}}, + "status": 200, + "body": {"token": "mock-token-001"}, + }, + {"status": 401, "body": {"error": "unauthorized"}}, + ]) + + # Data endpoint: valid token → data, else 401 + client.set_mock_conditional("GET", "/api/data", [ + { + "match": {"headers": { + "Authorization": "Bearer mock-token-001", + }}, + "status": 200, + "body": {"items": [1, 2, 3]}, + }, + {"status": 401, "body": {"error": "unauthorized"}}, + ]) + + client.start("mock") + assert _wait_for_port("127.0.0.1", proxy_port) + + try: + proxies = {"http": f"http://127.0.0.1:{proxy_port}"} + + # Step 1: Login with correct credentials + login_resp = requests.post( + "http://example.com/api/auth", + json={"username": "admin", "password": "secret"}, + proxies=proxies, timeout=10, + ) + assert login_resp.status_code == 200 + token = login_resp.json()["token"] + assert token == "mock-token-001" + + # Step 2: Access data with token + data_resp = requests.get( + "http://example.com/api/data", + headers={"Authorization": f"Bearer {token}"}, + proxies=proxies, timeout=10, + ) + assert data_resp.status_code == 200 + assert data_resp.json()["items"] == [1, 2, 3] + + # Step 3: Access data without token → 401 + unauth_resp = requests.get( + "http://example.com/api/data", + proxies=proxies, timeout=10, + ) + assert unauth_resp.status_code == 401 + + # Step 4: Login with wrong credentials → 401 + bad_login = requests.post( + "http://example.com/api/auth", + json={"username": "hacker", "password": "nope"}, + proxies=proxies, timeout=10, + ) + assert bad_login.status_code == 401 + finally: + client.stop() diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver_test.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver_test.py new file mode 100644 index 000000000..191f2ab39 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver_test.py @@ -0,0 +1,870 @@ +""" +Tests for the mitmproxy Jumpstarter driver and client. + +These tests verify the driver/client contract using Jumpstarter's +local testing harness (no real hardware or network needed). +""" + +from __future__ import annotations + +import json +import socket +import time +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from jumpstarter_driver_mitmproxy.driver import MitmproxyDriver + + +@pytest.fixture +def driver(tmp_path): + """Create a MitmproxyDriver with temp directories.""" + d = MitmproxyDriver( + listen={"host": "127.0.0.1", "port": 18080}, + web={"host": "127.0.0.1", "port": 18081}, + directories={ + "data": str(tmp_path / "data"), + "conf": str(tmp_path / "confdir"), + "flows": str(tmp_path / "flows"), + "addons": str(tmp_path / "addons"), + "mocks": str(tmp_path / "mocks"), + "files": str(tmp_path / "files"), + }, + ssl_insecure=True, + ) + yield d + # Ensure capture server is cleaned up after each test + d._stop_capture_server() + + +class TestMockManagement: + """Test mock endpoint CRUD operations (no subprocess needed).""" + + def test_set_mock_creates_config_file(self, driver, tmp_path): + result = driver.set_mock( + "GET", "/api/v1/status", 200, + '{"id": "test-001"}', "application/json", "{}", + ) + + assert "Mock set" in result + config = tmp_path / "mocks" / "endpoints.json" + assert config.exists() + + data = json.loads(config.read_text()) + endpoints = data.get("endpoints", data) + assert "GET /api/v1/status" in endpoints + assert endpoints["GET /api/v1/status"]["status"] == 200 + + def test_remove_mock(self, driver): + driver.set_mock("GET", "/api/test", 200, '{}', "application/json", "{}") + result = driver.remove_mock("GET", "/api/test") + assert "Removed" in result + + def test_remove_nonexistent_mock(self, driver): + result = driver.remove_mock("GET", "/api/nonexistent") + assert "not found" in result + + def test_clear_mocks(self, driver): + driver.set_mock("GET", "/a", 200, '{}', "application/json", "{}") + driver.set_mock("POST", "/b", 201, '{}', "application/json", "{}") + result = driver.clear_mocks() + assert "Cleared 2" in result + + def test_list_mocks(self, driver): + driver.set_mock("GET", "/api/v1/health", 200, '{"ok": true}', + "application/json", "{}") + mocks = json.loads(driver.list_mocks()) + assert "GET /api/v1/health" in mocks + + def test_load_scenario(self, driver, tmp_path): + scenario = { + "GET /api/v1/status": { + "status": 200, + "body": {"id": "test-001"}, + }, + "POST /api/v1/telemetry": { + "status": 202, + "body": {"accepted": True}, + }, + } + scenario_file = tmp_path / "mocks" / "test-scenario.json" + scenario_file.parent.mkdir(parents=True, exist_ok=True) + scenario_file.write_text(json.dumps(scenario)) + + result = driver.load_mock_scenario("test-scenario.json") + assert "2 endpoint(s)" in result + + def test_load_missing_scenario(self, driver): + result = driver.load_mock_scenario("nonexistent.json") + assert "not found" in result + + def test_load_yaml_scenario(self, driver, tmp_path): + yaml_content = ( + "endpoints:\n" + " GET /api/v1/status:\n" + " status: 200\n" + " body:\n" + " id: device-001\n" + " firmware_version: \"2.5.1\"\n" + ) + scenario_file = tmp_path / "mocks" / "test.yaml" + scenario_file.parent.mkdir(parents=True, exist_ok=True) + scenario_file.write_text(yaml_content) + + result = driver.load_mock_scenario("test.yaml") + assert "1 endpoint(s)" in result + + config = tmp_path / "mocks" / "endpoints.json" + data = json.loads(config.read_text()) + ep = data["endpoints"]["GET /api/v1/status"] + assert ep["status"] == 200 + assert ep["body"]["id"] == "device-001" + assert ep["body"]["firmware_version"] == "2.5.1" + + def test_load_yml_extension(self, driver, tmp_path): + yaml_content = ( + "endpoints:\n" + " POST /api/v1/data:\n" + " status: 201\n" + " body: {accepted: true}\n" + ) + scenario_file = tmp_path / "mocks" / "test.yml" + scenario_file.parent.mkdir(parents=True, exist_ok=True) + scenario_file.write_text(yaml_content) + + result = driver.load_mock_scenario("test.yml") + assert "1 endpoint(s)" in result + + def test_load_yaml_with_comments(self, driver, tmp_path): + yaml_content = ( + "# This is a comment\n" + "endpoints:\n" + " # Auth endpoint\n" + " GET /api/v1/auth:\n" + " status: 200\n" + " body: {token: abc}\n" + ) + scenario_file = tmp_path / "mocks" / "commented.yaml" + scenario_file.parent.mkdir(parents=True, exist_ok=True) + scenario_file.write_text(yaml_content) + + result = driver.load_mock_scenario("commented.yaml") + assert "1 endpoint(s)" in result + + def test_load_invalid_yaml(self, driver, tmp_path): + scenario_file = tmp_path / "mocks" / "bad.yaml" + scenario_file.parent.mkdir(parents=True, exist_ok=True) + scenario_file.write_text("endpoints:\n - :\n bad:: [yaml\n") + + result = driver.load_mock_scenario("bad.yaml") + assert "Failed to load scenario" in result + + def test_load_json_still_works(self, driver, tmp_path): + scenario = { + "endpoints": { + "GET /api/v1/health": { + "status": 200, + "body": {"ok": True}, + } + } + } + scenario_file = tmp_path / "mocks" / "compat.json" + scenario_file.parent.mkdir(parents=True, exist_ok=True) + scenario_file.write_text(json.dumps(scenario)) + + result = driver.load_mock_scenario("compat.json") + assert "1 endpoint(s)" in result + + +class TestStatus: + """Test status reporting.""" + + def test_status_when_stopped(self, driver): + info = json.loads(driver.status()) + assert info["running"] is False + assert info["mode"] == "stopped" + assert info["pid"] is None + + def test_is_running_when_stopped(self, driver): + assert driver.is_running() is False + + +class TestConnectWeb: + """Test the connect_web exportstream method.""" + + def test_connect_web_is_exported(self, driver): + """Verify connect_web is registered as an exported stream method.""" + assert hasattr(driver, "connect_web") + assert callable(driver.connect_web) + + +class TestLifecycle: + """Test start/stop with mocked subprocess.""" + + @patch("jumpstarter_driver_mitmproxy.driver.subprocess.Popen") + def test_start_mock_mode(self, mock_popen, driver): + proc = MagicMock() + proc.poll.return_value = None # process is running + proc.pid = 12345 + mock_popen.return_value = proc + + result = driver.start("mock", False, "") + + assert "mock" in result + assert "8080" in result or "18080" in result + assert driver.is_running() + + # Verify mitmdump was called (not mitmweb) + cmd = mock_popen.call_args[0][0] + assert cmd[0] == "mitmdump" + + @patch("jumpstarter_driver_mitmproxy.driver.subprocess.Popen") + def test_start_with_web_ui(self, mock_popen, driver): + proc = MagicMock() + proc.poll.return_value = None + proc.pid = 12345 + mock_popen.return_value = proc + + result = driver.start("mock", True, "") + + assert "Web UI" in result + cmd = mock_popen.call_args[0][0] + assert cmd[0] == "mitmweb" + assert "--web-port" in cmd + + @patch("jumpstarter_driver_mitmproxy.driver.subprocess.Popen") + def test_start_record_mode(self, mock_popen, driver): + proc = MagicMock() + proc.poll.return_value = None + proc.pid = 12345 + mock_popen.return_value = proc + + result = driver.start("record", False, "") + + assert "record" in result + assert "Recording to" in result + cmd = mock_popen.call_args[0][0] + assert "-w" in cmd + + @patch("jumpstarter_driver_mitmproxy.driver.subprocess.Popen") + def test_start_replay_requires_file(self, mock_popen, driver): + result = driver.start("replay", False, "") + assert "Error" in result + mock_popen.assert_not_called() + + @patch("jumpstarter_driver_mitmproxy.driver.subprocess.Popen") + def test_start_replay_checks_file_exists(self, mock_popen, driver, + tmp_path): + result = driver.start("replay", False, "nonexistent.bin") + assert "not found" in result + + @patch("jumpstarter_driver_mitmproxy.driver.subprocess.Popen") + def test_start_unknown_mode(self, mock_popen, driver): + result = driver.start("bogus", False, "") + assert "Unknown mode" in result + + @patch("jumpstarter_driver_mitmproxy.driver.subprocess.Popen") + def test_double_start_rejected(self, mock_popen, driver): + proc = MagicMock() + proc.poll.return_value = None + proc.pid = 12345 + mock_popen.return_value = proc + + driver.start("mock", False, "") + result = driver.start("mock", False, "") + assert "Already running" in result + + @patch("jumpstarter_driver_mitmproxy.driver.subprocess.Popen") + def test_stop(self, mock_popen, driver): + proc = MagicMock() + proc.poll.return_value = None + proc.pid = 12345 + proc.wait.return_value = 0 + mock_popen.return_value = proc + + driver.start("mock", False, "") + result = driver.stop() + + assert "Stopped" in result + assert "mock" in result + proc.send_signal.assert_called_once() + + def test_stop_when_not_running(self, driver): + result = driver.stop() + assert "Not running" in result + + +class TestAddonGeneration: + """Test that the default addon script is generated correctly.""" + + @patch("jumpstarter_driver_mitmproxy.driver.subprocess.Popen") + def test_generates_addon_if_missing(self, mock_popen, driver, tmp_path): + proc = MagicMock() + proc.poll.return_value = None + proc.pid = 12345 + mock_popen.return_value = proc + + driver.start("mock", False, "") + + addon_file = tmp_path / "addons" / "mock_addon.py" + assert addon_file.exists() + + content = addon_file.read_text() + assert "MitmproxyMockAddon" in content + assert "addons = [MitmproxyMockAddon()]" in content + + +class TestCACert: + """Test CA certificate path and content retrieval.""" + + def test_ca_cert_not_found(self, driver): + result = driver.get_ca_cert_path() + assert "not found" in result + + def test_ca_cert_found(self, driver, tmp_path): + cert_path = tmp_path / "confdir" / "mitmproxy-ca-cert.pem" + cert_path.parent.mkdir(parents=True, exist_ok=True) + cert_path.write_text("FAKE CERT") + result = driver.get_ca_cert_path() + assert result == str(cert_path) + + def test_get_ca_cert_not_found(self, driver): + result = driver.get_ca_cert() + assert result.startswith("Error:") + assert "not found" in result + + def test_get_ca_cert_returns_contents(self, driver, tmp_path): + pem_content = "-----BEGIN CERTIFICATE-----\nFAKEDATA\n-----END CERTIFICATE-----\n" + cert_path = tmp_path / "confdir" / "mitmproxy-ca-cert.pem" + cert_path.parent.mkdir(parents=True, exist_ok=True) + cert_path.write_text(pem_content) + result = driver.get_ca_cert() + assert result == pem_content + + +class TestCaptureManagement: + """Test capture request buffer operations (no subprocess needed).""" + + def test_get_captured_requests_empty(self, driver): + result = json.loads(driver.get_captured_requests()) + assert result == [] + + def test_clear_captured_requests_empty(self, driver): + result = driver.clear_captured_requests() + assert "Cleared 0" in result + + def test_captured_requests_buffer(self, driver): + with driver._capture_lock: + driver._captured_requests.append({ + "method": "GET", + "path": "/api/v1/status", + "timestamp": 1700000000.0, + }) + result = json.loads(driver.get_captured_requests()) + assert len(result) == 1 + assert result[0]["method"] == "GET" + assert result[0]["path"] == "/api/v1/status" + + def test_clear_with_items(self, driver): + with driver._capture_lock: + driver._captured_requests.extend([ + {"method": "GET", "path": "/a"}, + {"method": "POST", "path": "/b"}, + ]) + result = driver.clear_captured_requests() + assert "Cleared 2" in result + assert json.loads(driver.get_captured_requests()) == [] + + def test_wait_for_request_immediate_match(self, driver): + with driver._capture_lock: + driver._captured_requests.append({ + "method": "GET", "path": "/api/v1/status", + }) + result = json.loads( + driver.wait_for_request("GET", "/api/v1/status", 1.0) + ) + assert result["method"] == "GET" + assert result["path"] == "/api/v1/status" + + def test_wait_for_request_timeout(self, driver): + result = json.loads( + driver.wait_for_request("GET", "/api/nonexistent", 0.5) + ) + assert "error" in result + assert "Timed out" in result["error"] + + def test_wait_for_request_wildcard(self, driver): + with driver._capture_lock: + driver._captured_requests.append({ + "method": "GET", "path": "/api/v1/users/123", + }) + result = json.loads( + driver.wait_for_request("GET", "/api/v1/users/*", 1.0) + ) + assert result["path"] == "/api/v1/users/123" + + def test_request_matches_exact(self): + req = {"method": "GET", "path": "/api/v1/status"} + assert MitmproxyDriver._request_matches(req, "GET", "/api/v1/status") + assert not MitmproxyDriver._request_matches(req, "POST", "/api/v1/status") + assert not MitmproxyDriver._request_matches(req, "GET", "/api/v1/other") + + def test_request_matches_wildcard(self): + req = {"method": "GET", "path": "/api/v1/users/456"} + assert MitmproxyDriver._request_matches(req, "GET", "/api/v1/users/*") + assert MitmproxyDriver._request_matches(req, "GET", "/api/*") + assert not MitmproxyDriver._request_matches(req, "GET", "/other/*") + + +class TestCaptureSocket: + """Test the capture Unix socket lifecycle.""" + + @patch("jumpstarter_driver_mitmproxy.driver.subprocess.Popen") + def test_socket_created_on_start(self, mock_popen, driver, tmp_path): + proc = MagicMock() + proc.poll.return_value = None + proc.pid = 12345 + mock_popen.return_value = proc + + driver.start("mock", False, "") + + sock_path = Path(driver._capture_socket_path) + assert sock_path.exists() + + driver.stop() + + @patch("jumpstarter_driver_mitmproxy.driver.subprocess.Popen") + def test_socket_cleaned_up_on_stop(self, mock_popen, driver, tmp_path): + proc = MagicMock() + proc.poll.return_value = None + proc.pid = 12345 + proc.wait.return_value = 0 + mock_popen.return_value = proc + + driver.start("mock", False, "") + sock_path = driver._capture_socket_path + + driver.stop() + + assert not Path(sock_path).exists() + assert driver._capture_socket_path is None + + @patch("jumpstarter_driver_mitmproxy.driver.subprocess.Popen") + def test_socket_receives_events(self, mock_popen, driver, tmp_path): + proc = MagicMock() + proc.poll.return_value = None + proc.pid = 12345 + mock_popen.return_value = proc + + driver.start("mock", False, "") + + # Connect to the capture socket and send an event + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + sock.connect(driver._capture_socket_path) + event = { + "method": "GET", + "path": "/test", + "timestamp": time.time(), + "response_status": 200, + } + sock.sendall((json.dumps(event) + "\n").encode()) + # Give the reader thread time to process + time.sleep(0.5) + + captured = json.loads(driver.get_captured_requests()) + assert len(captured) == 1 + assert captured[0]["method"] == "GET" + assert captured[0]["path"] == "/test" + finally: + sock.close() + driver.stop() + + +class TestConditionalMocks: + """Test conditional mock endpoint operations (no subprocess needed).""" + + def test_set_conditional_creates_config(self, driver, tmp_path): + rules = [ + { + "match": {"body_json": {"username": "admin"}}, + "status": 200, + "body": {"token": "abc"}, + }, + {"status": 401, "body": {"error": "unauthorized"}}, + ] + result = driver.set_mock_conditional( + "POST", "/api/auth", json.dumps(rules), + ) + assert "Conditional mock set" in result + assert "2 rule(s)" in result + + config = tmp_path / "mocks" / "endpoints.json" + assert config.exists() + + data = json.loads(config.read_text()) + endpoints = data.get("endpoints", data) + assert "POST /api/auth" in endpoints + assert "rules" in endpoints["POST /api/auth"] + assert len(endpoints["POST /api/auth"]["rules"]) == 2 + + def test_set_conditional_invalid_json(self, driver): + result = driver.set_mock_conditional( + "POST", "/api/auth", "not-valid-json", + ) + assert "Invalid JSON" in result + + def test_set_conditional_empty_rules(self, driver): + result = driver.set_mock_conditional( + "POST", "/api/auth", "[]", + ) + assert "non-empty" in result + + def test_conditional_and_remove(self, driver): + rules = [{"status": 200, "body": {"ok": True}}] + driver.set_mock_conditional( + "GET", "/api/test", json.dumps(rules), + ) + result = driver.remove_mock("GET", "/api/test") + assert "Removed" in result + + mocks = json.loads(driver.list_mocks()) + assert "GET /api/test" not in mocks + + def test_conditional_listed_in_mocks(self, driver): + rules = [ + {"match": {"headers": {"X-Key": "abc"}}, + "status": 200, "body": {"ok": True}}, + {"status": 403, "body": {"error": "forbidden"}}, + ] + driver.set_mock_conditional( + "GET", "/api/data", json.dumps(rules), + ) + + mocks = json.loads(driver.list_mocks()) + assert "GET /api/data" in mocks + assert "rules" in mocks["GET /api/data"] + + +class TestStateStore: + """Test shared state store operations (no subprocess needed).""" + + def test_set_and_get_state(self, driver): + driver.set_state("token", json.dumps("abc-123")) + result = json.loads(driver.get_state("token")) + assert result == "abc-123" + + def test_set_state_complex_value(self, driver): + driver.set_state("config", json.dumps({"retries": 3, "debug": True})) + result = json.loads(driver.get_state("config")) + assert result == {"retries": 3, "debug": True} + + def test_get_nonexistent_state(self, driver): + result = json.loads(driver.get_state("nonexistent")) + assert result is None + + def test_clear_state(self, driver): + driver.set_state("a", json.dumps(1)) + driver.set_state("b", json.dumps(2)) + result = driver.clear_state() + assert "Cleared 2" in result + + assert json.loads(driver.get_state("a")) is None + assert json.loads(driver.get_state("b")) is None + + def test_get_all_state(self, driver): + driver.set_state("x", json.dumps(10)) + driver.set_state("y", json.dumps("hello")) + all_state = json.loads(driver.get_all_state()) + assert all_state == {"x": 10, "y": "hello"} + + def test_state_file_written(self, driver, tmp_path): + driver.set_state("key", json.dumps("value")) + + state_file = tmp_path / "mocks" / "state.json" + assert state_file.exists() + + data = json.loads(state_file.read_text()) + assert data["key"] == "value" + + +class TestConfigValidation: + """Test Pydantic config validation and defaults.""" + + def test_defaults_from_data_dir(self): + d = MitmproxyDriver( + directories={"data": "/tmp/myproxy"}, + ) + try: + assert d.directories.data == "/tmp/myproxy" + assert d.directories.conf == "/tmp/myproxy/conf" + assert d.directories.flows == "/tmp/myproxy/flows" + assert d.directories.addons == "/tmp/myproxy/addons" + assert d.directories.mocks == "/tmp/myproxy/mock-responses" + assert d.directories.files == "/tmp/myproxy/mock-files" + assert d.listen.host == "127.0.0.1" + assert d.listen.port == 8080 + assert d.web.host == "127.0.0.1" + assert d.web.port == 8081 + finally: + d._stop_capture_server() + + def test_partial_directory_override(self): + d = MitmproxyDriver( + directories={ + "data": "/tmp/myproxy", + "conf": "/etc/mitmproxy", + }, + ) + try: + assert d.directories.conf == "/etc/mitmproxy" + assert d.directories.flows == "/tmp/myproxy/flows" + finally: + d._stop_capture_server() + + def test_inline_mocks_preloaded(self, tmp_path): + inline = { + "GET /api/health": {"status": 200, "body": {"ok": True}}, + } + d = MitmproxyDriver( + directories={ + "data": str(tmp_path / "data"), + "mocks": str(tmp_path / "mocks"), + "addons": str(tmp_path / "addons"), + }, + mocks=inline, + ) + try: + assert d.mocks == inline + finally: + d._stop_capture_server() + + +@pytest.fixture +def deep_merge_patch(): + """Import _deep_merge_patch lazily to avoid module-level side effects.""" + import importlib + import sys + # Temporarily mock Path.mkdir to prevent /opt/jumpstarter creation + original_mkdir = Path.mkdir + + def safe_mkdir(self, *args, **kwargs): + if str(self).startswith("/opt/"): + return + return original_mkdir(self, *args, **kwargs) + + Path.mkdir = safe_mkdir + try: + if "jumpstarter_driver_mitmproxy.bundled_addon" in sys.modules: + mod = sys.modules["jumpstarter_driver_mitmproxy.bundled_addon"] + else: + mod = importlib.import_module( + "jumpstarter_driver_mitmproxy.bundled_addon" + ) + return mod._deep_merge_patch + finally: + Path.mkdir = original_mkdir + + +@pytest.fixture +def apply_patches(deep_merge_patch): + """Import _apply_patches lazily.""" + import sys + mod = sys.modules["jumpstarter_driver_mitmproxy.bundled_addon"] + return mod._apply_patches + + +class TestDeepMergePatch: + """Unit tests for _deep_merge_patch.""" + + def test_simple_dict_merge(self, deep_merge_patch): + target = {"a": 1, "b": 2} + deep_merge_patch(target, {"b": 3, "c": 4}) + assert target == {"a": 1, "b": 3, "c": 4} + + def test_nested_dict_merge(self, deep_merge_patch): + target = {"outer": {"inner": 1, "keep": True}} + deep_merge_patch(target, {"outer": {"inner": 99}}) + assert target == {"outer": {"inner": 99, "keep": True}} + + def test_array_index(self, deep_merge_patch): + target = {"items": [{"name": "a"}, {"name": "b"}]} + deep_merge_patch(target, {"items[1]": {"name": "patched"}}) + assert target["items"][1]["name"] == "patched" + assert target["items"][0]["name"] == "a" + + def test_nested_array_index(self, deep_merge_patch): + target = { + "list": [ + {"sub": {"val": "old", "extra": True}}, + ], + } + deep_merge_patch(target, {"list[0]": {"sub": {"val": "new"}}}) + assert target["list"][0]["sub"]["val"] == "new" + assert target["list"][0]["sub"]["extra"] is True + + def test_scalar_replacement(self, deep_merge_patch): + target = {"a": {"b": [1, 2, 3]}} + deep_merge_patch(target, {"a": {"b": [10]}}) + assert target["a"]["b"] == [10] + + def test_sibling_fields_at_same_level(self, deep_merge_patch): + target = {"a": 1, "b": 2, "c": 3} + deep_merge_patch(target, {"a": 10, "c": 30}) + assert target == {"a": 10, "b": 2, "c": 30} + + def test_array_scalar_replacement(self, deep_merge_patch): + target = {"items": ["a", "b", "c"]} + deep_merge_patch(target, {"items[2]": "z"}) + assert target["items"] == ["a", "b", "z"] + + def test_missing_key_raises(self, deep_merge_patch): + target = {"a": 1} + with pytest.raises(KeyError): + deep_merge_patch(target, {"nonexistent[0]": "val"}) + + +class TestApplyPatches: + """Unit tests for _apply_patches.""" + + def test_basic_patch(self, apply_patches): + body = json.dumps({"status": "active", "count": 5}).encode() + result = apply_patches(body, {"status": "inactive"}, None, None) + assert result is not None + parsed = json.loads(result) + assert parsed["status"] == "inactive" + assert parsed["count"] == 5 + + def test_non_json_returns_none(self, apply_patches): + result = apply_patches(b"not json", {"key": "val"}, None, None) + assert result is None + + def test_empty_body_returns_none(self, apply_patches): + result = apply_patches(b"", {"key": "val"}, None, None) + assert result is None + + def test_nested_patch(self, apply_patches): + body = json.dumps({ + "response": {"data": {"value": "old", "other": 1}}, + }).encode() + result = apply_patches( + body, {"response": {"data": {"value": "new"}}}, None, None, + ) + parsed = json.loads(result) + assert parsed["response"]["data"]["value"] == "new" + assert parsed["response"]["data"]["other"] == 1 + + def test_missing_key_continues_with_partial(self, apply_patches): + """Patches with bad keys log a warning but don't crash.""" + body = json.dumps({"a": 1}).encode() + result = apply_patches( + body, {"missing[0]": "val"}, None, None, + ) + # Should still return valid JSON (partial patch applied) + assert result is not None + parsed = json.loads(result) + assert parsed["a"] == 1 + + +class TestPatchMocks: + """Integration tests for set_mock_patch and patch scenario loading.""" + + def test_set_mock_patch(self, driver, tmp_path): + result = driver.set_mock_patch( + "GET", "/api/v1/status", + '{"data": {"status": "inactive"}}', + ) + assert "Patch mock set" in result + + config = tmp_path / "mocks" / "endpoints.json" + assert config.exists() + + data = json.loads(config.read_text()) + ep = data["endpoints"]["GET /api/v1/status"] + assert "patch" in ep + assert ep["patch"]["data"]["status"] == "inactive" + + def test_set_mock_patch_invalid_json(self, driver): + result = driver.set_mock_patch("GET", "/test", "not json") + assert "Invalid JSON" in result + + def test_set_mock_patch_non_object(self, driver): + result = driver.set_mock_patch("GET", "/test", '"string"') + assert "must be a JSON object" in result + + def test_set_mock_patch_list_and_remove(self, driver): + driver.set_mock_patch( + "GET", "/api/v1/status", + '{"data": {"status": "inactive"}}', + ) + mocks = json.loads(driver.list_mocks()) + assert "GET /api/v1/status" in mocks + assert "patch" in mocks["GET /api/v1/status"] + + result = driver.remove_mock("GET", "/api/v1/status") + assert "Removed" in result + + def test_load_yaml_scenario_with_mocks_key(self, driver, tmp_path): + yaml_content = ( + "mocks:\n" + " https://api.example.com/rest/v3/status:\n" + " - method: GET\n" + " patch:\n" + " account:\n" + " subState: INACTIVE\n" + ) + scenario_file = tmp_path / "mocks" / "test-patch.yaml" + scenario_file.parent.mkdir(parents=True, exist_ok=True) + scenario_file.write_text(yaml_content) + + result = driver.load_mock_scenario("test-patch.yaml") + assert "1 endpoint(s)" in result + + config = tmp_path / "mocks" / "endpoints.json" + data = json.loads(config.read_text()) + ep = data["endpoints"]["GET /rest/v3/status"] + assert "patch" in ep + assert ep["patch"]["account"]["subState"] == "INACTIVE" + + def test_load_scenario_content_with_mocks_key(self, driver): + yaml_content = ( + "mocks:\n" + " https://api.example.com/rest/v3/status:\n" + " - method: GET\n" + " patch:\n" + " status: inactive\n" + ) + result = driver.load_mock_scenario_content( + "test.yaml", yaml_content, + ) + assert "1 endpoint(s)" in result + + def test_patch_survives_flatten_and_convert(self, driver, tmp_path): + """Verify a patch entry round-trips through _flatten_entry + and _convert_url_endpoints correctly.""" + yaml_content = ( + "mocks:\n" + " https://api.example.com/rest/v3/modules/nonPII:\n" + " - method: GET\n" + " patch:\n" + " ModuleListResponse:\n" + " moduleList:\n" + " modules[0]:\n" + " status: Inactive\n" + ) + scenario_file = tmp_path / "mocks" / "roundtrip.yaml" + scenario_file.parent.mkdir(parents=True, exist_ok=True) + scenario_file.write_text(yaml_content) + + driver.load_mock_scenario("roundtrip.yaml") + + config = tmp_path / "mocks" / "endpoints.json" + data = json.loads(config.read_text()) + ep = data["endpoints"]["GET /rest/v3/modules/nonPII"] + assert "patch" in ep + assert ( + ep["patch"]["ModuleListResponse"]["moduleList"]["modules[0]"]["status"] + == "Inactive" + ) diff --git a/python/packages/jumpstarter-driver-mitmproxy/pyproject.toml b/python/packages/jumpstarter-driver-mitmproxy/pyproject.toml new file mode 100644 index 000000000..654851ac5 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "jumpstarter-driver-mitmproxy" +dynamic = ["version", "urls"] +description = "Jumpstarter driver for mitmproxy HTTP(S) interception, mocking, and traffic recording" +readme = "README.md" +authors = [{ name = "Kirk Brauer", email = "kbrauer@hatci.com" }] +requires-python = ">=3.11" +license = "Apache-2.0" +dependencies = ["jumpstarter", "mitmproxy>=10.0"] + +[project.entry-points."jumpstarter.drivers"] +MitmproxyDriver = "jumpstarter_driver_mitmproxy.driver:MitmproxyDriver" + +[tool.pytest.ini_options] +addopts = "--cov --cov-report=html --cov-report=xml" +log_cli = true +log_cli_level = "INFO" +testpaths = [ + "jumpstarter_driver_mitmproxy", + # demo/ and examples/ require a live HiL exporter; run them manually + # with: jmp shell --exporter demo/exporter.yaml -- pytest demo/ -v +] + +[dependency-groups] +dev = ["pytest-cov>=6.0.0", "pytest>=8.3.3", "requests>=2.28.0"] + +[tool.hatch.metadata.hooks.vcs.urls] +Homepage = "https://jumpstarter.dev" +source_archive = "https://github.com/jumpstarter-dev/repo/archive/{commit_hash}.zip" + +[tool.hatch.version] +source = "vcs" +raw-options = { 'root' = '../../../' } + +[build-system] +requires = ["hatchling", "hatch-vcs", "hatch-pin-jumpstarter"] +build-backend = "hatchling.build" + +[tool.hatch.build.hooks.pin_jumpstarter] +name = "pin_jumpstarter" diff --git a/python/pyproject.toml b/python/pyproject.toml index f5789940d..4fa3f6066 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -38,6 +38,7 @@ jumpstarter-driver-uds = { workspace = true } jumpstarter-driver-uds-can = { workspace = true } jumpstarter-driver-uds-doip = { workspace = true } jumpstarter-driver-iscsi = { workspace = true } +jumpstarter-driver-mitmproxy = { workspace = true } jumpstarter-driver-ustreamer = { workspace = true } jumpstarter-driver-yepkit = { workspace = true } jumpstarter-driver-vnc = { workspace = true } diff --git a/python/uv.lock b/python/uv.lock index 7a962b7c1..53b9554ac 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1,6 +1,11 @@ version = 1 revision = 3 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", + "python_full_version < '3.12'", +] [manifest] members = [ @@ -24,6 +29,7 @@ members = [ "jumpstarter-driver-http", "jumpstarter-driver-http-power", "jumpstarter-driver-iscsi", + "jumpstarter-driver-mitmproxy", "jumpstarter-driver-network", "jumpstarter-driver-opendal", "jumpstarter-driver-power", @@ -49,6 +55,7 @@ members = [ "jumpstarter-driver-yepkit", "jumpstarter-example-automotive", "jumpstarter-example-soc-pytest", + "jumpstarter-example-xcp-ecu", "jumpstarter-imagehash", "jumpstarter-kubernetes", "jumpstarter-protocol", @@ -59,7 +66,7 @@ members = [ dev = [ { name = "esbonio", specifier = ">=0.16.5" }, { name = "pre-commit", specifier = ">=3.8.0" }, - { name = "ruff", specifier = "==0.15.2" }, + { name = "ruff", specifier = "==0.15.6" }, { name = "ty", specifier = ">=0.0.1a8" }, { name = "typos", specifier = ">=1.23.6" }, ] @@ -155,6 +162,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/47/b11d0089875a23bff0abd3edb5516bcd454db3fefab8604f5e4b07bd6210/aiohttp-3.12.13-cp313-cp313-win_amd64.whl", hash = "sha256:5a178390ca90419bfd41419a809688c368e63c86bd725e1186dd97f6b89c2706", size = 446735, upload-time = "2025-06-14T15:15:02.858Z" }, ] +[[package]] +name = "aioquic" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cryptography", version = "44.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "cryptography", version = "45.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pylsqpack" }, + { name = "pyopenssl", version = "24.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "pyopenssl", version = "25.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "service-identity" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/1a/bf10b2c57c06c7452b685368cb1ac90565a6e686e84ec6f84465fb8f78f4/aioquic-1.2.0.tar.gz", hash = "sha256:f91263bb3f71948c5c8915b4d50ee370004f20a416f67fab3dcc90556c7e7199", size = 179891, upload-time = "2024-07-06T23:27:09.301Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/03/1c385739e504c70ab2a66a4bc0e7cd95cee084b374dcd4dc97896378400b/aioquic-1.2.0-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3e23964dfb04526ade6e66f5b7cd0c830421b8138303ab60ba6e204015e7cb0b", size = 1753473, upload-time = "2024-07-06T23:26:20.809Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1f/4d1c40714db65be828e1a1e2cce7f8f4b252be67d89f2942f86a1951826c/aioquic-1.2.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:84d733332927b76218a3b246216104116f766f5a9b2308ec306cd017b3049660", size = 2083563, upload-time = "2024-07-06T23:26:24.254Z" }, + { url = "https://files.pythonhosted.org/packages/15/48/56a8c9083d1deea4ccaf1cbf5a91a396b838b4a0f8650f4e9f45c7879a38/aioquic-1.2.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2466499759b31ea4f1d17f4aeb1f8d4297169e05e3c1216d618c9757f4dd740d", size = 2555697, upload-time = "2024-07-06T23:26:26.16Z" }, + { url = "https://files.pythonhosted.org/packages/0f/93/fa4c981a8a8a903648d4cd6e12c0fca7f44e3ef4ba15a8b99a26af05b868/aioquic-1.2.0-cp38-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cd75015462ca5070a888110dc201f35a9f4c7459f9201b77adc3c06013611bb8", size = 2149089, upload-time = "2024-07-06T23:26:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/b0/0f/4a280923313b831892caaa45348abea89e7dd2e4422a86699bb0e506b1dd/aioquic-1.2.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43ae3b11d43400a620ca0b4b4885d12b76a599c2cbddba755f74bebfa65fe587", size = 2205221, upload-time = "2024-07-06T23:26:30.682Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6b/a6a1d1762ce06f13b68f524bb9c5f4d6ca7cda9b072d7e744626b89b77be/aioquic-1.2.0-cp38-abi3-win32.whl", hash = "sha256:910d8c91da86bba003d491d15deaeac3087d1b9d690b9edc1375905d8867b742", size = 1214037, upload-time = "2024-07-06T23:26:32.651Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/e8a8a75c93dee0ab229df3c2d17f63cd44d0ad5ee8540e2ec42779ce3a39/aioquic-1.2.0-cp38-abi3-win_amd64.whl", hash = "sha256:e3dcfb941004333d477225a6689b55fc7f905af5ee6a556eb5083be0354e653a", size = 1530339, upload-time = "2024-07-06T23:26:34.753Z" }, +] + [[package]] name = "aiosignal" version = "1.3.2" @@ -199,6 +230,102 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, ] +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings", version = "21.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "argon2-cffi-bindings", version = "25.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "21.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", +] +dependencies = [ + { name = "cffi", marker = "python_full_version >= '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/e9/184b8ccce6683b0aa2fbb7ba5683ea4b9c5763f1356347f1312c32e3c66e/argon2-cffi-bindings-21.2.0.tar.gz", hash = "sha256:bb89ceffa6c791807d1305ceb77dbfacc5aa499891d2c55661c6459651fc39e3", size = 1779911, upload-time = "2021-12-01T08:52:55.68Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/13/838ce2620025e9666aa8f686431f67a29052241692a3dd1ae9d3692a89d3/argon2_cffi_bindings-21.2.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ccb949252cb2ab3a08c02024acb77cfb179492d5701c7cbdbfd776124d4d2367", size = 29658, upload-time = "2021-12-01T09:09:17.016Z" }, + { url = "https://files.pythonhosted.org/packages/b3/02/f7f7bb6b6af6031edb11037639c697b912e1dea2db94d436e681aea2f495/argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9524464572e12979364b7d600abf96181d3541da11e23ddf565a32e70bd4dc0d", size = 80583, upload-time = "2021-12-01T09:09:19.546Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f7/378254e6dd7ae6f31fe40c8649eea7d4832a42243acaf0f1fff9083b2bed/argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b746dba803a79238e925d9046a63aa26bf86ab2a2fe74ce6b009a1c3f5c8f2ae", size = 86168, upload-time = "2021-12-01T09:09:21.445Z" }, + { url = "https://files.pythonhosted.org/packages/74/f6/4a34a37a98311ed73bb80efe422fed95f2ac25a4cacc5ae1d7ae6a144505/argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58ed19212051f49a523abb1dbe954337dc82d947fb6e5a0da60f7c8471a8476c", size = 82709, upload-time = "2021-12-01T09:09:18.182Z" }, + { url = "https://files.pythonhosted.org/packages/74/2b/73d767bfdaab25484f7e7901379d5f8793cccbb86c6e0cbc4c1b96f63896/argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:bd46088725ef7f58b5a1ef7ca06647ebaf0eb4baff7d1d0d177c6cc8744abd86", size = 83613, upload-time = "2021-12-01T09:09:22.741Z" }, + { url = "https://files.pythonhosted.org/packages/4f/fd/37f86deef67ff57c76f137a67181949c2d408077e2e3dd70c6c42912c9bf/argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_i686.whl", hash = "sha256:8cd69c07dd875537a824deec19f978e0f2078fdda07fd5c42ac29668dda5f40f", size = 84583, upload-time = "2021-12-01T09:09:24.177Z" }, + { url = "https://files.pythonhosted.org/packages/6f/52/5a60085a3dae8fded8327a4f564223029f5f54b0cb0455a31131b5363a01/argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f1152ac548bd5b8bcecfb0b0371f082037e47128653df2e8ba6e914d384f3c3e", size = 88475, upload-time = "2021-12-01T09:09:26.673Z" }, + { url = "https://files.pythonhosted.org/packages/8b/95/143cd64feb24a15fa4b189a3e1e7efbaeeb00f39a51e99b26fc62fbacabd/argon2_cffi_bindings-21.2.0-cp36-abi3-win32.whl", hash = "sha256:603ca0aba86b1349b147cab91ae970c63118a0f30444d4bc80355937c950c082", size = 27698, upload-time = "2021-12-01T09:09:27.87Z" }, + { url = "https://files.pythonhosted.org/packages/37/2c/e34e47c7dee97ba6f01a6203e0383e15b60fb85d78ac9a15cd066f6fe28b/argon2_cffi_bindings-21.2.0-cp36-abi3-win_amd64.whl", hash = "sha256:b2ef1c30440dbbcba7a5dc3e319408b59676e2e039e2ae11a8775ecf482b192f", size = 30817, upload-time = "2021-12-01T09:09:30.267Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e4/bf8034d25edaa495da3c8a3405627d2e35758e44ff6eaa7948092646fdcc/argon2_cffi_bindings-21.2.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e415e3f62c8d124ee16018e491a009937f8cf7ebf5eb430ffc5de21b900dad93", size = 53104, upload-time = "2021-12-01T09:09:31.335Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and python_full_version < '3.14'", +] +dependencies = [ + { name = "cffi", marker = "python_full_version >= '3.12' and python_full_version < '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +] + +[[package]] +name = "asgiref" +version = "3.8.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186, upload-time = "2024-03-22T14:39:36.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828, upload-time = "2024-03-22T14:39:34.521Z" }, +] + +[[package]] +name = "asgiref" +version = "3.10.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] +sdist = { url = "https://files.pythonhosted.org/packages/46/08/4dfec9b90758a59acc6be32ac82e98d1fbfc321cb5cfa410436dbacf821c/asgiref-3.10.0.tar.gz", hash = "sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e", size = 37483, upload-time = "2025-10-05T09:15:06.557Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/9c/fc2331f538fbf7eedba64b2052e99ccf9ba9d6888e2f41441ee28847004b/asgiref-3.10.0-py3-none-any.whl", hash = "sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734", size = 24050, upload-time = "2025-10-05T09:15:05.11Z" }, +] + [[package]] name = "attrs" version = "25.3.0" @@ -213,7 +340,8 @@ name = "authlib" version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, + { name = "cryptography", version = "44.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "cryptography", version = "45.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a2/9d/b1e08d36899c12c8b894a44a5583ee157789f26fc4b176f8e4b6217b56e1/authlib-1.6.0.tar.gz", hash = "sha256:4367d32031b7af175ad3a323d571dc7257b7099d55978087ceae4a0d88cd3210", size = 158371, upload-time = "2025-05-23T00:21:45.011Z" } wheels = [ @@ -322,6 +450,9 @@ wheels = [ name = "bcrypt" version = "4.3.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] sdist = { url = "https://files.pythonhosted.org/packages/bb/5d/6d7433e0f3cd46ce0b43cd65e1db465ea024dbb8216fb2404e919c2ad77b/bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18", size = 25697, upload-time = "2025-02-28T01:24:09.174Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/bf/2c/3d44e853d1fe969d229bd58d39ae6902b3d924af0e2b5a60d17d4b809ded/bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281", size = 483719, upload-time = "2025-02-28T01:22:34.539Z" }, @@ -372,6 +503,80 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/13/47bba97924ebe86a62ef83dc75b7c8a881d53c535f83e2c54c4bd701e05c/bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57967b7a28d855313a963aaea51bf6df89f833db4320da458e5b3c5ab6d4c938", size = 280110, upload-time = "2025-02-28T01:24:05.896Z" }, ] +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, + { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, + { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, + { url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" }, + { url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" }, +] + [[package]] name = "beartype" version = "0.21.0" @@ -503,6 +708,124 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/5a/c3378ba7b05abc7c2d95ae492eac0523d937c77afcb9ff7e7f67fe2ca11d/bleak-1.1.1-py3-none-any.whl", hash = "sha256:e601371396e357d95ee3c256db65b7da624c94ef6f051d47dfce93ea8361c22e", size = 136534, upload-time = "2025-09-07T18:44:47.525Z" }, ] +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "brotli" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/c2/f9e977608bdf958650638c3f1e28f85a1b075f075ebbe77db8555463787b/Brotli-1.1.0.tar.gz", hash = "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724", size = 7372270, upload-time = "2023-09-07T14:05:41.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/12/ad41e7fadd5db55459c4c401842b47f7fee51068f86dd2894dd0dcfc2d2a/Brotli-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc", size = 873068, upload-time = "2023-09-07T14:03:37.779Z" }, + { url = "https://files.pythonhosted.org/packages/95/4e/5afab7b2b4b61a84e9c75b17814198ce515343a44e2ed4488fac314cd0a9/Brotli-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c8146669223164fc87a7e3de9f81e9423c67a79d6b3447994dfb9c95da16e2d6", size = 446244, upload-time = "2023-09-07T14:03:39.223Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e6/f305eb61fb9a8580c525478a4a34c5ae1a9bcb12c3aee619114940bc513d/Brotli-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30924eb4c57903d5a7526b08ef4a584acc22ab1ffa085faceb521521d2de32dd", size = 2906500, upload-time = "2023-09-07T14:03:40.858Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4f/af6846cfbc1550a3024e5d3775ede1e00474c40882c7bf5b37a43ca35e91/Brotli-1.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ceb64bbc6eac5a140ca649003756940f8d6a7c444a68af170b3187623b43bebf", size = 2943950, upload-time = "2023-09-07T14:03:42.896Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e7/ca2993c7682d8629b62630ebf0d1f3bb3d579e667ce8e7ca03a0a0576a2d/Brotli-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a469274ad18dc0e4d316eefa616d1d0c2ff9da369af19fa6f3daa4f09671fd61", size = 2918527, upload-time = "2023-09-07T14:03:44.552Z" }, + { url = "https://files.pythonhosted.org/packages/b3/96/da98e7bedc4c51104d29cc61e5f449a502dd3dbc211944546a4cc65500d3/Brotli-1.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:524f35912131cc2cabb00edfd8d573b07f2d9f21fa824bd3fb19725a9cf06327", size = 2845489, upload-time = "2023-09-07T14:03:46.594Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/ccbc16947d6ce943a7f57e1a40596c75859eeb6d279c6994eddd69615265/Brotli-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5b3cc074004d968722f51e550b41a27be656ec48f8afaeeb45ebf65b561481dd", size = 2914080, upload-time = "2023-09-07T14:03:48.204Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/0bd38d758d1afa62a5524172f0b18626bb2392d717ff94806f741fcd5ee9/Brotli-1.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9", size = 2813051, upload-time = "2023-09-07T14:03:50.348Z" }, + { url = "https://files.pythonhosted.org/packages/14/56/48859dd5d129d7519e001f06dcfbb6e2cf6db92b2702c0c2ce7d97e086c1/Brotli-1.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265", size = 2938172, upload-time = "2023-09-07T14:03:52.395Z" }, + { url = "https://files.pythonhosted.org/packages/3d/77/a236d5f8cd9e9f4348da5acc75ab032ab1ab2c03cc8f430d24eea2672888/Brotli-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8", size = 2933023, upload-time = "2023-09-07T14:03:53.96Z" }, + { url = "https://files.pythonhosted.org/packages/f1/87/3b283efc0f5cb35f7f84c0c240b1e1a1003a5e47141a4881bf87c86d0ce2/Brotli-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f", size = 2935871, upload-time = "2024-10-18T12:32:16.688Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/2be4cc3e2141dc1a43ad4ca1875a72088229de38c68e842746b342667b2a/Brotli-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757", size = 2847784, upload-time = "2024-10-18T12:32:18.459Z" }, + { url = "https://files.pythonhosted.org/packages/66/13/b58ddebfd35edde572ccefe6890cf7c493f0c319aad2a5badee134b4d8ec/Brotli-1.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0", size = 3034905, upload-time = "2024-10-18T12:32:20.192Z" }, + { url = "https://files.pythonhosted.org/packages/84/9c/bc96b6c7db824998a49ed3b38e441a2cae9234da6fa11f6ed17e8cf4f147/Brotli-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b", size = 2929467, upload-time = "2024-10-18T12:32:21.774Z" }, + { url = "https://files.pythonhosted.org/packages/e7/71/8f161dee223c7ff7fea9d44893fba953ce97cf2c3c33f78ba260a91bcff5/Brotli-1.1.0-cp311-cp311-win32.whl", hash = "sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50", size = 333169, upload-time = "2023-09-07T14:03:55.404Z" }, + { url = "https://files.pythonhosted.org/packages/02/8a/fece0ee1057643cb2a5bbf59682de13f1725f8482b2c057d4e799d7ade75/Brotli-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1", size = 357253, upload-time = "2023-09-07T14:03:56.643Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d0/5373ae13b93fe00095a58efcbce837fd470ca39f703a235d2a999baadfbc/Brotli-1.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28", size = 815693, upload-time = "2024-10-18T12:32:23.824Z" }, + { url = "https://files.pythonhosted.org/packages/8e/48/f6e1cdf86751300c288c1459724bfa6917a80e30dbfc326f92cea5d3683a/Brotli-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f", size = 422489, upload-time = "2024-10-18T12:32:25.641Z" }, + { url = "https://files.pythonhosted.org/packages/06/88/564958cedce636d0f1bed313381dfc4b4e3d3f6015a63dae6146e1b8c65c/Brotli-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409", size = 873081, upload-time = "2023-09-07T14:03:57.967Z" }, + { url = "https://files.pythonhosted.org/packages/58/79/b7026a8bb65da9a6bb7d14329fd2bd48d2b7f86d7329d5cc8ddc6a90526f/Brotli-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2", size = 446244, upload-time = "2023-09-07T14:03:59.319Z" }, + { url = "https://files.pythonhosted.org/packages/e5/18/c18c32ecea41b6c0004e15606e274006366fe19436b6adccc1ae7b2e50c2/Brotli-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451", size = 2906505, upload-time = "2023-09-07T14:04:01.327Z" }, + { url = "https://files.pythonhosted.org/packages/08/c8/69ec0496b1ada7569b62d85893d928e865df29b90736558d6c98c2031208/Brotli-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f4bf76817c14aa98cc6697ac02f3972cb8c3da93e9ef16b9c66573a68014f91", size = 2944152, upload-time = "2023-09-07T14:04:03.033Z" }, + { url = "https://files.pythonhosted.org/packages/ab/fb/0517cea182219d6768113a38167ef6d4eb157a033178cc938033a552ed6d/Brotli-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0c5516f0aed654134a2fc936325cc2e642f8a0e096d075209672eb321cff408", size = 2919252, upload-time = "2023-09-07T14:04:04.675Z" }, + { url = "https://files.pythonhosted.org/packages/c7/53/73a3431662e33ae61a5c80b1b9d2d18f58dfa910ae8dd696e57d39f1a2f5/Brotli-1.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c3020404e0b5eefd7c9485ccf8393cfb75ec38ce75586e046573c9dc29967a0", size = 2845955, upload-time = "2023-09-07T14:04:06.585Z" }, + { url = "https://files.pythonhosted.org/packages/55/ac/bd280708d9c5ebdbf9de01459e625a3e3803cce0784f47d633562cf40e83/Brotli-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4ed11165dd45ce798d99a136808a794a748d5dc38511303239d4e2363c0695dc", size = 2914304, upload-time = "2023-09-07T14:04:08.668Z" }, + { url = "https://files.pythonhosted.org/packages/76/58/5c391b41ecfc4527d2cc3350719b02e87cb424ef8ba2023fb662f9bf743c/Brotli-1.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180", size = 2814452, upload-time = "2023-09-07T14:04:10.736Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4e/91b8256dfe99c407f174924b65a01f5305e303f486cc7a2e8a5d43c8bec3/Brotli-1.1.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248", size = 2938751, upload-time = "2023-09-07T14:04:12.875Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a6/e2a39a5d3b412938362bbbeba5af904092bf3f95b867b4a3eb856104074e/Brotli-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966", size = 2933757, upload-time = "2023-09-07T14:04:14.551Z" }, + { url = "https://files.pythonhosted.org/packages/13/f0/358354786280a509482e0e77c1a5459e439766597d280f28cb097642fc26/Brotli-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9", size = 2936146, upload-time = "2024-10-18T12:32:27.257Z" }, + { url = "https://files.pythonhosted.org/packages/80/f7/daf538c1060d3a88266b80ecc1d1c98b79553b3f117a485653f17070ea2a/Brotli-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb", size = 2848055, upload-time = "2024-10-18T12:32:29.376Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0eaa0585c4077d3c2d1edf322d8e97aabf317941d3a72d7b3ad8bce004b0/Brotli-1.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111", size = 3035102, upload-time = "2024-10-18T12:32:31.371Z" }, + { url = "https://files.pythonhosted.org/packages/d8/63/1c1585b2aa554fe6dbce30f0c18bdbc877fa9a1bf5ff17677d9cca0ac122/Brotli-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839", size = 2930029, upload-time = "2024-10-18T12:32:33.293Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3b/4e3fd1893eb3bbfef8e5a80d4508bec17a57bb92d586c85c12d28666bb13/Brotli-1.1.0-cp312-cp312-win32.whl", hash = "sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0", size = 333276, upload-time = "2023-09-07T14:04:16.49Z" }, + { url = "https://files.pythonhosted.org/packages/3d/d5/942051b45a9e883b5b6e98c041698b1eb2012d25e5948c58d6bf85b1bb43/Brotli-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951", size = 357255, upload-time = "2023-09-07T14:04:17.83Z" }, + { url = "https://files.pythonhosted.org/packages/0a/9f/fb37bb8ffc52a8da37b1c03c459a8cd55df7a57bdccd8831d500e994a0ca/Brotli-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5", size = 815681, upload-time = "2024-10-18T12:32:34.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/b3/dbd332a988586fefb0aa49c779f59f47cae76855c2d00f450364bb574cac/Brotli-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8", size = 422475, upload-time = "2024-10-18T12:32:36.485Z" }, + { url = "https://files.pythonhosted.org/packages/bb/80/6aaddc2f63dbcf2d93c2d204e49c11a9ec93a8c7c63261e2b4bd35198283/Brotli-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f", size = 2906173, upload-time = "2024-10-18T12:32:37.978Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1d/e6ca79c96ff5b641df6097d299347507d39a9604bde8915e76bf026d6c77/Brotli-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648", size = 2943803, upload-time = "2024-10-18T12:32:39.606Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a3/d98d2472e0130b7dd3acdbb7f390d478123dbf62b7d32bda5c830a96116d/Brotli-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0", size = 2918946, upload-time = "2024-10-18T12:32:41.679Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a5/c69e6d272aee3e1423ed005d8915a7eaa0384c7de503da987f2d224d0721/Brotli-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089", size = 2845707, upload-time = "2024-10-18T12:32:43.478Z" }, + { url = "https://files.pythonhosted.org/packages/58/9f/4149d38b52725afa39067350696c09526de0125ebfbaab5acc5af28b42ea/Brotli-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368", size = 2936231, upload-time = "2024-10-18T12:32:45.224Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/145de884285611838a16bebfdb060c231c52b8f84dfbe52b852a15780386/Brotli-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c", size = 2848157, upload-time = "2024-10-18T12:32:46.894Z" }, + { url = "https://files.pythonhosted.org/packages/50/ae/408b6bfb8525dadebd3b3dd5b19d631da4f7d46420321db44cd99dcf2f2c/Brotli-1.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284", size = 3035122, upload-time = "2024-10-18T12:32:48.844Z" }, + { url = "https://files.pythonhosted.org/packages/af/85/a94e5cfaa0ca449d8f91c3d6f78313ebf919a0dbd55a100c711c6e9655bc/Brotli-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7", size = 2930206, upload-time = "2024-10-18T12:32:51.198Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f0/a61d9262cd01351df22e57ad7c34f66794709acab13f34be2675f45bf89d/Brotli-1.1.0-cp313-cp313-win32.whl", hash = "sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0", size = 333804, upload-time = "2024-10-18T12:32:52.661Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c1/ec214e9c94000d1c1974ec67ced1c970c148aa6b8d8373066123fc3dbf06/Brotli-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b", size = 358517, upload-time = "2024-10-18T12:32:54.066Z" }, +] + +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110, upload-time = "2025-11-05T18:38:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438, upload-time = "2025-11-05T18:38:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420, upload-time = "2025-11-05T18:38:15.111Z" }, + { url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619, upload-time = "2025-11-05T18:38:16.094Z" }, + { url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014, upload-time = "2025-11-05T18:38:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661, upload-time = "2025-11-05T18:38:18.41Z" }, + { url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150, upload-time = "2025-11-05T18:38:19.792Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505, upload-time = "2025-11-05T18:38:20.913Z" }, + { url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451, upload-time = "2025-11-05T18:38:21.94Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035, upload-time = "2025-11-05T18:38:22.941Z" }, + { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, +] + [[package]] name = "cachetools" version = "5.5.2" @@ -743,12 +1066,60 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "44.0.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "cffi", marker = "python_full_version < '3.12' and platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/d6/1411ab4d6108ab167d06254c5be517681f1e331f90edf1379895bcb87020/cryptography-44.0.3.tar.gz", hash = "sha256:fe19d8bc5536a91a24a8133328880a41831b6c5df54599a8417b62fe015d3053", size = 711096, upload-time = "2025-05-02T19:36:04.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/53/c776d80e9d26441bb3868457909b4e74dd9ccabd182e10b2b0ae7a07e265/cryptography-44.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:962bc30480a08d133e631e8dfd4783ab71cc9e33d5d7c1e192f0b7c06397bb88", size = 6670281, upload-time = "2025-05-02T19:34:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/6a/06/af2cf8d56ef87c77319e9086601bef621bedf40f6f59069e1b6d1ec498c5/cryptography-44.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffc61e8f3bf5b60346d89cd3d37231019c17a081208dfbbd6e1605ba03fa137", size = 3959305, upload-time = "2025-05-02T19:34:53.042Z" }, + { url = "https://files.pythonhosted.org/packages/ae/01/80de3bec64627207d030f47bf3536889efee8913cd363e78ca9a09b13c8e/cryptography-44.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58968d331425a6f9eedcee087f77fd3c927c88f55368f43ff7e0a19891f2642c", size = 4171040, upload-time = "2025-05-02T19:34:54.675Z" }, + { url = "https://files.pythonhosted.org/packages/bd/48/bb16b7541d207a19d9ae8b541c70037a05e473ddc72ccb1386524d4f023c/cryptography-44.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e28d62e59a4dbd1d22e747f57d4f00c459af22181f0b2f787ea83f5a876d7c76", size = 3963411, upload-time = "2025-05-02T19:34:56.61Z" }, + { url = "https://files.pythonhosted.org/packages/42/b2/7d31f2af5591d217d71d37d044ef5412945a8a8e98d5a2a8ae4fd9cd4489/cryptography-44.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af653022a0c25ef2e3ffb2c673a50e5a0d02fecc41608f4954176f1933b12359", size = 3689263, upload-time = "2025-05-02T19:34:58.591Z" }, + { url = "https://files.pythonhosted.org/packages/25/50/c0dfb9d87ae88ccc01aad8eb93e23cfbcea6a6a106a9b63a7b14c1f93c75/cryptography-44.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:157f1f3b8d941c2bd8f3ffee0af9b049c9665c39d3da9db2dc338feca5e98a43", size = 4196198, upload-time = "2025-05-02T19:35:00.988Z" }, + { url = "https://files.pythonhosted.org/packages/66/c9/55c6b8794a74da652690c898cb43906310a3e4e4f6ee0b5f8b3b3e70c441/cryptography-44.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:c6cd67722619e4d55fdb42ead64ed8843d64638e9c07f4011163e46bc512cf01", size = 3966502, upload-time = "2025-05-02T19:35:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f7/7cb5488c682ca59a02a32ec5f975074084db4c983f849d47b7b67cc8697a/cryptography-44.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b424563394c369a804ecbee9b06dfb34997f19d00b3518e39f83a5642618397d", size = 4196173, upload-time = "2025-05-02T19:35:05.018Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0b/2f789a8403ae089b0b121f8f54f4a3e5228df756e2146efdf4a09a3d5083/cryptography-44.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c91fc8e8fd78af553f98bc7f2a1d8db977334e4eea302a4bfd75b9461c2d8904", size = 4087713, upload-time = "2025-05-02T19:35:07.187Z" }, + { url = "https://files.pythonhosted.org/packages/1d/aa/330c13655f1af398fc154089295cf259252f0ba5df93b4bc9d9c7d7f843e/cryptography-44.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:25cd194c39fa5a0aa4169125ee27d1172097857b27109a45fadc59653ec06f44", size = 4299064, upload-time = "2025-05-02T19:35:08.879Z" }, + { url = "https://files.pythonhosted.org/packages/10/a8/8c540a421b44fd267a7d58a1fd5f072a552d72204a3f08194f98889de76d/cryptography-44.0.3-cp37-abi3-win32.whl", hash = "sha256:3be3f649d91cb182c3a6bd336de8b61a0a71965bd13d1a04a0e15b39c3d5809d", size = 2773887, upload-time = "2025-05-02T19:35:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0d/c4b1657c39ead18d76bbd122da86bd95bdc4095413460d09544000a17d56/cryptography-44.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:3883076d5c4cc56dbef0b898a74eb6992fdac29a7b9013870b34efe4ddb39a0d", size = 3209737, upload-time = "2025-05-02T19:35:12.12Z" }, + { url = "https://files.pythonhosted.org/packages/34/a3/ad08e0bcc34ad436013458d7528e83ac29910943cea42ad7dd4141a27bbb/cryptography-44.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:5639c2b16764c6f76eedf722dbad9a0914960d3489c0cc38694ddf9464f1bb2f", size = 6673501, upload-time = "2025-05-02T19:35:13.775Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f0/7491d44bba8d28b464a5bc8cc709f25a51e3eac54c0a4444cf2473a57c37/cryptography-44.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3ffef566ac88f75967d7abd852ed5f182da252d23fac11b4766da3957766759", size = 3960307, upload-time = "2025-05-02T19:35:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c8/e5c5d0e1364d3346a5747cdcd7ecbb23ca87e6dea4f942a44e88be349f06/cryptography-44.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:192ed30fac1728f7587c6f4613c29c584abdc565d7417c13904708db10206645", size = 4170876, upload-time = "2025-05-02T19:35:18.138Z" }, + { url = "https://files.pythonhosted.org/packages/73/96/025cb26fc351d8c7d3a1c44e20cf9a01e9f7cf740353c9c7a17072e4b264/cryptography-44.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7d5fe7195c27c32a64955740b949070f21cba664604291c298518d2e255931d2", size = 3964127, upload-time = "2025-05-02T19:35:19.864Z" }, + { url = "https://files.pythonhosted.org/packages/01/44/eb6522db7d9f84e8833ba3bf63313f8e257729cf3a8917379473fcfd6601/cryptography-44.0.3-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3f07943aa4d7dad689e3bb1638ddc4944cc5e0921e3c227486daae0e31a05e54", size = 3689164, upload-time = "2025-05-02T19:35:21.449Z" }, + { url = "https://files.pythonhosted.org/packages/68/fb/d61a4defd0d6cee20b1b8a1ea8f5e25007e26aeb413ca53835f0cae2bcd1/cryptography-44.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb90f60e03d563ca2445099edf605c16ed1d5b15182d21831f58460c48bffb93", size = 4198081, upload-time = "2025-05-02T19:35:23.187Z" }, + { url = "https://files.pythonhosted.org/packages/1b/50/457f6911d36432a8811c3ab8bd5a6090e8d18ce655c22820994913dd06ea/cryptography-44.0.3-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ab0b005721cc0039e885ac3503825661bd9810b15d4f374e473f8c89b7d5460c", size = 3967716, upload-time = "2025-05-02T19:35:25.426Z" }, + { url = "https://files.pythonhosted.org/packages/35/6e/dca39d553075980ccb631955c47b93d87d27f3596da8d48b1ae81463d915/cryptography-44.0.3-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3bb0847e6363c037df8f6ede57d88eaf3410ca2267fb12275370a76f85786a6f", size = 4197398, upload-time = "2025-05-02T19:35:27.678Z" }, + { url = "https://files.pythonhosted.org/packages/9b/9d/d1f2fe681eabc682067c66a74addd46c887ebacf39038ba01f8860338d3d/cryptography-44.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b0cc66c74c797e1db750aaa842ad5b8b78e14805a9b5d1348dc603612d3e3ff5", size = 4087900, upload-time = "2025-05-02T19:35:29.312Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f5/3599e48c5464580b73b236aafb20973b953cd2e7b44c7c2533de1d888446/cryptography-44.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6866df152b581f9429020320e5eb9794c8780e90f7ccb021940d7f50ee00ae0b", size = 4301067, upload-time = "2025-05-02T19:35:31.547Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/d2c48c8137eb39d0c193274db5c04a75dab20d2f7c3f81a7dcc3a8897701/cryptography-44.0.3-cp39-abi3-win32.whl", hash = "sha256:c138abae3a12a94c75c10499f1cbae81294a6f983b3af066390adee73f433028", size = 2775467, upload-time = "2025-05-02T19:35:33.805Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ad/51f212198681ea7b0deaaf8846ee10af99fba4e894f67b353524eab2bbe5/cryptography-44.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:5d186f32e52e66994dce4f766884bcb9c68b8da62d61d9d215bfe5fb56d21334", size = 3210375, upload-time = "2025-05-02T19:35:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4b/c11ad0b6c061902de5223892d680e89c06c7c4d606305eb8de56c5427ae6/cryptography-44.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:896530bc9107b226f265effa7ef3f21270f18a2026bc09fed1ebd7b66ddf6375", size = 3390230, upload-time = "2025-05-02T19:35:49.062Z" }, + { url = "https://files.pythonhosted.org/packages/58/11/0a6bf45d53b9b2290ea3cec30e78b78e6ca29dc101e2e296872a0ffe1335/cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9b4d4a5dbee05a2c390bf212e78b99434efec37b17a4bff42f50285c5c8c9647", size = 3895216, upload-time = "2025-05-02T19:35:51.351Z" }, + { url = "https://files.pythonhosted.org/packages/0a/27/b28cdeb7270e957f0077a2c2bfad1b38f72f1f6d699679f97b816ca33642/cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02f55fb4f8b79c1221b0961488eaae21015b69b210e18c386b69de182ebb1259", size = 4115044, upload-time = "2025-05-02T19:35:53.044Z" }, + { url = "https://files.pythonhosted.org/packages/35/b0/ec4082d3793f03cb248881fecefc26015813199b88f33e3e990a43f79835/cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dd3db61b8fe5be220eee484a17233287d0be6932d056cf5738225b9c05ef4fff", size = 3898034, upload-time = "2025-05-02T19:35:54.72Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7f/adf62e0b8e8d04d50c9a91282a57628c00c54d4ae75e2b02a223bd1f2613/cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:978631ec51a6bbc0b7e58f23b68a8ce9e5f09721940933e9c217068388789fe5", size = 4114449, upload-time = "2025-05-02T19:35:57.139Z" }, + { url = "https://files.pythonhosted.org/packages/87/62/d69eb4a8ee231f4bf733a92caf9da13f1c81a44e874b1d4080c25ecbb723/cryptography-44.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:5d20cc348cca3a8aa7312f42ab953a56e15323800ca3ab0706b8cd452a3a056c", size = 3134369, upload-time = "2025-05-02T19:35:58.907Z" }, +] + [[package]] name = "cryptography" version = "45.0.4" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "cffi", marker = "python_full_version >= '3.12' and platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fe/c8/a2a376a8711c1e11708b9c9972e0c3223f5fc682552c82d8db844393d6ce/cryptography-45.0.4.tar.gz", hash = "sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57", size = 744890, upload-time = "2025-06-10T00:03:51.297Z" } wheels = [ @@ -906,7 +1277,8 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bitstring" }, { name = "click" }, - { name = "cryptography" }, + { name = "cryptography", version = "44.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "cryptography", version = "45.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "intelhex" }, { name = "pyserial" }, { name = "pyyaml" }, @@ -939,6 +1311,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, ] +[[package]] +name = "flask" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "blinker", marker = "python_full_version < '3.12'" }, + { name = "click", marker = "python_full_version < '3.12'" }, + { name = "itsdangerous", marker = "python_full_version < '3.12'" }, + { name = "jinja2", marker = "python_full_version < '3.12'" }, + { name = "werkzeug", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/50/dff6380f1c7f84135484e176e0cac8690af72fa90e932ad2a0a60e28c69b/flask-3.1.0.tar.gz", hash = "sha256:5f873c5184c897c8d9d1b05df1e3d01b14910ce69607a117bd3277098a5836ac", size = 680824, upload-time = "2024-11-13T18:24:38.127Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/47/93213ee66ef8fae3b93b3e29206f6b251e65c97bd91d8e1c5596ef15af0a/flask-3.1.0-py3-none-any.whl", hash = "sha256:d667207822eb83f1c4b50949b1623c8fc8d51f2341d65f72e1a1815397551136", size = 102979, upload-time = "2024-11-13T18:24:36.135Z" }, +] + +[[package]] +name = "flask" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] +dependencies = [ + { name = "blinker", marker = "python_full_version >= '3.12'" }, + { name = "click", marker = "python_full_version >= '3.12'" }, + { name = "itsdangerous", marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "markupsafe", marker = "python_full_version >= '3.12'" }, + { name = "werkzeug", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" } +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 = "frozenlist" version = "1.7.0" @@ -1128,15 +1540,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/36/74841fd268a8f8b85eb6647f2d962461dc3b1f7fc7850c7b7e7a1f3effc0/grpcio_reflection-1.74.0-py3-none-any.whl", hash = "sha256:ad1c4e94185f6def18f298f40f719603118f59d646939bb827f7bc72400f9ba0", size = 22696, upload-time = "2025-07-24T19:01:47.793Z" }, ] +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418, upload-time = "2022-09-25T15:40:01.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259, upload-time = "2022-09-25T15:39:59.68Z" }, +] + [[package]] name = "h11" version = "0.16.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h2" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "hpack", marker = "python_full_version < '3.12'" }, + { name = "hyperframe", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/32/fec683ddd10629ea4ea46d206752a95a2d8a48c22521edd70b142488efe1/h2-4.1.0.tar.gz", hash = "sha256:a83aca08fbe7aacb79fec788c9c0bac936343560ed9ec18b82a13a12c28d2abb", size = 2145593, upload-time = "2021-10-05T18:27:47.18Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e5/db6d438da759efbb488c4f3fbdab7764492ff3c3f953132efa6b9f0e9e53/h2-4.1.0-py3-none-any.whl", hash = "sha256:03a46bcf682256c95b5fd9e9a99c1323584c3eec6440d379b9903d709476bc6d", size = 57488, upload-time = "2021-10-05T18:27:39.977Z" }, +] + +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] +dependencies = [ + { name = "hpack", marker = "python_full_version >= '3.12'" }, + { name = "hyperframe", version = "6.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + [[package]] name = "hatch-pin-jumpstarter" version = "0.1.0" @@ -1183,6 +1644,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/e7/ae38d7a6dfba0533684e0b2136817d667588ae3ec984c1a4e5df5eb88482/hatchling-1.27.0-py3-none-any.whl", hash = "sha256:d3a2f3567c4f926ea39849cdf924c7e99e6686c9c8e288ae1037c8fa2a5d937b", size = 75794, upload-time = "2024-12-15T17:08:10.364Z" }, ] +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/2a/4747bff0a17f7281abe73e955d60d80aae537a5d203f417fa1c2e7578ebb/hyperframe-6.0.1.tar.gz", hash = "sha256:ae510046231dc8e9ecb1a6586f63d2347bf4c8905914aa84ba585ae85f28a914", size = 25008, upload-time = "2021-04-17T12:11:22.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/de/85a784bcc4a3779d1753a7ec2dee5de90e18c7bcf402e71b51fcf150b129/hyperframe-6.0.1-py3-none-any.whl", hash = "sha256:0ec6bafd80d8ad2195c4f03aacba3a8265e57bc4cff261e802bf39970ed02a15", size = 12389, upload-time = "2021-04-17T12:11:21.045Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + [[package]] name = "identify" version = "2.6.12" @@ -1252,6 +1747,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/66/7f8c48009c72d73bc6bbe6eb87ac838d6a526146f7dab14af671121eb379/invoke-2.2.0-py3-none-any.whl", hash = "sha256:6ea924cc53d4f78e3d98bc436b08069a03077e6f85ad1ddaa8a116d7dad15820", size = 160274, upload-time = "2023-07-12T18:05:16.294Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -1269,7 +1773,8 @@ name = "joserfc" version = "1.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, + { name = "cryptography", version = "44.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "cryptography", version = "45.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/fc/9508fedffd72b36914f05e3a9265dcb6e6cea109f03d1063fa64ffcf4e47/joserfc-1.1.0.tar.gz", hash = "sha256:a8f3442b04c233f742f7acde0d0dcd926414e9542a6337096b2b4e5f435f36c1", size = 182360, upload-time = "2025-05-24T04:26:38.159Z" } wheels = [ @@ -1321,7 +1826,8 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "cryptography" }, + { name = "cryptography", version = "44.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "cryptography", version = "45.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "jumpstarter-driver-composite" }, { name = "jumpstarter-driver-network" }, { name = "jumpstarter-driver-power" }, @@ -1372,6 +1878,7 @@ dependencies = [ { name = "jumpstarter-driver-corellium" }, { name = "jumpstarter-driver-doip" }, { name = "jumpstarter-driver-dutlink" }, + { name = "jumpstarter-driver-esp32" }, { name = "jumpstarter-driver-flashers" }, { name = "jumpstarter-driver-gpiod" }, { name = "jumpstarter-driver-http" }, @@ -1414,6 +1921,7 @@ requires-dist = [ { name = "jumpstarter-driver-corellium", editable = "packages/jumpstarter-driver-corellium" }, { name = "jumpstarter-driver-doip", editable = "packages/jumpstarter-driver-doip" }, { name = "jumpstarter-driver-dutlink", editable = "packages/jumpstarter-driver-dutlink" }, + { name = "jumpstarter-driver-esp32", editable = "packages/jumpstarter-driver-esp32" }, { name = "jumpstarter-driver-flashers", editable = "packages/jumpstarter-driver-flashers" }, { name = "jumpstarter-driver-gpiod", editable = "packages/jumpstarter-driver-gpiod" }, { name = "jumpstarter-driver-http", editable = "packages/jumpstarter-driver-http" }, @@ -2004,6 +2512,35 @@ dev = [ { name = "pytest-cov", specifier = ">=6.0.0" }, ] +[[package]] +name = "jumpstarter-driver-mitmproxy" +source = { editable = "packages/jumpstarter-driver-mitmproxy" } +dependencies = [ + { name = "jumpstarter" }, + { name = "mitmproxy", version = "11.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "mitmproxy", version = "12.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "jumpstarter", editable = "packages/jumpstarter" }, + { name = "mitmproxy", specifier = ">=10.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3.3" }, + { name = "pytest-cov", specifier = ">=6.0.0" }, + { name = "requests", specifier = ">=2.28.0" }, +] + [[package]] name = "jumpstarter-driver-network" source = { editable = "packages/jumpstarter-driver-network" } @@ -2516,7 +3053,8 @@ dev = [ { name = "pytest-cov" }, { name = "requests" }, { name = "rpmfile" }, - { name = "zstandard" }, + { name = "zstandard", version = "0.23.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "zstandard", version = "0.25.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] [package.metadata] @@ -2780,6 +3318,23 @@ requires-dist = [ { name = "pytest", specifier = ">=8.3.2" }, ] +[[package]] +name = "jumpstarter-example-xcp-ecu" +version = "0.1.0" +source = { virtual = "examples/xcp-ecu" } +dependencies = [ + { name = "jumpstarter" }, + { name = "jumpstarter-driver-xcp" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "jumpstarter", editable = "packages/jumpstarter" }, + { name = "jumpstarter-driver-xcp", editable = "packages/jumpstarter-driver-xcp" }, + { name = "pytest", specifier = ">=8.3.2" }, +] + [[package]] name = "jumpstarter-imagehash" source = { editable = "packages/jumpstarter-imagehash" } @@ -2906,6 +3461,31 @@ requires-dist = [ [package.metadata.requires-dev] dev = [{ name = "jumpstarter-driver-power", editable = "packages/jumpstarter-driver-power" }] +[[package]] +name = "kaitaistruct" +version = "0.10" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/54/04/dd60b9cb65d580ef6cb6eaee975ad1bdd22d46a3f51b07a1e0606710ea88/kaitaistruct-0.10.tar.gz", hash = "sha256:a044dee29173d6afbacf27bcac39daf89b654dd418cfa009ab82d9178a9ae52a", size = 7061, upload-time = "2022-07-09T00:34:06.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/bf/88ad23efc08708bda9a2647169828e3553bb2093a473801db61f75356395/kaitaistruct-0.10-py2.py3-none-any.whl", hash = "sha256:a97350919adbf37fda881f75e9365e2fb88d04832b7a4e57106ec70119efb235", size = 7013, upload-time = "2022-07-09T00:34:03.905Z" }, +] + +[[package]] +name = "kaitaistruct" +version = "0.11" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] +sdist = { url = "https://files.pythonhosted.org/packages/27/b8/ca7319556912f68832daa4b81425314857ec08dfccd8dbc8c0f65c992108/kaitaistruct-0.11.tar.gz", hash = "sha256:053ee764288e78b8e53acf748e9733268acbd579b8d82a427b1805453625d74b", size = 11519, upload-time = "2025-09-08T15:46:25.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/4a/cf14bf3b1f5ffb13c69cf5f0ea78031247790558ee88984a8bdd22fae60d/kaitaistruct-0.11-py2.py3-none-any.whl", hash = "sha256:5c6ce79177b4e193a577ecd359e26516d1d6d000a0bffd6e1010f2a46a62a561", size = 11372, upload-time = "2025-09-08T15:46:23.635Z" }, +] + [[package]] name = "kubernetes" version = "33.1.0" @@ -2945,6 +3525,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/bf/8d9ae183b3bee0811f633411d3e3de4bcc58eefb81021b3e2fdcc1d1e26b/kubernetes_asyncio-32.3.2-py3-none-any.whl", hash = "sha256:3584e6358571e686ea1396fc310890263c58fa41c084a841a9609a54ad05de62", size = 1984148, upload-time = "2025-04-30T21:59:32.254Z" }, ] +[[package]] +name = "ldap3" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/ac/96bd5464e3edbc61595d0d69989f5d9969ae411866427b2500a8e5b812c0/ldap3-2.9.1.tar.gz", hash = "sha256:f3e7fc4718e3f09dda568b57100095e0ce58633bcabbed8667ce3f8fbaa4229f", size = 398830, upload-time = "2021-07-18T06:34:21.786Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/f6/71d6ec9f18da0b2201287ce9db6afb1a1f637dedb3f0703409558981c723/ldap3-2.9.1-py2.py3-none-any.whl", hash = "sha256:5869596fc4948797020d3f03b7939da938778a0f9e2009f7a072ccf92b8e8d70", size = 432192, upload-time = "2021-07-18T06:34:12.905Z" }, +] + [[package]] name = "line-profiler" version = "5.0.2" @@ -3110,10 +3702,233 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mitmproxy" +version = "11.0.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "aioquic", marker = "python_full_version < '3.12'" }, + { name = "asgiref", version = "3.8.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "brotli", version = "1.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "certifi", marker = "python_full_version < '3.12'" }, + { name = "cryptography", version = "44.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "flask", version = "3.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "h11", version = "0.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "h2", version = "4.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "hyperframe", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "kaitaistruct", version = "0.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "ldap3", marker = "python_full_version < '3.12'" }, + { name = "mitmproxy-rs", version = "0.10.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "msgpack", version = "1.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "passlib", marker = "python_full_version < '3.12'" }, + { name = "publicsuffix2", marker = "python_full_version < '3.12'" }, + { name = "pydivert", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, + { name = "pyopenssl", version = "24.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "pyparsing", version = "3.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "pyperclip", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "ruamel-yaml", version = "0.18.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "sortedcontainers", marker = "python_full_version < '3.12'" }, + { name = "tornado", version = "6.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "urwid", version = "2.6.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "wsproto", marker = "python_full_version < '3.12'" }, + { name = "zstandard", version = "0.23.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/88/5f503d5dd63aa8e0e6d788380e8e8b5d172b682eb5770da625bf70a5f0a7/mitmproxy-11.0.2-py3-none-any.whl", hash = "sha256:95db7b57b21320a0c76e59e1d6644daaa431291cdf89419608301424651199b4", size = 1658730, upload-time = "2024-12-05T09:38:10.269Z" }, +] + +[[package]] +name = "mitmproxy" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] +dependencies = [ + { name = "aioquic", marker = "python_full_version >= '3.12'" }, + { name = "argon2-cffi", marker = "python_full_version >= '3.12'" }, + { name = "asgiref", version = "3.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "bcrypt", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "brotli", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "certifi", marker = "python_full_version >= '3.12'" }, + { name = "cryptography", version = "45.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "flask", version = "3.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "h11", version = "0.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "h2", version = "4.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "hyperframe", version = "6.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "kaitaistruct", version = "0.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "ldap3", marker = "python_full_version >= '3.12'" }, + { name = "mitmproxy-rs", version = "0.12.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "msgpack", version = "1.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "publicsuffix2", marker = "python_full_version >= '3.12'" }, + { name = "pydivert", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "pyopenssl", version = "25.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pyparsing", version = "3.2.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pyperclip", version = "1.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "ruamel-yaml", version = "0.18.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sortedcontainers", marker = "python_full_version >= '3.12'" }, + { name = "tornado", version = "6.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version == '3.12.*'" }, + { name = "urwid", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "wsproto", marker = "python_full_version >= '3.12'" }, + { name = "zstandard", version = "0.25.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/d4/2acc254beec19403269652ead42735c98baf6d56d060ef9dfe34256bda22/mitmproxy-12.2.1-py3-none-any.whl", hash = "sha256:7a508cc9fb906253eb26460d99b3572bf5a7b4a185ab62534379ac1915677dd2", size = 1650400, upload-time = "2025-11-24T19:01:11.712Z" }, +] + +[[package]] +name = "mitmproxy-linux" +version = "0.12.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/f2/8c776f9bf013752c4521fc8382efc7b55cb238cea69b7963200b4f8da293/mitmproxy_linux-0.12.9.tar.gz", hash = "sha256:94b10fee02aa42287739623cef921e1a53955005d45c9e2fa309ae9f0bf8d37d", size = 1299779, upload-time = "2026-01-30T14:54:13.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/6e/10a2fbcf564e18254293dc7118dc4ec72f3e5897509d7b4f804ab23df5cd/mitmproxy_linux-0.12.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4413e27c692f30036ad6d73432826e728ede026fac8e51651d0c545dd0177f2", size = 987838, upload-time = "2026-01-30T14:53:59.602Z" }, + { url = "https://files.pythonhosted.org/packages/20/c5/2eeb523019b1ad84ec659fc41b007cbc90ac99e2451c4e7ba7a28d910b04/mitmproxy_linux-0.12.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee842865a05f69196004ddcb29d50af0602361d9d6acee04f370f7e01c3674e8", size = 1067258, upload-time = "2026-01-30T14:54:01.872Z" }, +] + +[[package]] +name = "mitmproxy-macos" +version = "0.10.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/92/c98ab2a8e5fb5b9880a35b347ffb0e013a1d694b538831e290ad483c503d/mitmproxy_macos-0.10.7-py3-none-any.whl", hash = "sha256:e01664e1a31479818596641148ab80b5b531b03c8c9f292af8ded7103291db82", size = 2653482, upload-time = "2024-10-28T11:56:29.435Z" }, +] + +[[package]] +name = "mitmproxy-macos" +version = "0.12.9" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/71/d5899c5d1593403bccdd4b56306d03a200e14483318f86b882a144f79a32/mitmproxy_macos-0.12.9-py3-none-any.whl", hash = "sha256:20e024fbfeeecbdb4ee2a1e8361d18782146777fdc1e00dcfecd52c22a3219bf", size = 2569740, upload-time = "2026-01-30T14:54:03.379Z" }, +] + +[[package]] +name = "mitmproxy-rs" +version = "0.10.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "mitmproxy-macos", version = "0.10.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' and sys_platform == 'darwin'" }, + { name = "mitmproxy-windows", version = "0.10.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' and os_name == 'nt'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/64/114311494f8fb689343ce348b7f046bbc67a88247ffc655dc4c3440286fb/mitmproxy_rs-0.10.7.tar.gz", hash = "sha256:0959a540766403222464472b64122ac8ccbca66b5f019154496b98e62482277f", size = 1183834, upload-time = "2024-10-28T11:56:39.622Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/d9/a0c427fa4af584db2fa87eaaf3b6ba18df4bece4c04fbe9c6d37de22edf0/mitmproxy_rs-0.10.7-cp310-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:8b8eedccd2b03ff2f9505bd9005a54f796d2e40f731dd7246e6656075935ae6b", size = 3854635, upload-time = "2024-10-28T11:56:31.459Z" }, + { url = "https://files.pythonhosted.org/packages/f0/58/bdf172d78d123b9127d419153eaa8b14363449d5108d7367b550ea8600c4/mitmproxy_rs-0.10.7-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb648320f9007378f67d70479727db862faa2b7832dddaa4eef376d8c94d8388", size = 1385919, upload-time = "2024-10-28T11:56:33.64Z" }, + { url = "https://files.pythonhosted.org/packages/a2/59/780297cc8b5cecd9787257cae3fe0a60effaafb5238fd7879cfd4c63d357/mitmproxy_rs-0.10.7-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a57f099b80e5aaf2d98764761dab8e1644ae011c7cf2696079f68eecda0089c", size = 1469317, upload-time = "2024-10-28T11:56:34.878Z" }, + { url = "https://files.pythonhosted.org/packages/a5/19/67421b239b90408943e5d2286f812538a64009eaa522bf71f3378fb527bd/mitmproxy_rs-0.10.7-cp310-abi3-win_amd64.whl", hash = "sha256:5a95503f57c1d991641690d6e0a9a3e4df484832bed1da1e81b6cf53acf18f75", size = 1592355, upload-time = "2024-10-28T11:56:36.693Z" }, +] + +[[package]] +name = "mitmproxy-rs" +version = "0.12.9" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] +dependencies = [ + { name = "mitmproxy-linux", marker = "python_full_version >= '3.12' and sys_platform == 'linux'" }, + { name = "mitmproxy-macos", version = "0.12.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and sys_platform == 'darwin'" }, + { name = "mitmproxy-windows", version = "0.12.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and os_name == 'nt'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/5c/16a61303da76cd34aa6ddbd7ef6ac66d9ef8514c4d3a5b71831169d63236/mitmproxy_rs-0.12.9.tar.gz", hash = "sha256:c6ffc35c002c675cac534442d92d1cdebd66fafd63754ad33b92ae968ea6e449", size = 1334424, upload-time = "2026-01-30T14:54:15.043Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/78/dc9f4b4ef894709853407291ab281e478cb122b993633125b858eea523ba/mitmproxy_rs-0.12.9-cp312-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:afeb3a2da2bc26474e1a2febaea4432430c5fde890dfce33bc4c1e65e6baef1b", size = 7145620, upload-time = "2026-01-30T14:54:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/0c/6f/1ebd9ca748bf62eb90657b41692c46716cff03aaf134260a249a2ae2d251/mitmproxy_rs-0.12.9-cp312-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245922663440330c4b5a36d0194ed559b1dbd5e38545db2eb947180ed12a5e92", size = 3084785, upload-time = "2026-01-30T14:54:06.797Z" }, + { url = "https://files.pythonhosted.org/packages/10/af/fc2f2b30a6ade8646d276c4813f68b86d775696d467f12df32613d22c638/mitmproxy_rs-0.12.9-cp312-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fb9fb4aac9ecb82e2c3c5c439ef5e4961be7934d80ade5e9a99c0a944b8ea2f", size = 3252443, upload-time = "2026-01-30T14:54:08.908Z" }, + { url = "https://files.pythonhosted.org/packages/c2/20/b065c6a1eb27effec3368b03bdc842f6f611800ee5f990d994884286f160/mitmproxy_rs-0.12.9-cp312-abi3-win_amd64.whl", hash = "sha256:1fd716e87da8be3c62daa4325a5ff42bedd951fb8614c5f66caa94b7c21e2593", size = 3321769, upload-time = "2026-01-30T14:54:10.735Z" }, +] + +[[package]] +name = "mitmproxy-windows" +version = "0.10.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/1b/8519d7ffe246b32387012d738a7ce024de83120040e8400c325122870571/mitmproxy_windows-0.10.7-py3-none-any.whl", hash = "sha256:be2eb85980d69dcc5159bbbcd673f3a6966b6e3b34419eed6d5bfb36ed4cf9a3", size = 474415, upload-time = "2024-10-28T11:56:37.868Z" }, +] + +[[package]] +name = "mitmproxy-windows" +version = "0.12.9" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/83/2712af146c5f6a59a7f4658c02356b241c40ba19cb2b16db94235e95b699/mitmproxy_windows-0.12.9-py3-none-any.whl", hash = "sha256:fdec21fb66a5ba237d9106bfdc09d9428f315551bf4b41ba06b261e7beb56417", size = 464363, upload-time = "2026-01-30T14:54:12.531Z" }, +] + +[[package]] +name = "msgpack" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/d0/7555686ae7ff5731205df1012ede15dd9d927f6227ea151e901c7406af4f/msgpack-1.1.0.tar.gz", hash = "sha256:dd432ccc2c72b914e4cb77afce64aab761c1137cc698be3984eee260bcb2896e", size = 167260, upload-time = "2024-09-10T04:25:52.197Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/5e/a4c7154ba65d93be91f2f1e55f90e76c5f91ccadc7efc4341e6f04c8647f/msgpack-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3d364a55082fb2a7416f6c63ae383fbd903adb5a6cf78c5b96cc6316dc1cedc7", size = 150803, upload-time = "2024-09-10T04:24:40.911Z" }, + { url = "https://files.pythonhosted.org/packages/60/c2/687684164698f1d51c41778c838d854965dd284a4b9d3a44beba9265c931/msgpack-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79ec007767b9b56860e0372085f8504db5d06bd6a327a335449508bbee9648fa", size = 84343, upload-time = "2024-09-10T04:24:50.283Z" }, + { url = "https://files.pythonhosted.org/packages/42/ae/d3adea9bb4a1342763556078b5765e666f8fdf242e00f3f6657380920972/msgpack-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6ad622bf7756d5a497d5b6836e7fc3752e2dd6f4c648e24b1803f6048596f701", size = 81408, upload-time = "2024-09-10T04:25:12.774Z" }, + { url = "https://files.pythonhosted.org/packages/dc/17/6313325a6ff40ce9c3207293aee3ba50104aed6c2c1559d20d09e5c1ff54/msgpack-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e59bca908d9ca0de3dc8684f21ebf9a690fe47b6be93236eb40b99af28b6ea6", size = 396096, upload-time = "2024-09-10T04:24:37.245Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a1/ad7b84b91ab5a324e707f4c9761633e357820b011a01e34ce658c1dda7cc/msgpack-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e1da8f11a3dd397f0a32c76165cf0c4eb95b31013a94f6ecc0b280c05c91b59", size = 403671, upload-time = "2024-09-10T04:25:10.201Z" }, + { url = "https://files.pythonhosted.org/packages/bb/0b/fd5b7c0b308bbf1831df0ca04ec76fe2f5bf6319833646b0a4bd5e9dc76d/msgpack-1.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:452aff037287acb1d70a804ffd022b21fa2bb7c46bee884dbc864cc9024128a0", size = 387414, upload-time = "2024-09-10T04:25:27.552Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/ff8233b7c6e9929a1f5da3c7860eccd847e2523ca2de0d8ef4878d354cfa/msgpack-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8da4bf6d54ceed70e8861f833f83ce0814a2b72102e890cbdfe4b34764cdd66e", size = 383759, upload-time = "2024-09-10T04:25:03.366Z" }, + { url = "https://files.pythonhosted.org/packages/1f/1b/eb82e1fed5a16dddd9bc75f0854b6e2fe86c0259c4353666d7fab37d39f4/msgpack-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:41c991beebf175faf352fb940bf2af9ad1fb77fd25f38d9142053914947cdbf6", size = 394405, upload-time = "2024-09-10T04:25:07.348Z" }, + { url = "https://files.pythonhosted.org/packages/90/2e/962c6004e373d54ecf33d695fb1402f99b51832631e37c49273cc564ffc5/msgpack-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a52a1f3a5af7ba1c9ace055b659189f6c669cf3657095b50f9602af3a3ba0fe5", size = 396041, upload-time = "2024-09-10T04:25:48.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/20/6e03342f629474414860c48aeffcc2f7f50ddaf351d95f20c3f1c67399a8/msgpack-1.1.0-cp311-cp311-win32.whl", hash = "sha256:58638690ebd0a06427c5fe1a227bb6b8b9fdc2bd07701bec13c2335c82131a88", size = 68538, upload-time = "2024-09-10T04:24:29.953Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c4/5a582fc9a87991a3e6f6800e9bb2f3c82972912235eb9539954f3e9997c7/msgpack-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd2906780f25c8ed5d7b323379f6138524ba793428db5d0e9d226d3fa6aa1788", size = 74871, upload-time = "2024-09-10T04:25:44.823Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/716b7ca1dbde63290d2973d22bbef1b5032ca634c3ff4384a958ec3f093a/msgpack-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:d46cf9e3705ea9485687aa4001a76e44748b609d260af21c4ceea7f2212a501d", size = 152421, upload-time = "2024-09-10T04:25:49.63Z" }, + { url = "https://files.pythonhosted.org/packages/70/da/5312b067f6773429cec2f8f08b021c06af416bba340c912c2ec778539ed6/msgpack-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5dbad74103df937e1325cc4bfeaf57713be0b4f15e1c2da43ccdd836393e2ea2", size = 85277, upload-time = "2024-09-10T04:24:48.562Z" }, + { url = "https://files.pythonhosted.org/packages/28/51/da7f3ae4462e8bb98af0d5bdf2707f1b8c65a0d4f496e46b6afb06cbc286/msgpack-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58dfc47f8b102da61e8949708b3eafc3504509a5728f8b4ddef84bd9e16ad420", size = 82222, upload-time = "2024-09-10T04:25:36.49Z" }, + { url = "https://files.pythonhosted.org/packages/33/af/dc95c4b2a49cff17ce47611ca9ba218198806cad7796c0b01d1e332c86bb/msgpack-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4676e5be1b472909b2ee6356ff425ebedf5142427842aa06b4dfd5117d1ca8a2", size = 392971, upload-time = "2024-09-10T04:24:58.129Z" }, + { url = "https://files.pythonhosted.org/packages/f1/54/65af8de681fa8255402c80eda2a501ba467921d5a7a028c9c22a2c2eedb5/msgpack-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17fb65dd0bec285907f68b15734a993ad3fc94332b5bb21b0435846228de1f39", size = 401403, upload-time = "2024-09-10T04:25:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/97/8c/e333690777bd33919ab7024269dc3c41c76ef5137b211d776fbb404bfead/msgpack-1.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a51abd48c6d8ac89e0cfd4fe177c61481aca2d5e7ba42044fd218cfd8ea9899f", size = 385356, upload-time = "2024-09-10T04:25:31.406Z" }, + { url = "https://files.pythonhosted.org/packages/57/52/406795ba478dc1c890559dd4e89280fa86506608a28ccf3a72fbf45df9f5/msgpack-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2137773500afa5494a61b1208619e3871f75f27b03bcfca7b3a7023284140247", size = 383028, upload-time = "2024-09-10T04:25:17.08Z" }, + { url = "https://files.pythonhosted.org/packages/e7/69/053b6549bf90a3acadcd8232eae03e2fefc87f066a5b9fbb37e2e608859f/msgpack-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:398b713459fea610861c8a7b62a6fec1882759f308ae0795b5413ff6a160cf3c", size = 391100, upload-time = "2024-09-10T04:25:08.993Z" }, + { url = "https://files.pythonhosted.org/packages/23/f0/d4101d4da054f04274995ddc4086c2715d9b93111eb9ed49686c0f7ccc8a/msgpack-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:06f5fd2f6bb2a7914922d935d3b8bb4a7fff3a9a91cfce6d06c13bc42bec975b", size = 394254, upload-time = "2024-09-10T04:25:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/1c/12/cf07458f35d0d775ff3a2dc5559fa2e1fcd06c46f1ef510e594ebefdca01/msgpack-1.1.0-cp312-cp312-win32.whl", hash = "sha256:ad33e8400e4ec17ba782f7b9cf868977d867ed784a1f5f2ab46e7ba53b6e1e1b", size = 69085, upload-time = "2024-09-10T04:25:01.494Z" }, + { url = "https://files.pythonhosted.org/packages/73/80/2708a4641f7d553a63bc934a3eb7214806b5b39d200133ca7f7afb0a53e8/msgpack-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:115a7af8ee9e8cddc10f87636767857e7e3717b7a2e97379dc2054712693e90f", size = 75347, upload-time = "2024-09-10T04:25:33.106Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b0/380f5f639543a4ac413e969109978feb1f3c66e931068f91ab6ab0f8be00/msgpack-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:071603e2f0771c45ad9bc65719291c568d4edf120b44eb36324dcb02a13bfddf", size = 151142, upload-time = "2024-09-10T04:24:59.656Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ee/be57e9702400a6cb2606883d55b05784fada898dfc7fd12608ab1fdb054e/msgpack-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0f92a83b84e7c0749e3f12821949d79485971f087604178026085f60ce109330", size = 84523, upload-time = "2024-09-10T04:25:37.924Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3a/2919f63acca3c119565449681ad08a2f84b2171ddfcff1dba6959db2cceb/msgpack-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a1964df7b81285d00a84da4e70cb1383f2e665e0f1f2a7027e683956d04b734", size = 81556, upload-time = "2024-09-10T04:24:28.296Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/a11113d9e5c1498c145a8925768ea2d5fce7cbab15c99cda655aa09947ed/msgpack-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59caf6a4ed0d164055ccff8fe31eddc0ebc07cf7326a2aaa0dbf7a4001cd823e", size = 392105, upload-time = "2024-09-10T04:25:20.153Z" }, + { url = "https://files.pythonhosted.org/packages/2d/7b/2c1d74ca6c94f70a1add74a8393a0138172207dc5de6fc6269483519d048/msgpack-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0907e1a7119b337971a689153665764adc34e89175f9a34793307d9def08e6ca", size = 399979, upload-time = "2024-09-10T04:25:41.75Z" }, + { url = "https://files.pythonhosted.org/packages/82/8c/cf64ae518c7b8efc763ca1f1348a96f0e37150061e777a8ea5430b413a74/msgpack-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65553c9b6da8166e819a6aa90ad15288599b340f91d18f60b2061f402b9a4915", size = 383816, upload-time = "2024-09-10T04:24:45.826Z" }, + { url = "https://files.pythonhosted.org/packages/69/86/a847ef7a0f5ef3fa94ae20f52a4cacf596a4e4a010197fbcc27744eb9a83/msgpack-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7a946a8992941fea80ed4beae6bff74ffd7ee129a90b4dd5cf9c476a30e9708d", size = 380973, upload-time = "2024-09-10T04:25:04.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/90/c74cf6e1126faa93185d3b830ee97246ecc4fe12cf9d2d31318ee4246994/msgpack-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4b51405e36e075193bc051315dbf29168d6141ae2500ba8cd80a522964e31434", size = 387435, upload-time = "2024-09-10T04:24:17.879Z" }, + { url = "https://files.pythonhosted.org/packages/7a/40/631c238f1f338eb09f4acb0f34ab5862c4e9d7eda11c1b685471a4c5ea37/msgpack-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4c01941fd2ff87c2a934ee6055bda4ed353a7846b8d4f341c428109e9fcde8c", size = 399082, upload-time = "2024-09-10T04:25:18.398Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/fa8a952be252a1555ed39f97c06778e3aeb9123aa4cccc0fd2acd0b4e315/msgpack-1.1.0-cp313-cp313-win32.whl", hash = "sha256:7c9a35ce2c2573bada929e0b7b3576de647b0defbd25f5139dcdaba0ae35a4cc", size = 69037, upload-time = "2024-09-10T04:24:52.798Z" }, + { url = "https://files.pythonhosted.org/packages/b6/bc/8bd826dd03e022153bfa1766dcdec4976d6c818865ed54223d71f07862b3/msgpack-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:bce7d9e614a04d0883af0b3d4d501171fbfca038f12c77fa838d9f198147a23f", size = 75140, upload-time = "2024-09-10T04:24:31.288Z" }, +] + [[package]] name = "msgpack" version = "1.1.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] sdist = { url = "https://files.pythonhosted.org/packages/45/b1/ea4f68038a18c77c9467400d166d74c4ffa536f34761f7983a104357e614/msgpack-1.1.1.tar.gz", hash = "sha256:77b79ce34a2bdab2594f490c8e80dd62a02d650b91a75159a63ec413b8d104cd", size = 173555, upload-time = "2025-06-13T06:52:51.324Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7f/83/97f24bf9848af23fe2ba04380388216defc49a8af6da0c28cc636d722502/msgpack-1.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:71ef05c1726884e44f8b1d1773604ab5d4d17729d8491403a705e649116c9558", size = 82728, upload-time = "2025-06-13T06:51:50.68Z" }, @@ -3124,6 +3939,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/75/05/ac84063c5dae79722bda9f68b878dc31fc3059adb8633c79f1e82c2cd946/msgpack-1.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:88daaf7d146e48ec71212ce21109b66e06a98e5e44dca47d853cbfe171d6c8d2", size = 413338, upload-time = "2025-06-13T06:51:57.023Z" }, { url = "https://files.pythonhosted.org/packages/69/e8/fe86b082c781d3e1c09ca0f4dacd457ede60a13119b6ce939efe2ea77b76/msgpack-1.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8b55ea20dc59b181d3f47103f113e6f28a5e1c89fd5b67b9140edb442ab67f2", size = 422658, upload-time = "2025-06-13T06:51:58.419Z" }, { url = "https://files.pythonhosted.org/packages/3b/2b/bafc9924df52d8f3bb7c00d24e57be477f4d0f967c0a31ef5e2225e035c7/msgpack-1.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4a28e8072ae9779f20427af07f53bbb8b4aa81151054e882aee333b158da8752", size = 427124, upload-time = "2025-06-13T06:51:59.969Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3b/1f717e17e53e0ed0b68fa59e9188f3f610c79d7151f0e52ff3cd8eb6b2dc/msgpack-1.1.1-cp311-cp311-win32.whl", hash = "sha256:7da8831f9a0fdb526621ba09a281fadc58ea12701bc709e7b8cbc362feabc295", size = 65016, upload-time = "2025-06-13T06:52:01.294Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/9d1780768d3b249accecc5a38c725eb1e203d44a191f7b7ff1941f7df60c/msgpack-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:5fd1b58e1431008a57247d6e7cc4faa41c3607e8e7d4aaf81f7c29ea013cb458", size = 72267, upload-time = "2025-06-13T06:52:02.568Z" }, { url = "https://files.pythonhosted.org/packages/e3/26/389b9c593eda2b8551b2e7126ad3a06af6f9b44274eb3a4f054d48ff7e47/msgpack-1.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae497b11f4c21558d95de9f64fff7053544f4d1a17731c866143ed6bb4591238", size = 82359, upload-time = "2025-06-13T06:52:03.909Z" }, { url = "https://files.pythonhosted.org/packages/ab/65/7d1de38c8a22cf8b1551469159d4b6cf49be2126adc2482de50976084d78/msgpack-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:33be9ab121df9b6b461ff91baac6f2731f83d9b27ed948c5b9d1978ae28bf157", size = 79172, upload-time = "2025-06-13T06:52:05.246Z" }, { url = "https://files.pythonhosted.org/packages/0f/bd/cacf208b64d9577a62c74b677e1ada005caa9b69a05a599889d6fc2ab20a/msgpack-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f64ae8fe7ffba251fecb8408540c34ee9df1c26674c50c4544d72dbf792e5ce", size = 425013, upload-time = "2025-06-13T06:52:06.341Z" }, @@ -3132,6 +3949,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/16/69ed8f3ada150bf92745fb4921bd621fd2cdf5a42e25eb50bcc57a5328f0/msgpack-1.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d275a9e3c81b1093c060c3837e580c37f47c51eca031f7b5fb76f7b8470f5f9b", size = 409485, upload-time = "2025-06-13T06:52:10.382Z" }, { url = "https://files.pythonhosted.org/packages/c6/b6/0c398039e4c6d0b2e37c61d7e0e9d13439f91f780686deb8ee64ecf1ae71/msgpack-1.1.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fd6b577e4541676e0cc9ddc1709d25014d3ad9a66caa19962c4f5de30fc09ef", size = 412182, upload-time = "2025-06-13T06:52:11.644Z" }, { url = "https://files.pythonhosted.org/packages/b8/d0/0cf4a6ecb9bc960d624c93effaeaae75cbf00b3bc4a54f35c8507273cda1/msgpack-1.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb29aaa613c0a1c40d1af111abf025f1732cab333f96f285d6a93b934738a68a", size = 419883, upload-time = "2025-06-13T06:52:12.806Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/9697c211720fa71a2dfb632cad6196a8af3abea56eece220fde4674dc44b/msgpack-1.1.1-cp312-cp312-win32.whl", hash = "sha256:870b9a626280c86cff9c576ec0d9cbcc54a1e5ebda9cd26dab12baf41fee218c", size = 65406, upload-time = "2025-06-13T06:52:14.271Z" }, + { url = "https://files.pythonhosted.org/packages/c0/23/0abb886e80eab08f5e8c485d6f13924028602829f63b8f5fa25a06636628/msgpack-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:5692095123007180dca3e788bb4c399cc26626da51629a31d40207cb262e67f4", size = 72558, upload-time = "2025-06-13T06:52:15.252Z" }, { url = "https://files.pythonhosted.org/packages/a1/38/561f01cf3577430b59b340b51329803d3a5bf6a45864a55f4ef308ac11e3/msgpack-1.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3765afa6bd4832fc11c3749be4ba4b69a0e8d7b728f78e68120a157a4c5d41f0", size = 81677, upload-time = "2025-06-13T06:52:16.64Z" }, { url = "https://files.pythonhosted.org/packages/09/48/54a89579ea36b6ae0ee001cba8c61f776451fad3c9306cd80f5b5c55be87/msgpack-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8ddb2bcfd1a8b9e431c8d6f4f7db0773084e107730ecf3472f1dfe9ad583f3d9", size = 78603, upload-time = "2025-06-13T06:52:17.843Z" }, { url = "https://files.pythonhosted.org/packages/a0/60/daba2699b308e95ae792cdc2ef092a38eb5ee422f9d2fbd4101526d8a210/msgpack-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:196a736f0526a03653d829d7d4c5500a97eea3648aebfd4b6743875f28aa2af8", size = 420504, upload-time = "2025-06-13T06:52:18.982Z" }, @@ -3140,6 +3959,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2e/60/6bb17e9ffb080616a51f09928fdd5cac1353c9becc6c4a8abd4e57269a16/msgpack-1.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4141c5a32b5e37905b5940aacbc59739f036930367d7acce7a64e4dec1f5e0b", size = 405976, upload-time = "2025-06-13T06:52:22.995Z" }, { url = "https://files.pythonhosted.org/packages/ee/97/88983e266572e8707c1f4b99c8fd04f9eb97b43f2db40e3172d87d8642db/msgpack-1.1.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b1ce7f41670c5a69e1389420436f41385b1aa2504c3b0c30620764b15dded2e7", size = 408607, upload-time = "2025-06-13T06:52:24.152Z" }, { url = "https://files.pythonhosted.org/packages/bc/66/36c78af2efaffcc15a5a61ae0df53a1d025f2680122e2a9eb8442fed3ae4/msgpack-1.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4147151acabb9caed4e474c3344181e91ff7a388b888f1e19ea04f7e73dc7ad5", size = 424172, upload-time = "2025-06-13T06:52:25.704Z" }, + { url = "https://files.pythonhosted.org/packages/8c/87/a75eb622b555708fe0427fab96056d39d4c9892b0c784b3a721088c7ee37/msgpack-1.1.1-cp313-cp313-win32.whl", hash = "sha256:500e85823a27d6d9bba1d057c871b4210c1dd6fb01fbb764e37e4e8847376323", size = 65347, upload-time = "2025-06-13T06:52:26.846Z" }, + { url = "https://files.pythonhosted.org/packages/ca/91/7dc28d5e2a11a5ad804cf2b7f7a5fcb1eb5a4966d66a5d2b41aee6376543/msgpack-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:6d489fba546295983abd142812bda76b57e33d0b9f5d5b71c09a583285506f69", size = 72341, upload-time = "2025-06-13T06:52:27.835Z" }, ] [[package]] @@ -3384,8 +4205,10 @@ name = "paramiko" version = "4.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "bcrypt" }, - { name = "cryptography" }, + { name = "bcrypt", version = "4.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "bcrypt", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "cryptography", version = "44.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "cryptography", version = "45.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "invoke" }, { name = "pynacl" }, ] @@ -3394,6 +4217,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/90/a744336f5af32c433bd09af7854599682a383b37cfd78f7de263de6ad6cb/paramiko-4.0.0-py3-none-any.whl", hash = "sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9", size = 223932, upload-time = "2025-08-04T01:02:02.029Z" }, ] +[[package]] +name = "passlib" +version = "1.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, +] + [[package]] name = "pathspec" version = "0.12.1" @@ -3604,6 +4436,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, ] +[[package]] +name = "publicsuffix2" +version = "2.20191221" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/04/1759906c4c5b67b2903f546de234a824d4028ef24eb0b1122daa43376c20/publicsuffix2-2.20191221.tar.gz", hash = "sha256:00f8cc31aa8d0d5592a5ced19cccba7de428ebca985db26ac852d920ddd6fe7b", size = 99592, upload-time = "2019-12-21T11:30:44.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/16/053c2945c5e3aebeefb4ccd5c5e7639e38bc30ad1bdc7ce86c6d01707726/publicsuffix2-2.20191221-py2.py3-none-any.whl", hash = "sha256:786b5e36205b88758bd3518725ec8cfe7a8173f5269354641f581c6b80a99893", size = 89033, upload-time = "2019-12-21T11:30:41.744Z" }, +] + [[package]] name = "pyasn1" version = "0.6.1" @@ -3728,6 +4569,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, ] +[[package]] +name = "pydivert" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/71/2da9bcf742df3ab23f75f10fedca074951dd13a84bda8dea3077f68ae9a6/pydivert-2.1.0.tar.gz", hash = "sha256:f0e150f4ff591b78e35f514e319561dadff7f24a82186a171dd4d465483de5b4", size = 91057, upload-time = "2017-10-20T21:36:58.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/8f/86d7931c62013a5a7ebf4e1642a87d4a6050c0f570e714f61b0df1984c62/pydivert-2.1.0-py2.py3-none-any.whl", hash = "sha256:382db488e3c37c03ec9ec94e061a0b24334d78dbaeebb7d4e4d32ce4355d9da1", size = 104718, upload-time = "2017-10-20T21:36:56.726Z" }, +] + [[package]] name = "pygls" version = "1.3.1" @@ -3750,6 +4600,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pylsqpack" +version = "0.3.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/f3/2681d5d38cd789a62352e105619d353d3c245f463a376c1b9a735e3c47b3/pylsqpack-0.3.23.tar.gz", hash = "sha256:f55b126940d8b3157331f123d4428d703a698a6db65a6a7891f7ec1b90c86c56", size = 676891, upload-time = "2025-10-10T17:12:58.747Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/5d/44c5f05d4f72ac427210326a283f74541ad694d517a1c136631fdbcd8e4b/pylsqpack-0.3.23-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:978497811bb58cf7ae11c0e1d4cf9bdf6bccef77556d039ae1836b458cb235fc", size = 162519, upload-time = "2025-10-10T17:12:44.892Z" }, + { url = "https://files.pythonhosted.org/packages/38/9a/3472903fd88dfa87ac683e7113e0ac9df47b70924db9410b275c6e16b25f/pylsqpack-0.3.23-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8a9e25c5a98a0959c6511aaf7d1a6ac0d6146be349a8c3c09fec2e5250cb2901", size = 167819, upload-time = "2025-10-10T17:12:46.54Z" }, + { url = "https://files.pythonhosted.org/packages/a7/cf/43e7b04f6397be691a255589fbed25fb4b8d7b707ad8c118408553ff2a5b/pylsqpack-0.3.23-cp310-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f7d78352e764732ac1a9ab109aa84e003996a7d64de7098cb20bdc007cf7613", size = 246484, upload-time = "2025-10-10T17:12:47.588Z" }, + { url = "https://files.pythonhosted.org/packages/ed/38/e44ba48404b61b4dd1e9902bef7e01afac5c31e57c5dceec2f0f4e522fcb/pylsqpack-0.3.23-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ba86c384dcf8952cef190f8cc4d61cb2a8e4eeaf25093c6aa38b9b696ac82dc", size = 248586, upload-time = "2025-10-10T17:12:48.621Z" }, + { url = "https://files.pythonhosted.org/packages/1f/46/1f0eb601215bc7596e3003dde6a4c9ad457a4ab35405cdcc56c0727cdf49/pylsqpack-0.3.23-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:829a2466b80af9766cf0ad795b866796a4000cec441a0eb222357efd01ec6d42", size = 249520, upload-time = "2025-10-10T17:12:49.639Z" }, + { url = "https://files.pythonhosted.org/packages/b9/20/a91d4f90480baaa14aa940512bdfae3774b2524bbf71d3f16391b244b31e/pylsqpack-0.3.23-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b516d56078a16592596ea450ea20e9a54650af759754e2e807b7046be13c83ee", size = 246141, upload-time = "2025-10-10T17:12:51.165Z" }, + { url = "https://files.pythonhosted.org/packages/28/bb/02c018e0fc174122d5bd0cfcbe858d40a4516d9245fca4a7a2dd5201deea/pylsqpack-0.3.23-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:db03232c85855cb03226447e41539f8631d7d4e5483d48206e30d470a9cb07a1", size = 246064, upload-time = "2025-10-10T17:12:52.243Z" }, + { url = "https://files.pythonhosted.org/packages/02/ca/082d31c1180ab856118634a3a26c7739cf38aee656702c1b39dc1acc26a0/pylsqpack-0.3.23-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d91d87672beb0beff6a866dbf35e8b45791d8dffcd5cfd9d8cc397001101fd5", size = 247847, upload-time = "2025-10-10T17:12:53.364Z" }, + { url = "https://files.pythonhosted.org/packages/6a/33/58e7ced97a04bfb1807143fc70dc7ff3b8abef4e39c5144235f0985e43cc/pylsqpack-0.3.23-cp310-abi3-win32.whl", hash = "sha256:4e5b0b5ec92be6e5e6eb1c52d45271c5c7f8f2a2cd8c672ab240ac2cd893cd26", size = 153227, upload-time = "2025-10-10T17:12:54.459Z" }, + { url = "https://files.pythonhosted.org/packages/da/da/691477b89927643ea30f36511825e9551d7f36c887ce9bb9903fac31390d/pylsqpack-0.3.23-cp310-abi3-win_amd64.whl", hash = "sha256:498b374b16b51532997998c4cf4021161d2a611f5ea6b02ad95ca99815c54abf", size = 155779, upload-time = "2025-10-10T17:12:55.406Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/a8bc10443fd4261911dbb41331d39ce2ad28ba82a170eddecf23904b321c/pylsqpack-0.3.23-cp310-abi3-win_arm64.whl", hash = "sha256:2f9a2ef59588d32cd02847c6b9d7140440f67a0751da99f96a2ff4edadc85eae", size = 153188, upload-time = "2025-10-10T17:12:56.782Z" }, +] + [[package]] name = "pynacl" version = "1.5.0" @@ -3837,6 +4706,85 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d0/71bc50c6d57e3a55216ebd618b67eeb9d568239809382c7dfd870e906c67/pyobjc_framework_libdispatch-12.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:cf3b4befc34a143969db6a0dfcfebaea484c8c3ec527cd73676880b06b5348fc", size = 15986, upload-time = "2025-10-21T08:10:52.515Z" }, ] +[[package]] +name = "pyopenssl" +version = "24.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "cryptography", version = "44.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/d4/1067b82c4fc674d6f6e9e8d26b3dff978da46d351ca3bac171544693e085/pyopenssl-24.3.0.tar.gz", hash = "sha256:49f7a019577d834746bc55c5fce6ecbcec0f2b4ec5ce1cf43a9a173b8138bb36", size = 178944, upload-time = "2024-11-27T20:43:12.755Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/22/40f9162e943f86f0fc927ebc648078be87def360d9d8db346619fb97df2b/pyOpenSSL-24.3.0-py3-none-any.whl", hash = "sha256:e474f5a473cd7f92221cc04976e48f4d11502804657a08a989fb3be5514c904a", size = 56111, upload-time = "2024-11-27T20:43:21.112Z" }, +] + +[[package]] +name = "pyopenssl" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] +dependencies = [ + { name = "cryptography", version = "45.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version == '3.12.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/8c/cd89ad05804f8e3c17dea8f178c3f40eeab5694c30e0c9f5bcd49f576fc3/pyopenssl-25.1.0.tar.gz", hash = "sha256:8d031884482e0c67ee92bf9a4d8cceb08d92aba7136432ffb0703c5280fc205b", size = 179937, upload-time = "2025-05-17T16:28:31.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/28/2659c02301b9500751f8d42f9a6632e1508aa5120de5e43042b8b30f8d5d/pyopenssl-25.1.0-py3-none-any.whl", hash = "sha256:2b11f239acc47ac2e5aca04fd7fa829800aeee22a2eb30d744572a157bd8a1ab", size = 56771, upload-time = "2025-05-17T16:28:29.197Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/d5/e5aeee5387091148a19e1145f63606619cb5f20b83fccb63efae6474e7b2/pyparsing-3.2.0.tar.gz", hash = "sha256:cbf74e27246d595d9a74b186b810f6fbb86726dbf3b9532efb343f6d7294fe9c", size = 920984, upload-time = "2024-10-13T10:01:16.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/ec/2eb3cd785efd67806c46c13a17339708ddc346cbb684eade7a6e6f79536a/pyparsing-3.2.0-py3-none-any.whl", hash = "sha256:93d9577b88da0bbea8cc8334ee8b918ed014968fd2ec383e868fb8afb1ccef84", size = 106921, upload-time = "2024-10-13T10:01:13.682Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.2.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/a5/181488fc2b9d093e3972d2a472855aae8a03f000592dbfce716a512b3359/pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6", size = 1099274, upload-time = "2025-09-21T04:11:06.277Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e", size = 113890, upload-time = "2025-09-21T04:11:04.117Z" }, +] + +[[package]] +name = "pyperclip" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/30/23/2f0a3efc4d6a32f3b63cdff36cd398d9701d26cda58e3ab97ac79fb5e60d/pyperclip-1.9.0.tar.gz", hash = "sha256:b7de0142ddc81bfc5c7507eea19da920b92252b548b96186caf94a5e2527d310", size = 20961, upload-time = "2024-06-18T20:38:48.401Z" } + +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + [[package]] name = "pyserial" version = "3.5" @@ -3978,7 +4926,8 @@ name = "python-can" version = "4.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "msgpack", marker = "sys_platform != 'win32'" }, + { name = "msgpack", version = "1.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' and sys_platform != 'win32'" }, + { name = "msgpack", version = "1.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and sys_platform != 'win32'" }, { name = "packaging" }, { name = "typing-extensions" }, { name = "wrapt" }, @@ -4387,29 +5336,108 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/c5/2ecc91e593a8987fc4d2dfcc0ff6a1f611358e1e1763324e40a5564a7954/rtslib_fb-2.2.3-py3-none-any.whl", hash = "sha256:759b65a93ac4ee88fb367e5cdf153a745c1c045b197fef4da3abefc3ccfe5ff1", size = 49449, upload-time = "2025-04-09T08:05:14.794Z" }, ] +[[package]] +name = "ruamel-yaml" +version = "0.18.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "ruamel-yaml-clib", marker = "python_full_version < '3.12' and platform_python_implementation == 'CPython'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/81/4dfc17eb6ebb1aac314a3eb863c1325b907863a1b8b1382cdffcb6ac0ed9/ruamel.yaml-0.18.6.tar.gz", hash = "sha256:8b27e6a217e786c6fbe5634d8f3f11bc63e0f80f6a5890f28863d9c45aac311b", size = 143362, upload-time = "2024-02-07T06:47:20.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/67/8ece580cc363331d9a53055130f86b096bf16e38156e33b1d3014fffda6b/ruamel.yaml-0.18.6-py3-none-any.whl", hash = "sha256:57b53ba33def16c4f3d807c0ccbc00f8a6081827e81ba2491691b76882d0c636", size = 117761, upload-time = "2024-02-07T06:47:14.898Z" }, +] + +[[package]] +name = "ruamel-yaml" +version = "0.18.16" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] +dependencies = [ + { name = "ruamel-yaml-clib", marker = "python_full_version >= '3.12' and python_full_version < '3.14' and platform_python_implementation == 'CPython'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/c7/ee630b29e04a672ecfc9b63227c87fd7a37eb67c1bf30fe95376437f897c/ruamel.yaml-0.18.16.tar.gz", hash = "sha256:a6e587512f3c998b2225d68aa1f35111c29fad14aed561a26e73fab729ec5e5a", size = 147269, upload-time = "2025-10-22T17:54:02.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/73/bb1bc2529f852e7bf64a2dec885e89ff9f5cc7bbf6c9340eed30ff2c69c5/ruamel.yaml-0.18.16-py3-none-any.whl", hash = "sha256:048f26d64245bae57a4f9ef6feb5b552a386830ef7a826f235ffb804c59efbba", size = 119858, upload-time = "2025-10-22T17:53:59.012Z" }, +] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/97/60fda20e2fb54b83a61ae14648b0817c8f5d84a3821e40bfbdae1437026a/ruamel_yaml_clib-0.2.15.tar.gz", hash = "sha256:46e4cc8c43ef6a94885f72512094e482114a8a706d3c555a34ed4b0d20200600", size = 225794, upload-time = "2025-11-16T16:12:59.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/80/8ce7b9af532aa94dd83360f01ce4716264db73de6bc8efd22c32341f6658/ruamel_yaml_clib-0.2.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c583229f336682b7212a43d2fa32c30e643d3076178fb9f7a6a14dde85a2d8bd", size = 147998, upload-time = "2025-11-16T16:13:13.241Z" }, + { url = "https://files.pythonhosted.org/packages/53/09/de9d3f6b6701ced5f276d082ad0f980edf08ca67114523d1b9264cd5e2e0/ruamel_yaml_clib-0.2.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56ea19c157ed8c74b6be51b5fa1c3aff6e289a041575f0556f66e5fb848bb137", size = 132743, upload-time = "2025-11-16T16:13:14.265Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f7/73a9b517571e214fe5c246698ff3ed232f1ef863c8ae1667486625ec688a/ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5fea0932358e18293407feb921d4f4457db837b67ec1837f87074667449f9401", size = 731459, upload-time = "2025-11-16T20:22:44.338Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a2/0dc0013169800f1c331a6f55b1282c1f4492a6d32660a0cf7b89e6684919/ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71831bd61fbdb7aa0399d5c4da06bea37107ab5c79ff884cc07f2450910262", size = 749289, upload-time = "2025-11-16T16:13:15.633Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ed/3fb20a1a96b8dc645d88c4072df481fe06e0289e4d528ebbdcc044ebc8b3/ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:617d35dc765715fa86f8c3ccdae1e4229055832c452d4ec20856136acc75053f", size = 777630, upload-time = "2025-11-16T16:13:16.898Z" }, + { url = "https://files.pythonhosted.org/packages/60/50/6842f4628bc98b7aa4733ab2378346e1441e150935ad3b9f3c3c429d9408/ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b45498cc81a4724a2d42273d6cfc243c0547ad7c6b87b4f774cb7bcc131c98d", size = 744368, upload-time = "2025-11-16T16:13:18.117Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b0/128ae8e19a7d794c2e36130a72b3bb650ce1dd13fb7def6cf10656437dcf/ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:def5663361f6771b18646620fca12968aae730132e104688766cf8a3b1d65922", size = 745233, upload-time = "2025-11-16T20:22:45.833Z" }, + { url = "https://files.pythonhosted.org/packages/75/05/91130633602d6ba7ce3e07f8fc865b40d2a09efd4751c740df89eed5caf9/ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:014181cdec565c8745b7cbc4de3bf2cc8ced05183d986e6d1200168e5bb59490", size = 770963, upload-time = "2025-11-16T16:13:19.344Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4b/fd4542e7f33d7d1bc64cc9ac9ba574ce8cf145569d21f5f20133336cdc8c/ruamel_yaml_clib-0.2.15-cp311-cp311-win32.whl", hash = "sha256:d290eda8f6ada19e1771b54e5706b8f9807e6bb08e873900d5ba114ced13e02c", size = 102640, upload-time = "2025-11-16T16:13:20.498Z" }, + { url = "https://files.pythonhosted.org/packages/bb/eb/00ff6032c19c7537371e3119287999570867a0eafb0154fccc80e74bf57a/ruamel_yaml_clib-0.2.15-cp311-cp311-win_amd64.whl", hash = "sha256:bdc06ad71173b915167702f55d0f3f027fc61abd975bd308a0968c02db4a4c3e", size = 121996, upload-time = "2025-11-16T16:13:21.855Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/5fde11a0722d676e469d3d6f78c6a17591b9c7e0072ca359801c4bd17eee/ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb15a2e2a90c8475df45c0949793af1ff413acfb0a716b8b94e488ea95ce7cff", size = 149088, upload-time = "2025-11-16T16:13:22.836Z" }, + { url = "https://files.pythonhosted.org/packages/85/82/4d08ac65ecf0ef3b046421985e66301a242804eb9a62c93ca3437dc94ee0/ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64da03cbe93c1e91af133f5bec37fd24d0d4ba2418eaf970d7166b0a26a148a2", size = 134553, upload-time = "2025-11-16T16:13:24.151Z" }, + { url = "https://files.pythonhosted.org/packages/b9/cb/22366d68b280e281a932403b76da7a988108287adff2bfa5ce881200107a/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f6d3655e95a80325b84c4e14c080b2470fe4f33b6846f288379ce36154993fb1", size = 737468, upload-time = "2025-11-16T20:22:47.335Z" }, + { url = "https://files.pythonhosted.org/packages/71/73/81230babf8c9e33770d43ed9056f603f6f5f9665aea4177a2c30ae48e3f3/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71845d377c7a47afc6592aacfea738cc8a7e876d586dfba814501d8c53c1ba60", size = 753349, upload-time = "2025-11-16T16:13:26.269Z" }, + { url = "https://files.pythonhosted.org/packages/61/62/150c841f24cda9e30f588ef396ed83f64cfdc13b92d2f925bb96df337ba9/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e5499db1ccbc7f4b41f0565e4f799d863ea720e01d3e99fa0b7b5fcd7802c9", size = 788211, upload-time = "2025-11-16T16:13:27.441Z" }, + { url = "https://files.pythonhosted.org/packages/30/93/e79bd9cbecc3267499d9ead919bd61f7ddf55d793fb5ef2b1d7d92444f35/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4b293a37dc97e2b1e8a1aec62792d1e52027087c8eea4fc7b5abd2bdafdd6642", size = 743203, upload-time = "2025-11-16T16:13:28.671Z" }, + { url = "https://files.pythonhosted.org/packages/8d/06/1eb640065c3a27ce92d76157f8efddb184bd484ed2639b712396a20d6dce/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:512571ad41bba04eac7268fe33f7f4742210ca26a81fe0c75357fa682636c690", size = 747292, upload-time = "2025-11-16T20:22:48.584Z" }, + { url = "https://files.pythonhosted.org/packages/a5/21/ee353e882350beab65fcc47a91b6bdc512cace4358ee327af2962892ff16/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5e9f630c73a490b758bf14d859a39f375e6999aea5ddd2e2e9da89b9953486a", size = 771624, upload-time = "2025-11-16T16:13:29.853Z" }, + { url = "https://files.pythonhosted.org/packages/57/34/cc1b94057aa867c963ecf9ea92ac59198ec2ee3a8d22a126af0b4d4be712/ruamel_yaml_clib-0.2.15-cp312-cp312-win32.whl", hash = "sha256:f4421ab780c37210a07d138e56dd4b51f8642187cdfb433eb687fe8c11de0144", size = 100342, upload-time = "2025-11-16T16:13:31.067Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e5/8925a4208f131b218f9a7e459c0d6fcac8324ae35da269cb437894576366/ruamel_yaml_clib-0.2.15-cp312-cp312-win_amd64.whl", hash = "sha256:2b216904750889133d9222b7b873c199d48ecbb12912aca78970f84a5aa1a4bc", size = 119013, upload-time = "2025-11-16T16:13:32.164Z" }, + { url = "https://files.pythonhosted.org/packages/17/5e/2f970ce4c573dc30c2f95825f2691c96d55560268ddc67603dc6ea2dd08e/ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dcec721fddbb62e60c2801ba08c87010bd6b700054a09998c4d09c08147b8fb", size = 147450, upload-time = "2025-11-16T16:13:33.542Z" }, + { url = "https://files.pythonhosted.org/packages/d6/03/a1baa5b94f71383913f21b96172fb3a2eb5576a4637729adbf7cd9f797f8/ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:65f48245279f9bb301d1276f9679b82e4c080a1ae25e679f682ac62446fac471", size = 133139, upload-time = "2025-11-16T16:13:34.587Z" }, + { url = "https://files.pythonhosted.org/packages/dc/19/40d676802390f85784235a05788fd28940923382e3f8b943d25febbb98b7/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46895c17ead5e22bea5e576f1db7e41cb273e8d062c04a6a49013d9f60996c25", size = 731474, upload-time = "2025-11-16T20:22:49.934Z" }, + { url = "https://files.pythonhosted.org/packages/ce/bb/6ef5abfa43b48dd55c30d53e997f8f978722f02add61efba31380d73e42e/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3eb199178b08956e5be6288ee0b05b2fb0b5c1f309725ad25d9c6ea7e27f962a", size = 748047, upload-time = "2025-11-16T16:13:35.633Z" }, + { url = "https://files.pythonhosted.org/packages/ff/5d/e4f84c9c448613e12bd62e90b23aa127ea4c46b697f3d760acc32cb94f25/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d1032919280ebc04a80e4fb1e93f7a738129857eaec9448310e638c8bccefcf", size = 782129, upload-time = "2025-11-16T16:13:36.781Z" }, + { url = "https://files.pythonhosted.org/packages/de/4b/e98086e88f76c00c88a6bcf15eae27a1454f661a9eb72b111e6bbb69024d/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab0df0648d86a7ecbd9c632e8f8d6b21bb21b5fc9d9e095c796cacf32a728d2d", size = 736848, upload-time = "2025-11-16T16:13:37.952Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5c/5964fcd1fd9acc53b7a3a5d9a05ea4f95ead9495d980003a557deb9769c7/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:331fb180858dd8534f0e61aa243b944f25e73a4dae9962bd44c46d1761126bbf", size = 741630, upload-time = "2025-11-16T20:22:51.718Z" }, + { url = "https://files.pythonhosted.org/packages/07/1e/99660f5a30fceb58494598e7d15df883a07292346ef5696f0c0ae5dee8c6/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd4c928ddf6bce586285daa6d90680b9c291cfd045fc40aad34e445d57b1bf51", size = 766619, upload-time = "2025-11-16T16:13:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/36/2f/fa0344a9327b58b54970e56a27b32416ffbcfe4dcc0700605516708579b2/ruamel_yaml_clib-0.2.15-cp313-cp313-win32.whl", hash = "sha256:bf0846d629e160223805db9fe8cc7aec16aaa11a07310c50c8c7164efa440aec", size = 100171, upload-time = "2025-11-16T16:13:40.456Z" }, + { url = "https://files.pythonhosted.org/packages/06/c4/c124fbcef0684fcf3c9b72374c2a8c35c94464d8694c50f37eef27f5a145/ruamel_yaml_clib-0.2.15-cp313-cp313-win_amd64.whl", hash = "sha256:45702dfbea1420ba3450bb3dd9a80b33f0badd57539c6aac09f42584303e0db6", size = 118845, upload-time = "2025-11-16T16:13:41.481Z" }, + { url = "https://files.pythonhosted.org/packages/3e/bd/ab8459c8bb759c14a146990bf07f632c1cbec0910d4853feeee4be2ab8bb/ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:753faf20b3a5906faf1fc50e4ddb8c074cb9b251e00b14c18b28492f933ac8ef", size = 147248, upload-time = "2025-11-16T16:13:42.872Z" }, + { url = "https://files.pythonhosted.org/packages/69/f2/c4cec0a30f1955510fde498aac451d2e52b24afdbcb00204d3a951b772c3/ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:480894aee0b29752560a9de46c0e5f84a82602f2bc5c6cde8db9a345319acfdf", size = 133764, upload-time = "2025-11-16T16:13:43.932Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/2480d062281385a2ea4f7cc9476712446e0c548cd74090bff92b4b49e898/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d3b58ab2454b4747442ac76fab66739c72b1e2bb9bd173d7694b9f9dbc9c000", size = 730537, upload-time = "2025-11-16T20:22:52.918Z" }, + { url = "https://files.pythonhosted.org/packages/75/08/e365ee305367559f57ba6179d836ecc3d31c7d3fdff2a40ebf6c32823a1f/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bfd309b316228acecfa30670c3887dcedf9b7a44ea39e2101e75d2654522acd4", size = 746944, upload-time = "2025-11-16T16:13:45.338Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/8b56b08db91e569d0a4fbfa3e492ed2026081bdd7e892f63ba1c88a2f548/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2812ff359ec1f30129b62372e5f22a52936fac13d5d21e70373dbca5d64bb97c", size = 778249, upload-time = "2025-11-16T16:13:46.871Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1d/70dbda370bd0e1a92942754c873bd28f513da6198127d1736fa98bb2a16f/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7e74ea87307303ba91073b63e67f2c667e93f05a8c63079ee5b7a5c8d0d7b043", size = 737140, upload-time = "2025-11-16T16:13:48.349Z" }, + { url = "https://files.pythonhosted.org/packages/5b/87/822d95874216922e1120afb9d3fafa795a18fdd0c444f5c4c382f6dac761/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:713cd68af9dfbe0bb588e144a61aad8dcc00ef92a82d2e87183ca662d242f524", size = 741070, upload-time = "2025-11-16T20:22:54.151Z" }, + { url = "https://files.pythonhosted.org/packages/b9/17/4e01a602693b572149f92c983c1f25bd608df02c3f5cf50fd1f94e124a59/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:542d77b72786a35563f97069b9379ce762944e67055bea293480f7734b2c7e5e", size = 765882, upload-time = "2025-11-16T16:13:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/9f/17/7999399081d39ebb79e807314de6b611e1d1374458924eb2a489c01fc5ad/ruamel_yaml_clib-0.2.15-cp314-cp314-win32.whl", hash = "sha256:424ead8cef3939d690c4b5c85ef5b52155a231ff8b252961b6516ed7cf05f6aa", size = 102567, upload-time = "2025-11-16T16:13:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/67/be582a7370fdc9e6846c5be4888a530dcadd055eef5b932e0e85c33c7d73/ruamel_yaml_clib-0.2.15-cp314-cp314-win_amd64.whl", hash = "sha256:ac9b8d5fa4bb7fd2917ab5027f60d4234345fd366fe39aa711d5dca090aa1467", size = 122847, upload-time = "2025-11-16T16:13:51.807Z" }, +] + [[package]] name = "ruff" -version = "0.15.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" }, - { url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" }, - { url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" }, - { url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" }, - { url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" }, - { url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" }, - { url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" }, - { url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" }, - { url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" }, - { url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" }, - { url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" }, - { url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" }, - { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" }, +version = "0.15.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" }, + { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" }, + { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" }, + { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" }, + { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" }, + { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" }, + { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" }, + { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, ] [[package]] @@ -4468,6 +5496,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/70/b84f9944a03964a88031ef6ac219b6c91e8ba2f373362329d8770ef36f02/semver-2.13.0-py2.py3-none-any.whl", hash = "sha256:ced8b23dceb22134307c1b8abfa523da14198793d9787ac838e70e29e77458d4", size = 12901, upload-time = "2020-10-20T20:16:52.583Z" }, ] +[[package]] +name = "service-identity" +version = "24.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cryptography", version = "44.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "cryptography", version = "45.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pyasn1" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/a5/dfc752b979067947261dbbf2543470c58efe735c3c1301dd870ef27830ee/service_identity-24.2.0.tar.gz", hash = "sha256:b8683ba13f0d39c6cd5d625d2c5f65421d6d707b013b375c355751557cbe8e09", size = 39245, upload-time = "2024-10-26T07:21:57.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/2c/ca6dd598b384bc1ce581e24aaae0f2bed4ccac57749d5c3befbb5e742081/service_identity-24.2.0-py3-none-any.whl", hash = "sha256:6b047fbd8a84fd0bb0d55ebce4031e400562b9196e1e0d3e0fe2b8a59f6d4a85", size = 11364, upload-time = "2024-10-26T07:21:56.302Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -4798,6 +5842,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, ] +[[package]] +name = "tornado" +version = "6.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/59/45/a0daf161f7d6f36c3ea5fc0c2de619746cc3dd4c76402e9db545bd920f63/tornado-6.4.2.tar.gz", hash = "sha256:92bad5b4746e9879fd7bf1eb21dce4e3fc5128d71601f80005afa39237ad620b", size = 501135, upload-time = "2024-11-22T03:06:38.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/7e/71f604d8cea1b58f82ba3590290b66da1e72d840aeb37e0d5f7291bd30db/tornado-6.4.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e828cce1123e9e44ae2a50a9de3055497ab1d0aeb440c5ac23064d9e44880da1", size = 436299, upload-time = "2024-11-22T03:06:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/96/44/87543a3b99016d0bf54fdaab30d24bf0af2e848f1d13d34a3a5380aabe16/tornado-6.4.2-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:072ce12ada169c5b00b7d92a99ba089447ccc993ea2143c9ede887e0937aa803", size = 434253, upload-time = "2024-11-22T03:06:22.39Z" }, + { url = "https://files.pythonhosted.org/packages/cb/fb/fdf679b4ce51bcb7210801ef4f11fdac96e9885daa402861751353beea6e/tornado-6.4.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a017d239bd1bb0919f72af256a970624241f070496635784d9bf0db640d3fec", size = 437602, upload-time = "2024-11-22T03:06:24.214Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/e31aeffffc22b475a64dbeb273026a21b5b566f74dee48742817626c47dc/tornado-6.4.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c36e62ce8f63409301537222faffcef7dfc5284f27eec227389f2ad11b09d946", size = 436972, upload-time = "2024-11-22T03:06:25.559Z" }, + { url = "https://files.pythonhosted.org/packages/22/55/b78a464de78051a30599ceb6983b01d8f732e6f69bf37b4ed07f642ac0fc/tornado-6.4.2-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bca9eb02196e789c9cb5c3c7c0f04fb447dc2adffd95265b2c7223a8a615ccbf", size = 437173, upload-time = "2024-11-22T03:06:27.584Z" }, + { url = "https://files.pythonhosted.org/packages/79/5e/be4fb0d1684eb822c9a62fb18a3e44a06188f78aa466b2ad991d2ee31104/tornado-6.4.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:304463bd0772442ff4d0f5149c6f1c2135a1fae045adf070821c6cdc76980634", size = 437892, upload-time = "2024-11-22T03:06:28.933Z" }, + { url = "https://files.pythonhosted.org/packages/f5/33/4f91fdd94ea36e1d796147003b490fe60a0215ac5737b6f9c65e160d4fe0/tornado-6.4.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:c82c46813ba483a385ab2a99caeaedf92585a1f90defb5693351fa7e4ea0bf73", size = 437334, upload-time = "2024-11-22T03:06:30.428Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ae/c1b22d4524b0e10da2f29a176fb2890386f7bd1f63aacf186444873a88a0/tornado-6.4.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:932d195ca9015956fa502c6b56af9eb06106140d844a335590c1ec7f5277d10c", size = 437261, upload-time = "2024-11-22T03:06:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/b5/25/36dbd49ab6d179bcfc4c6c093a51795a4f3bed380543a8242ac3517a1751/tornado-6.4.2-cp38-abi3-win32.whl", hash = "sha256:2876cef82e6c5978fde1e0d5b1f919d756968d5b4282418f3146b79b58556482", size = 438463, upload-time = "2024-11-22T03:06:34.71Z" }, + { url = "https://files.pythonhosted.org/packages/61/cc/58b1adeb1bb46228442081e746fcdbc4540905c87e8add7c277540934edb/tornado-6.4.2-cp38-abi3-win_amd64.whl", hash = "sha256:908b71bf3ff37d81073356a5fadcc660eb10c1476ee6e2725588626ce7e5ca38", size = 438907, upload-time = "2024-11-22T03:06:36.71Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] +sdist = { url = "https://files.pythonhosted.org/packages/09/ce/1eb500eae19f4648281bb2186927bb062d2438c2e5093d1360391afd2f90/tornado-6.5.2.tar.gz", hash = "sha256:ab53c8f9a0fa351e2c0741284e06c7a45da86afb544133201c5cc8578eb076a0", size = 510821, upload-time = "2025-08-08T18:27:00.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/48/6a7529df2c9cc12efd2e8f5dd219516184d703b34c06786809670df5b3bd/tornado-6.5.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2436822940d37cde62771cff8774f4f00b3c8024fe482e16ca8387b8a2724db6", size = 442563, upload-time = "2025-08-08T18:26:42.945Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b5/9b575a0ed3e50b00c40b08cbce82eb618229091d09f6d14bce80fc01cb0b/tornado-6.5.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:583a52c7aa94ee046854ba81d9ebb6c81ec0fd30386d96f7640c96dad45a03ef", size = 440729, upload-time = "2025-08-08T18:26:44.473Z" }, + { url = "https://files.pythonhosted.org/packages/1b/4e/619174f52b120efcf23633c817fd3fed867c30bff785e2cd5a53a70e483c/tornado-6.5.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0fe179f28d597deab2842b86ed4060deec7388f1fd9c1b4a41adf8af058907e", size = 444295, upload-time = "2025-08-08T18:26:46.021Z" }, + { url = "https://files.pythonhosted.org/packages/95/fa/87b41709552bbd393c85dd18e4e3499dcd8983f66e7972926db8d96aa065/tornado-6.5.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b186e85d1e3536d69583d2298423744740986018e393d0321df7340e71898882", size = 443644, upload-time = "2025-08-08T18:26:47.625Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/fb15f06e33d7430ca89420283a8762a4e6b8025b800ea51796ab5e6d9559/tornado-6.5.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e792706668c87709709c18b353da1f7662317b563ff69f00bab83595940c7108", size = 443878, upload-time = "2025-08-08T18:26:50.599Z" }, + { url = "https://files.pythonhosted.org/packages/11/92/fe6d57da897776ad2e01e279170ea8ae726755b045fe5ac73b75357a5a3f/tornado-6.5.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:06ceb1300fd70cb20e43b1ad8aaee0266e69e7ced38fa910ad2e03285009ce7c", size = 444549, upload-time = "2025-08-08T18:26:51.864Z" }, + { url = "https://files.pythonhosted.org/packages/9b/02/c8f4f6c9204526daf3d760f4aa555a7a33ad0e60843eac025ccfd6ff4a93/tornado-6.5.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:74db443e0f5251be86cbf37929f84d8c20c27a355dd452a5cfa2aada0d001ec4", size = 443973, upload-time = "2025-08-08T18:26:53.625Z" }, + { url = "https://files.pythonhosted.org/packages/ae/2d/f5f5707b655ce2317190183868cd0f6822a1121b4baeae509ceb9590d0bd/tornado-6.5.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b5e735ab2889d7ed33b32a459cac490eda71a1ba6857b0118de476ab6c366c04", size = 443954, upload-time = "2025-08-08T18:26:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/e8/59/593bd0f40f7355806bf6573b47b8c22f8e1374c9b6fd03114bd6b7a3dcfd/tornado-6.5.2-cp39-abi3-win32.whl", hash = "sha256:c6f29e94d9b37a95013bb669616352ddb82e3bfe8326fccee50583caebc8a5f0", size = 445023, upload-time = "2025-08-08T18:26:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2a/f609b420c2f564a748a2d80ebfb2ee02a73ca80223af712fca591386cafb/tornado-6.5.2-cp39-abi3-win_amd64.whl", hash = "sha256:e56a5af51cc30dd2cae649429af65ca2f6571da29504a07995175df14c18f35f", size = 445427, upload-time = "2025-08-08T18:26:57.91Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4f/e1f65e8f8c76d73658b33d33b81eed4322fb5085350e4328d5c956f0c8f9/tornado-6.5.2-cp39-abi3-win_arm64.whl", hash = "sha256:d6c33dc3672e3a1f3618eb63b7ef4683a7688e7b9e6e8f0d9aa5726360a004af", size = 444456, upload-time = "2025-08-08T18:26:59.207Z" }, +] + [[package]] name = "traitlets" version = "5.14.3" @@ -4872,7 +5960,8 @@ name = "types-paramiko" version = "3.5.0.20250516" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, + { name = "cryptography", version = "44.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "cryptography", version = "45.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/f4/6b8b9875f1fe044fedd7f7cde1827f58510e3735c151bdd8aeb9ec2a3dcd/types_paramiko-3.5.0.20250516.tar.gz", hash = "sha256:8c2ac59648c16c5469aa0559abdb758a0f67f9f7cf36164b64654f7b6c2ebfb0", size = 28021, upload-time = "2025-05-16T03:08:56.619Z" } wheels = [ @@ -4959,13 +6048,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] +[[package]] +name = "urwid" +version = "2.6.16" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "wcwidth", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/21/ad23c9e961b2d36d57c63686a6f86768dd945d406323fb58c84f09478530/urwid-2.6.16.tar.gz", hash = "sha256:93ad239939e44c385e64aa00027878b9e5c486d59e855ec8ab5b1e1adcdb32a2", size = 848179, upload-time = "2024-10-15T16:07:24.297Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/cb/271a4f5a1bf4208dbdc96d85b9eae744cf4e5e11ac73eda76dc98c8fd2d7/urwid-2.6.16-py3-none-any.whl", hash = "sha256:de14896c6df9eb759ed1fd93e0384a5279e51e0dde8f621e4083f7a8368c0797", size = 297196, upload-time = "2024-10-15T16:07:22.521Z" }, +] + +[[package]] +name = "urwid" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] +dependencies = [ + { name = "wcwidth", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/d3/09683323e2290732a39dc92ca5031d5e5ddda56f8d236f885a400535b29a/urwid-3.0.3.tar.gz", hash = "sha256:300804dd568cda5aa1c5b204227bd0cfe7a62cef2d00987c5eb2e4e64294ed9b", size = 855817, upload-time = "2025-09-15T10:26:17.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/50/a35894423102d76b9b9ae011ab643d8102120c6dc420e86b16caa7441117/urwid-3.0.3-py3-none-any.whl", hash = "sha256:ede36ecc99a293bbb4b5e5072c7b7bb943eb3bed17decf89b808209ed2dead15", size = 296144, upload-time = "2025-09-15T10:26:15.38Z" }, +] + [[package]] name = "uvicorn" version = "0.34.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, - { name = "h11" }, + { name = "h11", version = "0.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "h11", version = "0.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/ad/713be230bcda622eaa35c28f0d328c3675c371238470abdea52417f17a8e/uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a", size = 76631, upload-time = "2025-06-01T07:48:17.531Z" } wheels = [ @@ -5070,6 +6192,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/d3/254cea30f918f489db09d6a8435a7de7047f8cb68584477a515f160541d6/watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:923fec6e5461c42bd7e3fd5ec37492c6f3468be0499bc0707b4bbbc16ac21792", size = 454009, upload-time = "2025-06-15T19:06:52.896Z" }, ] +[[package]] +name = "wcwidth" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, +] + [[package]] name = "websocket-client" version = "1.8.0" @@ -5375,7 +6506,8 @@ name = "wsproto" version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11" }, + { name = "h11", version = "0.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "h11", version = "0.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d776427904d73c189e10aeae66d7f555bb2feee16d1e4ba5a/wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065", size = 53425, upload-time = "2022-08-23T19:58:21.447Z" } wheels = [ @@ -5477,8 +6609,11 @@ wheels = [ name = "zstandard" version = "0.23.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] dependencies = [ - { name = "cffi", marker = "platform_python_implementation == 'PyPy'" }, + { name = "cffi", marker = "python_full_version < '3.12' and platform_python_implementation == 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ed/f6/2ac0287b442160a89d726b17a9184a4c615bb5237db763791a7fd16d9df1/zstandard-0.23.0.tar.gz", hash = "sha256:b2d8c62d08e7255f68f7a740bae85b3c9b8e5466baa9cbf7f57f1cde0ac6bc09", size = 681701, upload-time = "2024-07-15T00:18:06.141Z" } wheels = [ @@ -5531,3 +6666,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/4c/315ca5c32da7e2dc3455f3b2caee5c8c2246074a61aac6ec3378a97b7136/zstandard-0.23.0-cp313-cp313-win32.whl", hash = "sha256:a9b07268d0c3ca5c170a385a0ab9fb7fdd9f5fd866be004c4ea39e44edce47dd", size = 430862, upload-time = "2024-07-15T00:16:51.003Z" }, { url = "https://files.pythonhosted.org/packages/a2/bf/c6aaba098e2d04781e8f4f7c0ba3c7aa73d00e4c436bcc0cf059a66691d1/zstandard-0.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:f3513916e8c645d0610815c257cbfd3242adfd5c4cfa78be514e5a3ebb42a41b", size = 495578, upload-time = "2024-07-15T00:16:53.135Z" }, ] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" }, + { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" }, + { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" }, + { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" }, + { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" }, + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +]