From fcfe8c8a16690b896b4aaf41e4707f4ac9f58c10 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Wed, 18 Feb 2026 22:32:56 -0500 Subject: [PATCH 01/24] Add core MITM proxy server driver --- .../jumpstarter-driver-mitmproxy/README.md | 161 +++ .../examples/addons/_template.py | 105 ++ .../examples/addons/data_stream_websocket.py | 348 +++++ .../examples/addons/hls_audio_stream.py | 245 ++++ .../examples/addons/mjpeg_stream.py | 309 +++++ .../examples/conftest.py | 132 ++ .../examples/exporter.yaml | 60 + .../examples/scenarios/backend-degraded.json | 78 ++ .../examples/scenarios/full-scenario.json | 151 +++ .../examples/scenarios/happy-path.json | 57 + .../examples/scenarios/media-streaming.json | 66 + .../examples/scenarios/update-available.json | 45 + .../examples/test_device.py | 161 +++ .../jumpstarter_driver_mitmproxy/__init__.py | 1 + .../bundled_addon.py | 694 ++++++++++ .../jumpstarter_driver_mitmproxy/client.py | 641 +++++++++ .../jumpstarter_driver_mitmproxy/driver.py | 1044 +++++++++++++++ .../driver_integration_test.py | 516 ++++++++ .../driver_test.py | 382 ++++++ .../pyproject.toml | 36 + python/pyproject.toml | 1 + python/uv.lock | 1160 ++++++++++++++++- 22 files changed, 6381 insertions(+), 12 deletions(-) create mode 100644 python/packages/jumpstarter-driver-mitmproxy/README.md create mode 100644 python/packages/jumpstarter-driver-mitmproxy/examples/addons/_template.py create mode 100644 python/packages/jumpstarter-driver-mitmproxy/examples/addons/data_stream_websocket.py create mode 100644 python/packages/jumpstarter-driver-mitmproxy/examples/addons/hls_audio_stream.py create mode 100644 python/packages/jumpstarter-driver-mitmproxy/examples/addons/mjpeg_stream.py create mode 100644 python/packages/jumpstarter-driver-mitmproxy/examples/conftest.py create mode 100644 python/packages/jumpstarter-driver-mitmproxy/examples/exporter.yaml create mode 100644 python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/backend-degraded.json create mode 100644 python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/full-scenario.json create mode 100644 python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/happy-path.json create mode 100644 python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/media-streaming.json create mode 100644 python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/update-available.json create mode 100644 python/packages/jumpstarter-driver-mitmproxy/examples/test_device.py create mode 100644 python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/__init__.py create mode 100644 python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py create mode 100644 python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py create mode 100644 python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py create mode 100644 python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver_integration_test.py create mode 100644 python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver_test.py create mode 100644 python/packages/jumpstarter-driver-mitmproxy/pyproject.toml diff --git a/python/packages/jumpstarter-driver-mitmproxy/README.md b/python/packages/jumpstarter-driver-mitmproxy/README.md new file mode 100644 index 000000000..208f87d77 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/README.md @@ -0,0 +1,161 @@ +# 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 and wildcard path matching +- **SSL/TLS interception** — Inspect and modify HTTPS traffic from your DUT +- **Traffic recording & replay** — Capture a "golden" session against real servers, then replay it offline in CI +- **Browser-based UI** — Launch `mitmweb` for interactive traffic inspection during development +- **Scenario files** — Load complete mock configurations from JSON, swap between test scenarios instantly + +## 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_port: 8080 # Proxy port (DUT connects here) + web_port: 8081 # mitmweb browser UI port + ssl_insecure: true # Skip upstream cert verification +``` + +See `examples/exporter.yaml` for a full exporter config with DUT Link, serial, and video drivers. + +## Usage + +### In pytest + +```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 via client.serial, client.video ... + + proxy.stop() +``` + +### With context managers + +```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 +``` + +### From jmp shell + +``` +$ jmp shell --exporter my-bench + +jumpstarter local > j proxy start --mode mock --web-ui +Started in 'mock' mode on 0.0.0.0:8080 | Web UI: http://0.0.0.0:8081 + +jumpstarter local > j proxy status +{"running": true, "mode": "mock", "web_ui_address": "http://0.0.0.0:8081", ...} + +jumpstarter local > j proxy stop +Stopped (was 'mock' mode) +``` + +## Modes + +| Mode | Binary | Description | +| ------------- | ---------------- | ---------------------------------------- | +| `mock` | mitmdump/mitmweb | Intercept traffic, return mock responses | +| `passthrough` | mitmdump/mitmweb | Transparent proxy, log only | +| `record` | mitmdump/mitmweb | Capture all traffic to a flow file | +| `replay` | mitmdump/mitmweb | Serve responses from a recorded flow | + +Add `web_ui=True` to any mode for the browser-based mitmweb interface. + +## Mock Scenarios + +Create JSON files with endpoint definitions: + +```json +{ + "GET /api/v1/status": { + "status": 200, + "body": {"id": "device-001", "status": "active"} + }, + "POST /api/v1/telemetry": { + "status": 202, + "body": {"accepted": true} + }, + "GET /api/v1/search*": { + "status": 200, + "body": {"results": []} + } +} +``` + +Load in tests with `proxy.load_mock_scenario("my-scenario.json")` or the `mock_scenario` context manager. + +## 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 +``` + +## SSL/TLS Setup + +For HTTPS interception, install the mitmproxy CA cert on your DUT: + +```python +# Get the cert path from your test +cert_path = proxy.get_ca_cert_path() +# -> /etc/mitmproxy/mitmproxy-ca-cert.pem +``` + +Then install it on the DUT via serial, adb, or your provisioning system. + +## License + +Apache-2.0 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..a4ef9f5bb --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/_template.py @@ -0,0 +1,105 @@ +""" +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": ...}', + # ) 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..73ad54cd3 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/data_stream_websocket.py @@ -0,0 +1,348 @@ +""" +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() + 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..0bdafb018 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/hls_audio_stream.py @@ -0,0 +1,245 @@ +""" +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] = {} + self._segment_cache: bytes | None = None + + def handle(self, flow: http.HTTPFlow, config: dict) -> bool: + """Route HLS requests to the appropriate handler.""" + path = flow.request.path + 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, 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, + 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 to load a real segment from disk + files_dir = Path("/opt/jumpstarter/mitmproxy/mock-files") + + # 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..cc5a7764e --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/mjpeg_stream.py @@ -0,0 +1,309 @@ +""" +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"), + ) + + if resource == "snapshot.jpg": + self._serve_snapshot( + flow, camera_id, frames_dir, resolution, + ) + return True + + elif resource == "stream.mjpeg": + self._serve_mjpeg_stream( + flow, camera_id, frames_dir, resolution, fps, + ) + return True + + return False + + def _serve_snapshot( + self, + flow: http.HTTPFlow, + camera_id: str, + frames_dir: str, + resolution: list[int], + ): + """Serve a single JPEG snapshot.""" + frame = self._get_frame(camera_id, frames_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, + 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" + frame_interval = 1.0 / fps + burst_duration_s = 10 # Generate 10 seconds of frames + num_frames = int(burst_duration_s * fps) + + parts = [] + for i in range(num_frames): + frame = self._get_frame( + camera_id, frames_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, + 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 + files_base = Path("/opt/jumpstarter/mitmproxy/mock-files") + frame_dir = files_base / 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..0f4125a45 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/exporter.yaml @@ -0,0 +1,60 @@ +# 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 + +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" + listen_port: 8080 + + # Web UI (mitmweb) + web_host: "0.0.0.0" + web_port: 8081 + + # mitmproxy CA and config directory + confdir: "/etc/mitmproxy" + + # Recorded traffic flows + flow_dir: "/var/log/mitmproxy" + + # Addon scripts directory + addon_dir: "/opt/jumpstarter/mitmproxy/addons" + + # Mock endpoint definitions + mock_dir: "/opt/jumpstarter/mitmproxy/mock-responses" + + # Skip upstream SSL verification (DUT talks to test infra) + ssl_insecure: true diff --git a/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/backend-degraded.json b/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/backend-degraded.json new file mode 100644 index 000000000..5d1f2a2d7 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/backend-degraded.json @@ -0,0 +1,78 @@ +{ + "config": { + "files_dir": "/opt/jumpstarter/mitmproxy/mock-files", + "addons_dir": "/opt/jumpstarter/mitmproxy/addons", + "default_latency_ms": 0 + }, + + "endpoints": { + "GET /api/v1/auth/token": { + "$comment": "Auth works 3 times, then starts failing (expired cert simulation)", + "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} + } + ] + }, + + "GET /api/v1/status": { + "$comment": "Intermittent 503s with increasing latency", + "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 + } + ] + }, + + "POST /api/v1/telemetry/upload": { + "status": 503, + "body": {"error": "Backend overloaded"}, + "latency_ms": 8000, + "$comment": "All telemetry uploads time out — tests retry logic" + }, + + "GET /api/v1/updates/check": { + "status": 500, + "body": {"error": "Internal server error"}, + "$comment": "Update service is completely down" + }, + + "GET /api/v1/search*": { + "status": 200, + "body": {"results": []}, + "latency_ms": 4000, + "$comment": "Search works but is very slow" + } + } +} diff --git a/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/full-scenario.json b/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/full-scenario.json new file mode 100644 index 000000000..baf9479a2 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/full-scenario.json @@ -0,0 +1,151 @@ +{ + "$comment": [ + "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" + } + }, + + "POST /api/v1/command": { + "status": 200, + "body": { + "command_id": "cmd-mock-001", + "status": "accepted", + "estimated_completion_s": 15 + }, + "latency_ms": 800, + "$comment": "Simulate realistic command processing delay" + }, + + "GET /api/v1/search*": { + "$comment": "Wildcard: matches any path starting with /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" + } + }, + + "GET /api/v1/updates/download/v2.6.0.bin": { + "$comment": "Serve a real binary file from disk", + "status": 200, + "file": "firmware/v2.6.0-test.bin", + "content_type": "application/octet-stream", + "headers": { + "Content-Disposition": "attachment; filename=\"v2.6.0.bin\"" + } + }, + + "GET /api/v1/media/album-art*": { + "$comment": "Serve image files with pattern matching", + "status": 200, + "file": "images/default-album-art.jpg", + "content_type": "image/jpeg" + }, + + "GET /api/v1/config/theme.json": { + "$comment": "Serve a JSON file directly from disk", + "status": 200, + "file": "config/theme.json", + "content_type": "application/json" + }, + + "POST /api/v1/telemetry/upload": { + "status": 202, + "body": {"accepted": true}, + "match": { + "headers": { + "Content-Type": "application/json" + } + }, + "$comment": "Only matches if Content-Type header is present" + }, + + "POST /api/v1/telemetry/upload#reject": { + "$comment": "Second rule for same path: missing content type -> 400", + "status": 400, + "body": {"error": "Content-Type required"}, + "match": { + "headers_absent": ["Content-Type"] + }, + "priority": 10 + }, + + "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}}" + }, + "$comment": "Template expressions are evaluated per-request for dynamic data" + }, + + "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} + } + ], + "$comment": "Stateful: first 3 requests succeed, 4th fails, then recovers" + }, + + "GET /streaming/audio/channel/*": { + "addon": "hls_audio_stream", + "$comment": "Delegate to a custom addon script for HLS audio simulation" + }, + + "GET /streaming/video/camera/*": { + "addon": "mjpeg_stream", + "$comment": "Delegate to a custom addon for MJPEG video streaming" + }, + + "WEBSOCKET /api/v1/data/realtime": { + "addon": "data_stream_websocket", + "$comment": "Delegate WebSocket connections to a custom addon" + } + } +} diff --git a/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/happy-path.json b/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/happy-path.json new file mode 100644 index 000000000..25cec744f --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/happy-path.json @@ -0,0 +1,57 @@ +{ + "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.json b/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/media-streaming.json new file mode 100644 index 000000000..63418c60a --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/media-streaming.json @@ -0,0 +1,66 @@ +{ + "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.json b/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/update-available.json new file mode 100644 index 000000000..6a4ce8c15 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/update-available.json @@ -0,0 +1,45 @@ +{ + "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" + } + }, + + "GET /api/v1/updates/download/v2.6.0.bin": { + "$comment": "Serve a real test firmware binary from disk", + "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..9a5e610f3 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py @@ -0,0 +1,694 @@ +""" +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 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 + +# ── 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 + {{request_path}} → The matched request path + {{request_header(name)}} → Value of a request header + """ + + _counters: dict[str, int] = defaultdict(int) + _pattern = re.compile(r"\{\{(.+?)\}\}") + + @classmethod + def render(cls, template: Any, flow: http.HTTPFlow | None = None) -> Any: + """Recursively render template expressions in a value.""" + if isinstance(template, str): + return cls._render_string(template, flow) + elif isinstance(template, dict): + return {k: cls.render(v, flow) for k, v in template.items()} + elif isinstance(template, list): + return [cls.render(v, flow) for v in template] + return template + + @classmethod + def _render_string(cls, s: str, flow: http.HTTPFlow | 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) + + # Otherwise, substitute within the string + def replacer(m): + result = cls._evaluate(m.group(1).strip(), flow) + return str(result) + + return cls._pattern.sub(replacer, s) + + @classmethod + def _evaluate(cls, expr: str, flow: http.HTTPFlow | None) -> Any: + """Evaluate a single template expression.""" + if expr == "now_iso": + return datetime.now(timezone.utc).isoformat() + elif expr == "now_epoch": + return int(time.time()) + elif expr == "uuid": + import uuid + return str(uuid.uuid4()) + elif expr.startswith("random_int("): + args = cls._parse_args(expr) + return random.randint(int(args[0]), int(args[1])) + elif expr.startswith("random_float("): + args = cls._parse_args(expr) + return round(random.uniform(float(args[0]), float(args[1])), 2) + elif expr.startswith("random_choice("): + args = cls._parse_args(expr) + return random.choice(args) + elif expr.startswith("counter("): + args = cls._parse_args(expr) + name = args[0] + cls._counters[name] += 1 + return cls._counters[name] + elif expr.startswith("env("): + args = cls._parse_args(expr) + return os.environ.get(args[0], "") + elif expr == "request_path" and flow: + return flow.request.path + elif expr.startswith("request_header(") and flow: + args = cls._parse_args(expr) + return flow.request.headers.get(args[0], "") + else: + ctx.log.warn(f"Unknown template expression: {{{{{expr}}}}}") + return f"{{{{{expr}}}}}" + + @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, + ) + 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" + + +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._capture_client = CaptureClient(CAPTURE_SOCKET) + 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}") + + # ── 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 + 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 == 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)) + + 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 _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 + + # Header presence check + required_headers = match_rules.get("headers", {}) + for header, value in required_headers.items(): + actual = flow.request.headers.get(header) + if actual is None: + return False + if value and actual != value: + return False + + # Header absence check + absent_headers = match_rules.get("headers_absent", []) + for header in absent_headers: + if header in flow.request.headers: + return False + + # Query parameter check + required_params = match_rules.get("query", {}) + for param, value in required_params.items(): + actual = flow.request.query.get(param) + if actual is None: + return False + if value and actual != value: + return False + + # Body content check (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 + + # ── Response generation ───────────────────────────────── + + def request(self, flow: http.HTTPFlow): + """Main request hook: find and apply mock responses.""" + result = self._find_endpoint( + flow.request.method, flow.request.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 response sequences (stateful) + if "sequence" in endpoint: + self._handle_sequence(flow, key, endpoint) + return + + # Handle regular response + self._send_response(flow, endpoint) + + 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: + time.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, + ) + 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 + + def _handle_sequence( + self, flow: http.HTTPFlow, key: str, endpoint: dict, + ): + """Handle stateful response sequences. + + Each entry in the "sequence" list has an optional "repeat" + count. The addon tracks how many times each endpoint has + been called and advances through the sequence. + """ + sequence = endpoint["sequence"] + call_num = self._sequence_state[key] + + # Find which step we're on + position = 0 + for step in sequence: + repeat = step.get("repeat", float("inf")) + if call_num < position + repeat: + self._send_response(flow, step) + self._sequence_state[key] += 1 + return + position += repeat + + # Past the end of the sequence: use last entry + self._send_response(flow, sequence[-1]) + self._sequence_state[key] += 1 + + 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", {}) + + 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 str(file_path).startswith(str(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 _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) + return { + "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")), + } + + def response(self, flow: http.HTTPFlow): + """Log all responses and emit capture events.""" + if flow.response: + 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..fdbf4832f --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py @@ -0,0 +1,641 @@ +""" +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 json +from contextlib import contextmanager +from typing import Generator + +from jumpstarter.client import DriverClient + + +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 = "") -> 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. + + Returns: + Status message with connection details. + """ + return self.call("start", mode, web_ui, replay_file) + + 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 = "") -> 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. + + Returns: + Status message from start(). + """ + return self.call("restart", mode, web_ui, replay_file) + + # ── 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") + + # ── 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_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 file on the exporter. + + Replaces all current mocks. + + Args: + scenario_file: Filename (relative to mock_dir) or + absolute path. + + Returns: + Status message with endpoint count. + """ + return self.call("load_mock_scenario", scenario_file) + + # ── 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")) + + # ── 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") + + # ── 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 clear_captured_requests(self) -> str: + """Clear all captured requests. + + Returns: + Message with the count of cleared requests. + """ + return self.call("clear_captured_requests") + + 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_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 scenario JSON file. + + Example:: + + with proxy.mock_scenario("update-available.json"): + # 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"}, + ) 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..e04ca1b4a --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py @@ -0,0 +1,1044 @@ +""" +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 json +import logging +import os +import signal +import socket +import subprocess +import tempfile +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path + +from jumpstarter.driver import Driver, export + +logger = logging.getLogger(__name__) + + +@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 + addon_dir: "/opt/jumpstarter/mitmproxy/addons" + """ + + # ── Configuration (from exporter YAML) ────────────────────── + + listen_host: str = "0.0.0.0" + """Network interface to bind the proxy listener to.""" + + listen_port: int = 8080 + """Port for the proxy listener (DUT connects here).""" + + web_host: str = "0.0.0.0" + """Network interface to bind the mitmweb UI to.""" + + web_port: int = 8081 + """Port for the mitmweb browser UI.""" + + confdir: str = "/etc/mitmproxy" + """Directory for mitmproxy configuration and CA certificates.""" + + flow_dir: str = "/var/log/mitmproxy" + """Directory for recorded traffic flow files.""" + + addon_dir: str = "/opt/jumpstarter/mitmproxy/addons" + """Directory containing mitmproxy addon scripts.""" + + mock_dir: str = "/opt/jumpstarter/mitmproxy/mock-responses" + """Directory for mock endpoint definition files.""" + + ssl_insecure: bool = True + """Skip upstream SSL certificate verification (useful for dev/test).""" + + # ── 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) + _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) + + @classmethod + def client(cls) -> str: + """Return the import path of the corresponding client class.""" + return "jumpstarter_driver_mitmproxy.client.MitmproxyClient" + + # ── Lifecycle ─────────────────────────────────────────────── + + @export + def start(self, mode: str = "mock", web_ui: bool = False, + replay_file: str = "") -> 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``. + + Returns: + Status message with proxy and (optionally) web UI URLs. + """ + 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.flow_dir).mkdir(parents=True, exist_ok=True) + Path(self.mock_dir).mkdir(parents=True, exist_ok=True) + + # Start capture server (before addon generation so socket path is set) + self._start_capture_server() + + # Select binary: mitmweb for UI, mitmdump for headless + binary = "mitmweb" if web_ui else "mitmdump" + + cmd = [ + binary, + "--listen-host", self.listen_host, + "--listen-port", str(self.listen_port), + "--set", f"confdir={self.confdir}", + "--quiet", + ] + + if web_ui: + cmd.extend([ + "--web-host", self.web_host, + "--web-port", str(self.web_port), + "--set", "web_open_browser=false", + ]) + + if self.ssl_insecure: + cmd.extend(["--set", "ssl_insecure=true"]) + + # Mode-specific flags + if mode == "mock": + self._write_mock_config() + addon_path = Path(self.addon_dir) / "mock_addon.py" + if not addon_path.exists(): + self._generate_default_addon(addon_path) + cmd.extend(["-s", str(addon_path)]) + + elif mode == "record": + timestamp = time.strftime("%Y%m%d_%H%M%S") + flow_file = str( + Path(self.flow_dir) / f"capture_{timestamp}.bin" + ) + cmd.extend(["-w", flow_file]) + self._current_flow_file = flow_file + + elif mode == "replay": + # Resolve relative paths against flow_dir + replay_path = Path(replay_file) + if not replay_path.is_absolute(): + replay_path = Path(self.flow_dir) / 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", + ]) + + # passthrough: no extra flags needed + + logger.info("Starting %s: %s", binary, " ".join(cmd)) + + self._process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + # Wait for startup + time.sleep(2) + + if self._process.poll() is not None: + stderr = self._process.stderr.read().decode() if self._process.stderr else "" + self._process = None + self._stop_capture_server() + return f"Failed to start: {stderr[:500]}" + + self._current_mode = mode + self._web_ui_enabled = web_ui + + 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}" + + if mode == "record" and self._current_flow_file: + msg += f" | Recording to: {self._current_flow_file}" + + return msg + + @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 + + 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() + + 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 = "") -> 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) + + # ── 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}" + 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 + ) + + # ── 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: ``mock_dir/../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_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 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.addon_dir) + 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 file. + + Replaces all current mocks with the contents of the file. + + Args: + scenario_file: Filename (relative to mock_dir) or absolute + path to a JSON mock definitions file. + + Returns: + Status message with count of loaded endpoints. + """ + path = Path(scenario_file) + if not path.is_absolute(): + path = Path(self.mock_dir) / path + + if not path.exists(): + return f"Scenario file not found: {path}" + + try: + with open(path) as f: + raw = json.load(f) + except (json.JSONDecodeError, OSError) as e: + return f"Failed to load scenario: {e}" + + # Handle v2 format (with "endpoints" wrapper) or v1 flat format + if "endpoints" in raw: + self._mock_endpoints = raw["endpoints"] + else: + self._mock_endpoints = raw + + self._write_mock_config() + return ( + f"Loaded {len(self._mock_endpoints)} endpoint(s) " + f"from {path.name}" + ) + + # ── 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.flow_dir) + 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) + + # ── 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.confdir) / "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." + + # ── 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 + 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 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.""" + # Use a short path to avoid the ~104-char AF_UNIX limit on macOS. + # Try mock_dir/../capture.sock first; fall back to a temp file. + preferred = str(Path(self.mock_dir).parent / "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 + + @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 _write_mock_config(self): + """Write mock endpoint definitions to disk in v2 format.""" + mock_path = Path(self.mock_dir) + mock_path.mkdir(parents=True, exist_ok=True) + config_file = mock_path / "endpoints.json" + + files_dir = str(Path(self.mock_dir).parent / "mock-files") + + v2_config = { + "config": { + "files_dir": files_dir, + "addons_dir": self.addon_dir, + "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 _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.mock_dir, + ) + content = content.replace( + '/opt/jumpstarter/mitmproxy/capture.sock', + self._capture_socket_path + or '/opt/jumpstarter/mitmproxy/capture.sock', + ) + 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.mock_dir}/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.mock_dir}" + 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..6bb1f25c9 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver_integration_test.py @@ -0,0 +1,516 @@ +""" +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", + listen_port=proxy_port, + web_host="127.0.0.1", + web_port=web_port, + confdir=str(tmp_path / "confdir"), + flow_dir=str(tmp_path / "flows"), + addon_dir=str(tmp_path / "addons"), + mock_dir=str(tmp_path / "mocks"), + 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): + result = client.start("passthrough") + assert "passthrough" in result + + 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() 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..cccd7fa97 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver_test.py @@ -0,0 +1,382 @@ +""" +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", + listen_port=18080, + web_host="127.0.0.1", + web_port=18081, + confdir=str(tmp_path / "confdir"), + flow_dir=str(tmp_path / "flows"), + addon_dir=str(tmp_path / "addons"), + mock_dir=str(tmp_path / "mocks"), + 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 + + +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 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 reporting.""" + + 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) + + +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() diff --git a/python/packages/jumpstarter-driver-mitmproxy/pyproject.toml b/python/packages/jumpstarter-driver-mitmproxy/pyproject.toml new file mode 100644 index 000000000..24350db84 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/pyproject.toml @@ -0,0 +1,36 @@ +[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"] + +[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..b491ab6b4 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", @@ -155,6 +161,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 +229,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 +339,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 +449,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 +502,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 +707,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 +1065,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 = [ @@ -939,6 +1309,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 +1538,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 +1642,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 +1745,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 +1771,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 +1824,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" }, @@ -2004,6 +2508,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 +3049,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] @@ -2906,6 +3440,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" @@ -3110,10 +3669,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 +3906,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 +3916,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 +3926,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 +4172,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 +4184,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 +4403,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 +4536,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 +4567,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 +4673,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 +4893,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,6 +5303,85 @@ 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" @@ -4468,6 +5463,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" @@ -4872,7 +5883,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 +5971,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 +6115,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 +6429,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 +6532,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 +6589,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" }, +] From f8849866902989d4f92e84a201c51c75e98b80a8 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Wed, 18 Feb 2026 23:23:27 -0500 Subject: [PATCH 02/24] Add state store and conditional mocking for endpoints --- .../bundled_addon.py | 161 +++++++++- .../jumpstarter_driver_mitmproxy/client.py | 114 ++++++- .../jumpstarter_driver_mitmproxy/driver.py | 121 ++++++++ .../driver_integration_test.py | 288 ++++++++++++++++++ .../driver_test.py | 107 +++++++ 5 files changed, 780 insertions(+), 11 deletions(-) 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 index 9a5e610f3..2e26fd214 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py @@ -38,6 +38,33 @@ 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 + + # ── Template engine (lightweight, no dependencies) ────────── @@ -55,39 +82,60 @@ class TemplateEngine: {{env(VAR_NAME)}} → Environment variable {{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) _pattern = re.compile(r"\{\{(.+?)\}\}") @classmethod - def render(cls, template: Any, flow: http.HTTPFlow | None = None) -> Any: + 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) + return cls._render_string(template, flow, state) elif isinstance(template, dict): - return {k: cls.render(v, flow) for k, v in template.items()} + return {k: cls.render(v, flow, state) for k, v in template.items()} elif isinstance(template, list): - return [cls.render(v, flow) for v in template] + return [cls.render(v, flow, state) for v in template] return template @classmethod - def _render_string(cls, s: str, flow: http.HTTPFlow | None) -> Any: + 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) + 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) + 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) -> Any: + def _evaluate( + cls, + expr: str, + flow: http.HTTPFlow | None, + state: dict | None = None, + ) -> Any: """Evaluate a single template expression.""" if expr == "now_iso": return datetime.now(timezone.utc).isoformat() @@ -118,6 +166,32 @@ def _evaluate(cls, expr: str, flow: http.HTTPFlow | None) -> Any: elif expr.startswith("request_header(") and flow: args = cls._parse_args(expr) return flow.request.headers.get(args[0], "") + elif expr == "request_body" and flow: + return flow.request.get_text() or "" + elif expr.startswith("request_body_json(") and flow: + 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]) + elif expr.startswith("request_query(") and flow: + args = cls._parse_args(expr) + return flow.request.query.get(args[0], "") + elif expr.startswith("request_path_segment(") and flow: + 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 "" + elif expr.startswith("state("): + 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 else: ctx.log.warn(f"Unknown template expression: {{{{{expr}}}}}") return f"{{{{{expr}}}}}" @@ -293,6 +367,9 @@ def __init__(self): self._config_path = Path(self.MOCK_DIR) / "endpoints.json" self._sequence_state: dict[str, int] = defaultdict(int) self._capture_client = CaptureClient(CAPTURE_SOCKET) + self._state: dict = {} + self._state_mtime: float = 0 + self._state_path = Path(self.MOCK_DIR) / "state.json" self._load_config() # ── Config loading ────────────────────────────────────── @@ -342,6 +419,23 @@ def _load_config(self): 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( @@ -446,14 +540,32 @@ def _matches_conditions( if body_contains not in body: return False + # Body JSON field check (exact match on parsed JSON fields) + 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 ───────────────────────────────── def request(self, flow: http.HTTPFlow): """Main request hook: find and apply mock responses.""" + self._load_state() + + # 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, flow.request.path, flow, + flow.request.method, path, flow, ) if result is None: @@ -466,6 +578,11 @@ def request(self, flow: http.HTTPFlow): self._handle_addon(flow, endpoint) return + # Handle conditional rules (multiple response variants) + if "rules" in endpoint: + self._handle_rules(flow, key, endpoint) + return + # Handle response sequences (stateful) if "sequence" in endpoint: self._handle_sequence(flow, key, endpoint) @@ -508,7 +625,7 @@ def _send_response(self, flow: http.HTTPFlow, endpoint: dict): return elif "body_template" in endpoint: rendered = TemplateEngine.render( - endpoint["body_template"], flow, + endpoint["body_template"], flow, self._state, ) body = json.dumps(rendered).encode() elif "body" in endpoint: @@ -556,6 +673,30 @@ def _handle_sequence( self._send_response(flow, sequence[-1]) self._sequence_state[key] += 1 + 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: + self._handle_sequence(flow, key, rule) + else: + 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"] diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py index fdbf4832f..ce962a2e3 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py @@ -38,7 +38,7 @@ def test_update_check(client): import json from contextlib import contextmanager -from typing import Generator +from typing import Any, Generator from jumpstarter.client import DriverClient @@ -360,6 +360,118 @@ def load_mock_scenario(self, scenario_file: str) -> str: """ return self.call("load_mock_scenario", scenario_file) + # ── 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]: diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py index e04ca1b4a..57d2a00d5 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py @@ -102,6 +102,7 @@ class MitmproxyDriver(Driver): 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) @@ -634,6 +635,112 @@ def set_mock_addon(self, method: str, path: str, 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. @@ -930,6 +1037,20 @@ def _write_mock_config(self): config_file, ) + def _write_state(self): + """Write shared state store to disk for addon hot-reload.""" + mock_path = Path(self.mock_dir) + 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. 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 index 6bb1f25c9..ff995b611 100644 --- 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 @@ -514,3 +514,291 @@ def test_multiple_requests_captured_in_order(self, client, proxy_port): 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 index cccd7fa97..a70af731f 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver_test.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver_test.py @@ -380,3 +380,110 @@ def test_socket_receives_events(self, mock_popen, driver, tmp_path): 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" From 36e56410e8065a4d38165588c734263b2135a488 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Thu, 19 Feb 2026 00:23:18 -0500 Subject: [PATCH 03/24] Switch to YAML-based mock specs --- .../examples/exporter.yaml | 34 +-- .../examples/scenarios/backend-degraded.json | 78 ------- .../examples/scenarios/backend-degraded.yaml | 56 +++++ .../examples/scenarios/full-scenario.json | 151 ------------- .../examples/scenarios/full-scenario.yaml | 118 +++++++++++ .../examples/scenarios/happy-path.json | 57 ----- .../examples/scenarios/happy-path.yaml | 48 +++++ .../examples/scenarios/media-streaming.json | 66 ------ .../examples/scenarios/media-streaming.yaml | 53 +++++ .../examples/scenarios/update-available.json | 45 ---- .../examples/scenarios/update-available.yaml | 38 ++++ .../jumpstarter_driver_mitmproxy/client.py | 11 +- .../jumpstarter_driver_mitmproxy/driver.py | 198 ++++++++++++------ .../driver_integration_test.py | 18 +- .../driver_test.py | 147 ++++++++++++- 15 files changed, 626 insertions(+), 492 deletions(-) delete mode 100644 python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/backend-degraded.json create mode 100644 python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/backend-degraded.yaml delete mode 100644 python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/full-scenario.json create mode 100644 python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/full-scenario.yaml delete mode 100644 python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/happy-path.json create mode 100644 python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/happy-path.yaml delete mode 100644 python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/media-streaming.json create mode 100644 python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/media-streaming.yaml delete mode 100644 python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/update-available.json create mode 100644 python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/update-available.yaml diff --git a/python/packages/jumpstarter-driver-mitmproxy/examples/exporter.yaml b/python/packages/jumpstarter-driver-mitmproxy/examples/exporter.yaml index 0f4125a45..be863b266 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/examples/exporter.yaml +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/exporter.yaml @@ -37,24 +37,28 @@ export: type: jumpstarter_driver_mitmproxy.driver.MitmproxyDriver config: # Proxy listener (DUT connects here) - listen_host: "0.0.0.0" - listen_port: 8080 + listen: + host: "0.0.0.0" + port: 8080 # Web UI (mitmweb) - web_host: "0.0.0.0" - web_port: 8081 + web: + host: "0.0.0.0" + port: 8081 - # mitmproxy CA and config directory - confdir: "/etc/mitmproxy" - - # Recorded traffic flows - flow_dir: "/var/log/mitmproxy" - - # Addon scripts directory - addon_dir: "/opt/jumpstarter/mitmproxy/addons" - - # Mock endpoint definitions - mock_dir: "/opt/jumpstarter/mitmproxy/mock-responses" + # 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.json b/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/backend-degraded.json deleted file mode 100644 index 5d1f2a2d7..000000000 --- a/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/backend-degraded.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "config": { - "files_dir": "/opt/jumpstarter/mitmproxy/mock-files", - "addons_dir": "/opt/jumpstarter/mitmproxy/addons", - "default_latency_ms": 0 - }, - - "endpoints": { - "GET /api/v1/auth/token": { - "$comment": "Auth works 3 times, then starts failing (expired cert simulation)", - "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} - } - ] - }, - - "GET /api/v1/status": { - "$comment": "Intermittent 503s with increasing latency", - "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 - } - ] - }, - - "POST /api/v1/telemetry/upload": { - "status": 503, - "body": {"error": "Backend overloaded"}, - "latency_ms": 8000, - "$comment": "All telemetry uploads time out — tests retry logic" - }, - - "GET /api/v1/updates/check": { - "status": 500, - "body": {"error": "Internal server error"}, - "$comment": "Update service is completely down" - }, - - "GET /api/v1/search*": { - "status": 200, - "body": {"results": []}, - "latency_ms": 4000, - "$comment": "Search works but is very slow" - } - } -} 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.json b/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/full-scenario.json deleted file mode 100644 index baf9479a2..000000000 --- a/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/full-scenario.json +++ /dev/null @@ -1,151 +0,0 @@ -{ - "$comment": [ - "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" - } - }, - - "POST /api/v1/command": { - "status": 200, - "body": { - "command_id": "cmd-mock-001", - "status": "accepted", - "estimated_completion_s": 15 - }, - "latency_ms": 800, - "$comment": "Simulate realistic command processing delay" - }, - - "GET /api/v1/search*": { - "$comment": "Wildcard: matches any path starting with /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" - } - }, - - "GET /api/v1/updates/download/v2.6.0.bin": { - "$comment": "Serve a real binary file from disk", - "status": 200, - "file": "firmware/v2.6.0-test.bin", - "content_type": "application/octet-stream", - "headers": { - "Content-Disposition": "attachment; filename=\"v2.6.0.bin\"" - } - }, - - "GET /api/v1/media/album-art*": { - "$comment": "Serve image files with pattern matching", - "status": 200, - "file": "images/default-album-art.jpg", - "content_type": "image/jpeg" - }, - - "GET /api/v1/config/theme.json": { - "$comment": "Serve a JSON file directly from disk", - "status": 200, - "file": "config/theme.json", - "content_type": "application/json" - }, - - "POST /api/v1/telemetry/upload": { - "status": 202, - "body": {"accepted": true}, - "match": { - "headers": { - "Content-Type": "application/json" - } - }, - "$comment": "Only matches if Content-Type header is present" - }, - - "POST /api/v1/telemetry/upload#reject": { - "$comment": "Second rule for same path: missing content type -> 400", - "status": 400, - "body": {"error": "Content-Type required"}, - "match": { - "headers_absent": ["Content-Type"] - }, - "priority": 10 - }, - - "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}}" - }, - "$comment": "Template expressions are evaluated per-request for dynamic data" - }, - - "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} - } - ], - "$comment": "Stateful: first 3 requests succeed, 4th fails, then recovers" - }, - - "GET /streaming/audio/channel/*": { - "addon": "hls_audio_stream", - "$comment": "Delegate to a custom addon script for HLS audio simulation" - }, - - "GET /streaming/video/camera/*": { - "addon": "mjpeg_stream", - "$comment": "Delegate to a custom addon for MJPEG video streaming" - }, - - "WEBSOCKET /api/v1/data/realtime": { - "addon": "data_stream_websocket", - "$comment": "Delegate WebSocket connections to a custom addon" - } - } -} 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.json b/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/happy-path.json deleted file mode 100644 index 25cec744f..000000000 --- a/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/happy-path.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "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/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.json b/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/media-streaming.json deleted file mode 100644 index 63418c60a..000000000 --- a/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/media-streaming.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "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/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.json b/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/update-available.json deleted file mode 100644 index 6a4ce8c15..000000000 --- a/python/packages/jumpstarter-driver-mitmproxy/examples/scenarios/update-available.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "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" - } - }, - - "GET /api/v1/updates/download/v2.6.0.bin": { - "$comment": "Serve a real test firmware binary from disk", - "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/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/jumpstarter_driver_mitmproxy/client.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py index ce962a2e3..d2b0d362c 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py @@ -347,13 +347,14 @@ def list_addons(self) -> list[str]: return json.loads(self.call("list_addons")) def load_mock_scenario(self, scenario_file: str) -> str: - """Load a mock scenario from a JSON file on the exporter. + """Load a mock scenario from a JSON or YAML file on the exporter. - Replaces all current mocks. + 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. + absolute path (.json, .yaml, .yml). Returns: Status message with endpoint count. @@ -678,11 +679,11 @@ def mock_scenario( Loads a scenario file on entry and clears all mocks on exit. Args: - scenario_file: Path to scenario JSON file. + scenario_file: Path to scenario file (.json, .yaml, .yml). Example:: - with proxy.mock_scenario("update-available.json"): + with proxy.mock_scenario("update-available.yaml"): # all endpoints from the scenario are active test_full_update_flow() """ diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py index 57d2a00d5..5b154539e 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py @@ -40,11 +40,56 @@ from dataclasses import dataclass, field from pathlib import Path +import yaml +from pydantic import BaseModel, model_validator + from jumpstarter.driver import Driver, export logger = logging.getLogger(__name__) +class ListenConfig(BaseModel): + """Proxy listener address configuration.""" + + host: str = "0.0.0.0" + port: int = 8080 + + +class WebConfig(BaseModel): + """mitmweb UI address configuration.""" + + host: str = "0.0.0.0" + port: int = 8081 + + +class DirectoriesConfig(BaseModel): + """Directory layout configuration. + + All subdirectories default to ``{data}/`` when left empty. + """ + + data: str = "/opt/jumpstarter/mitmproxy" + conf: str = "" + flows: str = "" + addons: str = "" + mocks: str = "" + files: str = "" + + @model_validator(mode="after") + def _resolve_defaults(self) -> "DirectoriesConfig": + 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. @@ -62,39 +107,46 @@ class MitmproxyDriver(Driver): proxy: type: jumpstarter_driver_mitmproxy.driver.MitmproxyDriver config: - listen_port: 8080 - web_port: 8081 - addon_dir: "/opt/jumpstarter/mitmproxy/addons" + listen: + port: 8080 + web: + port: 8081 + directories: + data: /opt/jumpstarter/mitmproxy + ssl_insecure: true + mock_scenario: happy-path.yaml + mocks: + GET /api/v1/health: + status: 200 + body: {ok: true} """ # ── Configuration (from exporter YAML) ────────────────────── - listen_host: str = "0.0.0.0" - """Network interface to bind the proxy listener to.""" + listen: dict = field(default_factory=dict) + """Proxy listener address (host/port). See :class:`ListenConfig`.""" - listen_port: int = 8080 - """Port for the proxy listener (DUT connects here).""" + web: dict = field(default_factory=dict) + """mitmweb UI address (host/port). See :class:`WebConfig`.""" - web_host: str = "0.0.0.0" - """Network interface to bind the mitmweb UI to.""" + directories: dict = field(default_factory=dict) + """Directory layout. See :class:`DirectoriesConfig`.""" - web_port: int = 8081 - """Port for the mitmweb browser UI.""" - - confdir: str = "/etc/mitmproxy" - """Directory for mitmproxy configuration and CA certificates.""" - - flow_dir: str = "/var/log/mitmproxy" - """Directory for recorded traffic flow files.""" + ssl_insecure: bool = True + """Skip upstream SSL certificate verification (useful for dev/test).""" - addon_dir: str = "/opt/jumpstarter/mitmproxy/addons" - """Directory containing mitmproxy addon scripts.""" + mock_scenario: str = "" + """Scenario file to auto-load on startup (relative to mocks dir or absolute).""" - mock_dir: str = "/opt/jumpstarter/mitmproxy/mock-responses" - """Directory for mock endpoint definition files.""" + mocks: dict = field(default_factory=dict) + """Inline mock endpoint definitions, loaded at startup.""" - ssl_insecure: bool = True - """Skip upstream SSL certificate verification (useful for dev/test).""" + def __post_init__(self): + if hasattr(super(), "__post_init__"): + super().__post_init__() + self.listen = ListenConfig.model_validate(self.listen) + self.web = WebConfig.model_validate(self.web) + self.directories = DirectoriesConfig.model_validate(self.directories) # ── Internal state (not from config) ──────────────────────── @@ -168,8 +220,8 @@ def start(self, mode: str = "mock", web_ui: bool = False, return "Error: replay_file is required for replay mode" # Ensure directories exist - Path(self.flow_dir).mkdir(parents=True, exist_ok=True) - Path(self.mock_dir).mkdir(parents=True, exist_ok=True) + 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() @@ -179,16 +231,16 @@ def start(self, mode: str = "mock", web_ui: bool = False, cmd = [ binary, - "--listen-host", self.listen_host, - "--listen-port", str(self.listen_port), - "--set", f"confdir={self.confdir}", + "--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), + "--web-host", self.web.host, + "--web-port", str(self.web.port), "--set", "web_open_browser=false", ]) @@ -197,8 +249,9 @@ def start(self, mode: str = "mock", web_ui: bool = False, # Mode-specific flags if mode == "mock": + self._load_startup_mocks() self._write_mock_config() - addon_path = Path(self.addon_dir) / "mock_addon.py" + addon_path = Path(self.directories.addons) / "mock_addon.py" if not addon_path.exists(): self._generate_default_addon(addon_path) cmd.extend(["-s", str(addon_path)]) @@ -206,7 +259,7 @@ def start(self, mode: str = "mock", web_ui: bool = False, elif mode == "record": timestamp = time.strftime("%Y%m%d_%H%M%S") flow_file = str( - Path(self.flow_dir) / f"capture_{timestamp}.bin" + Path(self.directories.flows) / f"capture_{timestamp}.bin" ) cmd.extend(["-w", flow_file]) self._current_flow_file = flow_file @@ -215,7 +268,7 @@ def start(self, mode: str = "mock", web_ui: bool = False, # Resolve relative paths against flow_dir replay_path = Path(replay_file) if not replay_path.is_absolute(): - replay_path = Path(self.flow_dir) / replay_path + 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}" @@ -248,12 +301,12 @@ def start(self, mode: str = "mock", web_ui: bool = False, msg = ( f"Started in '{mode}' mode on " - f"{self.listen_host}:{self.listen_port} " + 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}" + msg += f" | Web UI: http://{self.web.host}:{self.web.port}" if mode == "record" and self._current_flow_file: msg += f" | Recording to: {self._current_flow_file}" @@ -347,12 +400,12 @@ def status(self) -> str: "mode": self._current_mode, "pid": self._process.pid if running else None, "proxy_address": ( - f"{self.listen_host}:{self.listen_port}" + 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"http://{self.web.host}:{self.web.port}" if running and self._web_ui_enabled else None ), "mock_count": len(self._mock_endpoints), @@ -456,7 +509,7 @@ def set_mock_file(self, method: str, path: str, """Mock an endpoint to serve a file from disk. The file path is relative to the files directory - (default: ``mock_dir/../mock-files/``). + (default: ``{data}/mock-files/``). Args: method: HTTP method (GET, POST, etc.) @@ -748,7 +801,7 @@ def list_addons(self) -> str: Returns: JSON array of addon names (without .py extension). """ - addon_path = Path(self.addon_dir) + addon_path = Path(self.directories.addons) if not addon_path.exists(): return json.dumps([]) @@ -760,28 +813,33 @@ def list_addons(self) -> str: @export def load_mock_scenario(self, scenario_file: str) -> str: - """Load a complete mock scenario from a JSON file. + """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 JSON mock definitions file. + 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.mock_dir) / path + path = Path(self.directories.mocks) / path if not path.exists(): return f"Scenario file not found: {path}" try: with open(path) as f: - raw = json.load(f) - except (json.JSONDecodeError, OSError) as e: + 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) or v1 flat format @@ -805,7 +863,7 @@ def list_flow_files(self) -> str: Returns: JSON array of flow file info (name, size, modified time). """ - flow_path = Path(self.flow_dir) + flow_path = Path(self.directories.flows) files = [] for f in sorted(flow_path.glob("*.bin")): stat = f.stat() @@ -832,7 +890,7 @@ def get_ca_cert_path(self) -> str: Returns: Absolute path to the CA certificate file. """ - cert_path = Path(self.confdir) / "mitmproxy-ca-cert.pem" + 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." @@ -895,8 +953,8 @@ def wait_for_request(self, method: str, path: str, def _start_capture_server(self): """Create a Unix domain socket for receiving capture events.""" # Use a short path to avoid the ~104-char AF_UNIX limit on macOS. - # Try mock_dir/../capture.sock first; fall back to a temp file. - preferred = str(Path(self.mock_dir).parent / "capture.sock") + # 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) @@ -1011,18 +1069,41 @@ def _request_matches(req: dict, method: str, path: str) -> bool: # ── 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.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: + self._mock_endpoints = raw["endpoints"] + else: + self._mock_endpoints = raw + + 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.mock_dir) + mock_path = Path(self.directories.mocks) mock_path.mkdir(parents=True, exist_ok=True) config_file = mock_path / "endpoints.json" - files_dir = str(Path(self.mock_dir).parent / "mock-files") - v2_config = { "config": { - "files_dir": files_dir, - "addons_dir": self.addon_dir, + "files_dir": self.directories.files, + "addons_dir": self.directories.addons, "default_latency_ms": 0, "default_content_type": "application/json", }, @@ -1039,7 +1120,7 @@ def _write_mock_config(self): def _write_state(self): """Write shared state store to disk for addon hot-reload.""" - mock_path = Path(self.mock_dir) + mock_path = Path(self.directories.mocks) mock_path.mkdir(parents=True, exist_ok=True) state_file = mock_path / "state.json" @@ -1070,12 +1151,11 @@ def _generate_default_addon(self, path: Path): content = path.read_text() content = content.replace( '/opt/jumpstarter/mitmproxy/mock-responses', - self.mock_dir, + self.directories.mocks, ) content = content.replace( '/opt/jumpstarter/mitmproxy/capture.sock', - self._capture_socket_path - or '/opt/jumpstarter/mitmproxy/capture.sock', + self._capture_socket_path or '', ) path.write_text(content) logger.info("Installed bundled v2 addon: %s", path) @@ -1085,7 +1165,7 @@ def _generate_default_addon(self, path: Path): addon_code = f'''\ """ Auto-generated mitmproxy addon (v2 format) for DUT backend mocking. -Reads from: {self.mock_dir}/endpoints.json +Reads from: {self.directories.mocks}/endpoints.json Managed by jumpstarter-driver-mitmproxy. """ import json, os, time @@ -1093,7 +1173,7 @@ def _generate_default_addon(self, path: Path): from mitmproxy import http, ctx class MitmproxyMockAddon: - MOCK_DIR = "{self.mock_dir}" + MOCK_DIR = "{self.directories.mocks}" def __init__(self): self.config = {{}} self.endpoints = {{}} 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 index ff995b611..9a73526df 100644 --- 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 @@ -75,14 +75,16 @@ def web_port(): 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", - listen_port=proxy_port, - web_host="127.0.0.1", - web_port=web_port, - confdir=str(tmp_path / "confdir"), - flow_dir=str(tmp_path / "flows"), - addon_dir=str(tmp_path / "addons"), - mock_dir=str(tmp_path / "mocks"), + 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: 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 index a70af731f..271303566 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver_test.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver_test.py @@ -22,14 +22,16 @@ def driver(tmp_path): """Create a MitmproxyDriver with temp directories.""" d = MitmproxyDriver( - listen_host="127.0.0.1", - listen_port=18080, - web_host="127.0.0.1", - web_port=18081, - confdir=str(tmp_path / "confdir"), - flow_dir=str(tmp_path / "flows"), - addon_dir=str(tmp_path / "addons"), - mock_dir=str(tmp_path / "mocks"), + 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 @@ -98,6 +100,83 @@ 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.""" @@ -487,3 +566,55 @@ def test_state_file_written(self, driver, tmp_path): 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 == "0.0.0.0" + assert d.listen.port == 8080 + assert d.web.host == "0.0.0.0" + 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() From c00fe161e444b67aec6dfb86a4e0dab539ad5b5f Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Thu, 19 Feb 2026 00:37:23 -0500 Subject: [PATCH 04/24] Add web UI forwarding and ca-cert command to j CLI --- .../examples/exporter.yaml | 4 + .../jumpstarter_driver_mitmproxy/client.py | 101 ++++++++++++++++++ .../jumpstarter_driver_mitmproxy/driver.py | 31 +++++- .../driver_test.py | 24 ++++- 4 files changed, 158 insertions(+), 2 deletions(-) diff --git a/python/packages/jumpstarter-driver-mitmproxy/examples/exporter.yaml b/python/packages/jumpstarter-driver-mitmproxy/examples/exporter.yaml index be863b266..6b146c94d 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/examples/exporter.yaml +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/exporter.yaml @@ -11,6 +11,10 @@ # Usage: # jmp exporter start my-bench # jmp shell --exporter my-bench +# +# To forward the mitmweb UI to your local machine during a shell session: +# j proxy web # defaults to localhost:8081 +# j proxy web --port 9090 # custom local port export: # -- Hardware interfaces --------------------------------------------------- diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py index d2b0d362c..5c88b6c7d 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py @@ -38,9 +38,14 @@ def test_update_check(client): import json from contextlib import contextmanager +from ipaddress import IPv6Address, ip_address +from threading import Event from typing import Any, Generator +import click + from jumpstarter.client import DriverClient +from jumpstarter.client.decorators import driver_click_group class CaptureContext: @@ -158,6 +163,79 @@ def web_ui_url(self) -> str | None: info = self.status() return info.get("web_ui_address") + # ── CLI (jmp shell) ──────────────────────────────────────── + + def cli(self): + @driver_click_group(self) + def base(): + """Mitmproxy driver""" + pass + + @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}" + click.echo(f"mitmweb UI available at: {url}") + click.echo("Press Ctrl+C to stop forwarding.") + Event().wait() + + @base.command("ca-cert") + @click.argument("output", default="mitmproxy-ca-cert.pem") + def ca_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() + from pathlib import Path + + 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, @@ -496,6 +574,29 @@ def get_ca_cert_path(self) -> str: """ 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]: diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py index 5b154539e..ea6d1dafa 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py @@ -37,13 +37,14 @@ import tempfile import threading import time +from contextlib import asynccontextmanager from dataclasses import dataclass, field from pathlib import Path import yaml from pydantic import BaseModel, model_validator -from jumpstarter.driver import Driver, export +from jumpstarter.driver import Driver, export, exportstream logger = logging.getLogger(__name__) @@ -425,6 +426,18 @@ def is_running(self) -> bool: 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 @@ -895,6 +908,22 @@ def get_ca_cert_path(self) -> str: 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 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 index 271303566..271291383 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver_test.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver_test.py @@ -191,6 +191,15 @@ 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.""" @@ -308,7 +317,7 @@ def test_generates_addon_if_missing(self, mock_popen, driver, tmp_path): class TestCACert: - """Test CA certificate path reporting.""" + """Test CA certificate path and content retrieval.""" def test_ca_cert_not_found(self, driver): result = driver.get_ca_cert_path() @@ -321,6 +330,19 @@ def test_ca_cert_found(self, driver, tmp_path): 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).""" From 21d8e81eda2500ed825b985e36f3d0d18d83f6f1 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Thu, 19 Feb 2026 00:39:07 -0500 Subject: [PATCH 05/24] Add additional client CLI commands --- .../examples/exporter.yaml | 15 +- .../jumpstarter_driver_mitmproxy/client.py | 154 +++++++++++++++++- 2 files changed, 164 insertions(+), 5 deletions(-) diff --git a/python/packages/jumpstarter-driver-mitmproxy/examples/exporter.yaml b/python/packages/jumpstarter-driver-mitmproxy/examples/exporter.yaml index 6b146c94d..0e5fb2b47 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/examples/exporter.yaml +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/exporter.yaml @@ -12,9 +12,18 @@ # jmp exporter start my-bench # jmp shell --exporter my-bench # -# To forward the mitmweb UI to your local machine during a shell session: -# j proxy web # defaults to localhost:8081 -# j proxy web --port 9090 # custom local port +# 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 load-scenario happy.yaml # load mock scenario +# j proxy list-mocks # list configured mocks +# j proxy clear-mocks # remove all mocks +# j proxy list-flows # list recorded flow files +# j proxy captures [--clear] # show captured requests +# j proxy web [--port 9090] # forward mitmweb UI +# j proxy ca-cert [output.pem] # download CA certificate export: # -- Hardware interfaces --------------------------------------------------- diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py index 5c88b6c7d..67c52c94f 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py @@ -165,12 +165,128 @@ def web_ui_url(self) -> str | None: # ── CLI (jmp shell) ──────────────────────────────────────── - def cli(self): + 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.") + def start_cmd(mode: str, web_ui: bool, replay_file: str): + """Start the mitmproxy process.""" + click.echo(self.start(mode=mode, web_ui=web_ui, replay_file=replay_file)) + + @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.") + def restart_cmd(mode: str | None, web_ui: bool, replay_file: str): + """Stop and restart the mitmproxy process.""" + click.echo(self.restart(mode=mode or "", web_ui=web_ui, replay_file=replay_file)) + + # ── 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.command("list-mocks") + def list_mocks_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}") + + @base.command("clear-mocks") + def clear_mocks_cmd(): + """Remove all mock endpoint definitions.""" + click.echo(self.clear_mocks()) + + @base.command("load-scenario") + @click.argument("scenario_file") + def load_scenario_cmd(scenario_file: str): + """Load a mock scenario from a YAML or JSON file. + + SCENARIO_FILE is a filename relative to the mocks directory + on the exporter, or an absolute path. + """ + click.echo(self.load_mock_scenario(scenario_file)) + + # ── Flow file commands ───────────────────────────────── + + @base.command("list-flows") + def list_flows_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', '')})") + + # ── Capture commands ─────────────────────────────────── + + @base.command("captures") + @click.option("--clear", is_flag=True, help="Clear captures after displaying.") + def captures_cmd(clear: bool): + """Show captured requests.""" + reqs = self.get_captured_requests() + if not reqs: + click.echo("No captured requests.") + else: + click.echo(f"{len(reqs)} captured request(s):") + for r in reqs: + status = r.get("response_status", "") + status_str = f" -> {status}" if status else "" + click.echo(f" {r.get('method')} {r.get('path')}{status_str}") + if clear and reqs: + click.echo(self.clear_captured_requests()) + + # ── Web UI forwarding ────────────────────────────────── + @base.command("web") @click.option("--address", default="localhost", show_default=True) @click.option("--port", default=8081, show_default=True, type=int) @@ -219,6 +335,8 @@ async def portforward(*, client, method, local_host, local_port): click.echo("Press Ctrl+C to stop forwarding.") Event().wait() + # ── CA certificate ───────────────────────────────────── + @base.command("ca-cert") @click.argument("output", default="mitmproxy-ca-cert.pem") def ca_cert_cmd(output: str): @@ -227,9 +345,9 @@ def ca_cert_cmd(output: str): 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() from pathlib import Path + pem = self.get_ca_cert() out = Path(output) out.write_text(pem) click.echo(f"CA certificate written to: {out.resolve()}") @@ -853,3 +971,35 @@ def mock_timeout(self, method: str, path: str) -> str: method, path, 504, body={"error": "Gateway Timeout"}, ) + + +# ── CLI helpers ──────────────────────────────────────────────── + + +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" From d252de5eec344d15ff144b67ecaaebd9d575f66f Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Thu, 19 Feb 2026 00:49:21 -0500 Subject: [PATCH 06/24] Improve j CLI interface --- .../jumpstarter-driver-mitmproxy/README.md | 345 +++++++++++++++--- .../examples/exporter.yaml | 13 +- .../jumpstarter_driver_mitmproxy/client.py | 66 ++-- 3 files changed, 340 insertions(+), 84 deletions(-) diff --git a/python/packages/jumpstarter-driver-mitmproxy/README.md b/python/packages/jumpstarter-driver-mitmproxy/README.md index 208f87d77..8231924b0 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/README.md +++ b/python/packages/jumpstarter-driver-mitmproxy/README.md @@ -6,11 +6,13 @@ A [Jumpstarter](https://jumpstarter.dev) driver for [mitmproxy](https://mitmprox 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 and wildcard path matching -- **SSL/TLS interception** — Inspect and modify HTTPS traffic from your DUT +- **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 -- **Browser-based UI** — Launch `mitmweb` for interactive traffic inspection during development -- **Scenario files** — Load complete mock configurations from JSON, swap between test scenarios instantly +- **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 @@ -35,16 +37,103 @@ export: proxy: type: jumpstarter_driver_mitmproxy.driver.MitmproxyDriver config: - listen_port: 8080 # Proxy port (DUT connects here) - web_port: 8081 # mitmweb browser UI port - ssl_insecure: true # Skip upstream cert verification + 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} ``` -See `examples/exporter.yaml` for a full exporter config with DUT Link, serial, and video drivers. +### 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`](examples/exporter.yaml) for a full exporter config with DUT Link, serial, and video drivers. -## Usage +## 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 scenario load happy-path.yaml # load a scenario file +``` + +### Traffic Inspection -### In pytest +```console +j proxy capture list # show captured requests +j proxy capture clear # clear captured requests +j proxy flow list # list recorded flow files +``` + +### 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): @@ -59,12 +148,14 @@ def test_device_status(client): body={"id": "device-001", "status": "active"}, ) - # ... interact with DUT via client.serial, client.video ... + # ... interact with DUT ... proxy.stop() ``` -### With context managers +### Context Managers + +Context managers ensure clean teardown even if the test fails: ```python def test_firmware_update(client): @@ -82,54 +173,206 @@ def test_firmware_update(client): # Proxy auto-stopped here ``` -### From jmp shell +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") ``` -$ jmp shell --exporter my-bench -jumpstarter local > j proxy start --mode mock --web-ui -Started in 'mock' mode on 0.0.0.0:8080 | Web UI: http://0.0.0.0:8081 +### Advanced Mocking + +#### Conditional responses -jumpstarter local > j proxy status -{"running": true, "mode": "mock", "web_ui_address": "http://0.0.0.0:8081", ...} +Return different responses based on request headers, body, or query params: -jumpstarter local > j proxy stop -Stopped (was 'mock' mode) +```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"}}, +]) ``` -## Modes +#### 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 -| Mode | Binary | Description | -| ------------- | ---------------- | ---------------------------------------- | -| `mock` | mitmdump/mitmweb | Intercept traffic, return mock responses | -| `passthrough` | mitmdump/mitmweb | Transparent proxy, log only | -| `record` | mitmdump/mitmweb | Capture all traffic to a flow file | -| `replay` | mitmdump/mitmweb | Serve responses from a recorded flow | +```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. -Add `web_ui=True` to any mode for the browser-based mitmweb interface. +### 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 JSON files with endpoint definitions: +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: -```json -{ - "GET /api/v1/status": { - "status": 200, - "body": {"id": "device-001", "status": "active"} - }, - "POST /api/v1/telemetry": { - "status": 202, - "body": {"accepted": true} - }, - "GET /api/v1/search*": { - "status": 200, - "body": {"results": []} - } -} +```console +j proxy scenario load happy-path.yaml ``` -Load in tests with `proxy.load_mock_scenario("my-scenario.json")` or the `mock_scenario` context manager. +```python +proxy.load_mock_scenario("happy-path.yaml") + +# Or with automatic cleanup: +with proxy.mock_scenario("happy-path.yaml"): + run_tests() +``` + +See [`examples/scenarios/`](examples/scenarios/) 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 @@ -144,18 +387,6 @@ podman run --rm -it --privileged \ jmp exporter start my-bench ``` -## SSL/TLS Setup - -For HTTPS interception, install the mitmproxy CA cert on your DUT: - -```python -# Get the cert path from your test -cert_path = proxy.get_ca_cert_path() -# -> /etc/mitmproxy/mitmproxy-ca-cert.pem -``` - -Then install it on the DUT via serial, adb, or your provisioning system. - ## License Apache-2.0 diff --git a/python/packages/jumpstarter-driver-mitmproxy/examples/exporter.yaml b/python/packages/jumpstarter-driver-mitmproxy/examples/exporter.yaml index 0e5fb2b47..5d2054903 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/examples/exporter.yaml +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/exporter.yaml @@ -17,13 +17,14 @@ # j proxy stop # stop the proxy # j proxy restart [-m passthrough] # restart with new mode # j proxy status # show proxy status -# j proxy load-scenario happy.yaml # load mock scenario -# j proxy list-mocks # list configured mocks -# j proxy clear-mocks # remove all mocks -# j proxy list-flows # list recorded flow files -# j proxy captures [--clear] # show captured requests +# j proxy scenario 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 ca-cert [output.pem] # download CA certificate +# j proxy cert [output.pem] # download CA certificate export: # -- Hardware interfaces --------------------------------------------------- diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py index 67c52c94f..6045a523b 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py @@ -228,8 +228,13 @@ def status_cmd(): # ── Mock management commands ─────────────────────────── - @base.command("list-mocks") - def list_mocks_cmd(): + @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: @@ -239,14 +244,21 @@ def list_mocks_cmd(): summary = _mock_summary(defn) click.echo(f" {key} -> {summary}") - @base.command("clear-mocks") - def clear_mocks_cmd(): + @mock_group.command("clear") + def mock_clear_cmd(): """Remove all mock endpoint definitions.""" click.echo(self.clear_mocks()) - @base.command("load-scenario") + # ── Scenario commands ───────────────────────────────── + + @base.group("scenario") + def scenario_group(): + """Mock scenario management.""" + pass + + @scenario_group.command("load") @click.argument("scenario_file") - def load_scenario_cmd(scenario_file: str): + def scenario_load_cmd(scenario_file: str): """Load a mock scenario from a YAML or JSON file. SCENARIO_FILE is a filename relative to the mocks directory @@ -256,8 +268,13 @@ def load_scenario_cmd(scenario_file: str): # ── Flow file commands ───────────────────────────────── - @base.command("list-flows") - def list_flows_cmd(): + @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: @@ -269,21 +286,28 @@ def list_flows_cmd(): # ── Capture commands ─────────────────────────────────── - @base.command("captures") - @click.option("--clear", is_flag=True, help="Clear captures after displaying.") - def captures_cmd(clear: bool): + @base.group("capture") + def capture_group(): + """Request capture management.""" + 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.") - else: - click.echo(f"{len(reqs)} captured request(s):") - for r in reqs: - status = r.get("response_status", "") - status_str = f" -> {status}" if status else "" - click.echo(f" {r.get('method')} {r.get('path')}{status_str}") - if clear and reqs: - click.echo(self.clear_captured_requests()) + return + click.echo(f"{len(reqs)} captured request(s):") + for r in reqs: + status = r.get("response_status", "") + status_str = f" -> {status}" if status else "" + click.echo(f" {r.get('method')} {r.get('path')}{status_str}") + + @capture_group.command("clear") + def capture_clear_cmd(): + """Clear all captured requests.""" + click.echo(self.clear_captured_requests()) # ── Web UI forwarding ────────────────────────────────── @@ -337,9 +361,9 @@ async def portforward(*, client, method, local_host, local_port): # ── CA certificate ───────────────────────────────────── - @base.command("ca-cert") + @base.command("cert") @click.argument("output", default="mitmproxy-ca-cert.pem") - def ca_cert_cmd(output: str): + 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. From 7686990ca69e1f8287b3d4fe7685e5e0534797d9 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Thu, 19 Feb 2026 01:24:12 -0500 Subject: [PATCH 07/24] Add demo for MITM proxy --- .../demo/backend.py | 128 ++++++++ .../demo/conftest.py | 86 ++++++ .../demo/dut_simulator.py | 143 +++++++++ .../demo/exporter.yaml | 29 ++ .../demo/scenarios/backend-outage.yaml | 18 ++ .../demo/scenarios/happy-path.yaml | 37 +++ .../demo/scenarios/update-available.yaml | 39 +++ .../demo/test_demo.py | 276 ++++++++++++++++++ .../jumpstarter_driver_mitmproxy/client.py | 11 +- .../jumpstarter_driver_mitmproxy/driver.py | 7 +- 10 files changed, 769 insertions(+), 5 deletions(-) create mode 100644 python/packages/jumpstarter-driver-mitmproxy/demo/backend.py create mode 100644 python/packages/jumpstarter-driver-mitmproxy/demo/conftest.py create mode 100644 python/packages/jumpstarter-driver-mitmproxy/demo/dut_simulator.py create mode 100644 python/packages/jumpstarter-driver-mitmproxy/demo/exporter.yaml create mode 100644 python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/backend-outage.yaml create mode 100644 python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/happy-path.yaml create mode 100644 python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/update-available.yaml create mode 100644 python/packages/jumpstarter-driver-mitmproxy/demo/test_demo.py 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..7a5404473 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/demo/conftest.py @@ -0,0 +1,86 @@ +"""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}"} + 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..a63493c58 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/demo/dut_simulator.py @@ -0,0 +1,143 @@ +#!/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 + +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 = url.split(backend)[-1] + 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..f2da6d7bb --- /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 scenario load scenarios/happy-path.yaml +# 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.yaml b/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/backend-outage.yaml new file mode 100644 index 000000000..a822519a5 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/backend-outage.yaml @@ -0,0 +1,18 @@ +# Backend-outage scenario: all API endpoints return 503. +# Demonstrates failure simulation — the DUT sees "Service Unavailable" +# for every request while this scenario is active. + +endpoints: + GET /api/v1/*: + status: 503 + body: + error: Service Unavailable + message: "Backend is down for maintenance" + source: mock + + POST /api/v1/*: + status: 503 + body: + error: Service Unavailable + message: "Backend is down for maintenance" + source: mock diff --git a/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/happy-path.yaml b/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/happy-path.yaml new file mode 100644 index 000000000..8c3f422d2 --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/happy-path.yaml @@ -0,0 +1,37 @@ +# Happy-path scenario: all endpoints return mock success data. +# Device ID is "DUT-MOCK-999" and firmware "2.5.1" — contrast with the +# real backend's "DUT-REAL-001" / "1.0.0" to see the switch visually. + +endpoints: + GET /api/v1/status: + status: 200 + body: + device_id: DUT-MOCK-999 + status: online + firmware_version: "2.5.1" + uptime_s: 172800 + source: mock + + GET /api/v1/updates/check: + status: 200 + body: + update_available: false + current_version: "2.5.1" + latest_version: "2.5.1" + source: mock + + POST /api/v1/telemetry: + status: 200 + body: + accepted: true + source: mock + + GET /api/v1/config: + 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.yaml b/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/update-available.yaml new file mode 100644 index 000000000..2b4d30a2b --- /dev/null +++ b/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/update-available.yaml @@ -0,0 +1,39 @@ +# Update-available scenario: OTA update reported as available. +# Same as happy path but updates/check returns update_available: true +# with a newer firmware version. + +endpoints: + GET /api/v1/status: + status: 200 + body: + device_id: DUT-MOCK-999 + status: online + firmware_version: "2.5.1" + uptime_s: 172800 + source: mock + + GET /api/v1/updates/check: + 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 + + POST /api/v1/telemetry: + status: 200 + body: + accepted: true + source: mock + + GET /api/v1/config: + 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..91b3bb4bc --- /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.yaml")) + time.sleep(1) + + resp = http_session.get( + "http://example.com/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.yaml"), + ) + time.sleep(1) + + resp = http_session.get( + "http://example.com/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.yaml"), + ) + 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_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.yaml"), + ): + time.sleep(1) + resp = http_session.get( + "http://example.com/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/jumpstarter_driver_mitmproxy/client.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py index 6045a523b..268578f92 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py @@ -39,6 +39,7 @@ def test_update_check(client): 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 @@ -261,10 +262,11 @@ def scenario_group(): def scenario_load_cmd(scenario_file: str): """Load a mock scenario from a YAML or JSON file. - SCENARIO_FILE is a filename relative to the mocks directory - on the exporter, or an absolute path. + SCENARIO_FILE is a path to a scenario file. Relative paths + are resolved against the current working directory. """ - click.echo(self.load_mock_scenario(scenario_file)) + resolved = str(Path(scenario_file).resolve()) + click.echo(self.load_mock_scenario(resolved)) # ── Flow file commands ───────────────────────────────── @@ -355,7 +357,8 @@ async def portforward(*, client, method, local_host, local_port): url = f"http://[{host}]:{actual_port}" else: url = f"http://{host}:{actual_port}" - click.echo(f"mitmweb UI available at: {url}") + auth_url = f"{url}/?token=jumpstarter" + click.echo(f"mitmweb UI available at: {auth_url}") click.echo("Press Ctrl+C to stop forwarding.") Event().wait() diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py index ea6d1dafa..bb4038670 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py @@ -243,6 +243,7 @@ def start(self, mode: str = "mock", web_ui: bool = False, "--web-host", self.web.host, "--web-port", str(self.web.port), "--set", "web_open_browser=false", + "--set", "web_password=jumpstarter", ]) if self.ssl_insecure: @@ -307,7 +308,10 @@ def start(self, mode: str = "mock", web_ui: bool = False, ) if web_ui: - msg += f" | Web UI: http://{self.web.host}:{self.web.port}" + 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}" @@ -407,6 +411,7 @@ def status(self) -> str: "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), From 16f369fb840fb9d25c97cdac00a0a44872e65f7f Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Thu, 19 Feb 2026 10:17:57 -0500 Subject: [PATCH 08/24] Fix lint errors --- .../examples/addons/mjpeg_stream.py | 3 +- .../bundled_addon.py | 146 ++++++++++++------ .../jumpstarter_driver_mitmproxy/driver.py | 68 +++++--- 3 files changed, 147 insertions(+), 70 deletions(-) diff --git a/python/packages/jumpstarter-driver-mitmproxy/examples/addons/mjpeg_stream.py b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/mjpeg_stream.py index cc5a7764e..a6256bec2 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/examples/addons/mjpeg_stream.py +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/mjpeg_stream.py @@ -242,12 +242,11 @@ def _serve_mjpeg_stream( MJPEG server alongside mitmproxy. """ boundary = "frame" - frame_interval = 1.0 / fps burst_duration_s = 10 # Generate 10 seconds of frames num_frames = int(burst_duration_s * fps) parts = [] - for i in range(num_frames): + for _ in range(num_frames): frame = self._get_frame( camera_id, frames_dir, resolution, ) 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 index 2e26fd214..ce4384553 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py @@ -137,64 +137,93 @@ def _evaluate( 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() - elif expr == "now_epoch": + if expr == "now_epoch": return int(time.time()) - elif expr == "uuid": + if expr == "uuid": import uuid return str(uuid.uuid4()) - elif expr.startswith("random_int("): + if expr.startswith("random_int("): args = cls._parse_args(expr) return random.randint(int(args[0]), int(args[1])) - elif expr.startswith("random_float("): + if expr.startswith("random_float("): args = cls._parse_args(expr) return round(random.uniform(float(args[0]), float(args[1])), 2) - elif expr.startswith("random_choice("): + if expr.startswith("random_choice("): args = cls._parse_args(expr) return random.choice(args) - elif expr.startswith("counter("): + if expr.startswith("counter("): args = cls._parse_args(expr) name = args[0] cls._counters[name] += 1 return cls._counters[name] - elif expr.startswith("env("): + if expr.startswith("env("): args = cls._parse_args(expr) return os.environ.get(args[0], "") - elif expr == "request_path" and flow: + 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 - elif expr.startswith("request_header(") and flow: + if expr.startswith("request_header("): args = cls._parse_args(expr) return flow.request.headers.get(args[0], "") - elif expr == "request_body" and flow: + if expr == "request_body": return flow.request.get_text() or "" - elif expr.startswith("request_body_json(") and flow: + 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]) - elif expr.startswith("request_query(") and flow: + if expr.startswith("request_query("): args = cls._parse_args(expr) return flow.request.query.get(args[0], "") - elif expr.startswith("request_path_segment(") and flow: + 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 "" - elif expr.startswith("state("): - 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 - else: - ctx.log.warn(f"Unknown template expression: {{{{{expr}}}}}") - return f"{{{{{expr}}}}}" + 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]: @@ -472,6 +501,27 @@ def _find_endpoint( 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 @@ -493,14 +543,6 @@ def _find_endpoint( priority = ep.get("priority", 0) candidates.append((priority, pattern, ep)) - 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 _matches_conditions( self, endpoint: dict, flow: http.HTTPFlow, ) -> bool: @@ -509,38 +551,57 @@ def _matches_conditions( if not match_rules: return True - # Header presence check - required_headers = match_rules.get("headers", {}) - for header, value in required_headers.items(): + 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 - # Header absence check - absent_headers = match_rules.get("headers_absent", []) - for header in absent_headers: + @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 - # Query parameter check - required_params = match_rules.get("query", {}) - for param, value in required_params.items(): + @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 - # Body content check (substring) + @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 - # Body JSON field check (exact match on parsed JSON fields) + @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: @@ -551,7 +612,6 @@ def _matches_conditions( actual = _resolve_dotted_path(body_obj, field_path) if actual != expected: return False - return True # ── Response generation ───────────────────────────────── diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py index bb4038670..7c6bc4702 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py @@ -227,9 +227,41 @@ def start(self, mode: str = "mock", web_ui: bool = False, # Start capture server (before addon generation so socket path is set) self._start_capture_server() - # Select binary: mitmweb for UI, mitmdump for headless - binary = "mitmweb" if web_ui else "mitmdump" + 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.PIPE, + stderr=subprocess.PIPE, + ) + + # Wait for startup + time.sleep(2) + + if self._process.poll() is not None: + stderr = self._process.stderr.read().decode() if self._process.stderr else "" + self._process = None + self._stop_capture_server() + return f"Failed to start: {stderr[:500]}" + + 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, @@ -249,7 +281,12 @@ def start(self, mode: str = "mock", web_ui: bool = False, if self.ssl_insecure: cmd.extend(["--set", "ssl_insecure=true"]) - # Mode-specific flags + 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() @@ -267,7 +304,6 @@ def start(self, mode: str = "mock", web_ui: bool = False, self._current_flow_file = flow_file elif mode == "replay": - # Resolve relative paths against flow_dir replay_path = Path(replay_file) if not replay_path.is_absolute(): replay_path = Path(self.directories.flows) / replay_path @@ -279,28 +315,10 @@ def start(self, mode: str = "mock", web_ui: bool = False, "--server-replay-nopop", ]) - # passthrough: no extra flags needed - - logger.info("Starting %s: %s", binary, " ".join(cmd)) - - self._process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - # Wait for startup - time.sleep(2) - - if self._process.poll() is not None: - stderr = self._process.stderr.read().decode() if self._process.stderr else "" - self._process = None - self._stop_capture_server() - return f"Failed to start: {stderr[:500]}" - - self._current_mode = mode - self._web_ui_enabled = web_ui + 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} " From 4c3a6cd9d6cf3496bf0f165bbe56739d845c0b77 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Thu, 19 Feb 2026 11:56:12 -0500 Subject: [PATCH 09/24] Fix CodeRabbit suggestions --- python/.gitignore | 1 + .../package-apis/drivers/mitmproxy.md | 1 + .../examples/addons/hls_audio_stream.py | 10 +- .../bundled_addon.py | 65 +++++++-- .../jumpstarter_driver_mitmproxy/client.py | 66 +++++++-- .../jumpstarter_driver_mitmproxy/driver.py | 136 ++++++++++++++++-- 6 files changed, 236 insertions(+), 43 deletions(-) create mode 120000 python/docs/source/reference/package-apis/drivers/mitmproxy.md 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/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/examples/addons/hls_audio_stream.py b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/hls_audio_stream.py index 0bdafb018..604d4f359 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/examples/addons/hls_audio_stream.py +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/hls_audio_stream.py @@ -83,6 +83,9 @@ def __init__(self): 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", { @@ -113,8 +116,8 @@ def handle(self, flow: http.HTTPFlow, config: dict) -> bool: ) elif resource.startswith("seg_") and resource.endswith(".aac"): self._serve_segment( - flow, channel_id, resource, segments_dir, - segment_duration, + flow, channel_id, resource, files_dir, + segments_dir, segment_duration, ) else: return False @@ -200,6 +203,7 @@ def _serve_segment( flow: http.HTTPFlow, channel_id: str, resource: str, + files_dir: Path, segments_dir: str, segment_duration: float, ): @@ -209,8 +213,6 @@ def _serve_segment( generated silence if no file exists. This lets you test with real audio when available, but always have a working stream. """ - # Try to load a real segment from disk - files_dir = Path("/opt/jumpstarter/mitmproxy/mock-files") # Try channel-specific segment seg_path = files_dir / segments_dir / f"{channel_id}_{resource}" 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 index ce4384553..2806b05b0 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py @@ -22,6 +22,8 @@ from __future__ import annotations +import asyncio +import hashlib import importlib import importlib.util import json @@ -79,7 +81,7 @@ class TemplateEngine: {{random_choice(a, b, c)}} → Random selection from list {{uuid}} → Random UUID v4 {{counter(name)}} → Auto-incrementing counter - {{env(VAR_NAME)}} → Environment variable + {{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 @@ -91,6 +93,18 @@ class TemplateEngine: """ _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 @@ -177,7 +191,12 @@ def _evaluate_builtin(cls, expr: str) -> Any | None: return cls._counters[name] if expr.startswith("env("): args = cls._parse_args(expr) - return os.environ.get(args[0], "") + 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 @@ -273,6 +292,12 @@ def get_handler(self, name: str) -> Any | None: 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) @@ -300,6 +325,11 @@ def reload(self, name: str): # ── 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: @@ -399,6 +429,9 @@ def __init__(self): 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 ────────────────────────────────────── @@ -616,7 +649,7 @@ def _check_body_json(match_rules: dict, flow: http.HTTPFlow) -> bool: # ── Response generation ───────────────────────────────── - def request(self, flow: http.HTTPFlow): + async def request(self, flow: http.HTTPFlow): """Main request hook: find and apply mock responses.""" self._load_state() @@ -640,18 +673,18 @@ def request(self, flow: http.HTTPFlow): # Handle conditional rules (multiple response variants) if "rules" in endpoint: - self._handle_rules(flow, key, endpoint) + await self._handle_rules(flow, key, endpoint) return # Handle response sequences (stateful) if "sequence" in endpoint: - self._handle_sequence(flow, key, endpoint) + await self._handle_sequence(flow, key, endpoint) return # Handle regular response - self._send_response(flow, endpoint) + await self._send_response(flow, endpoint) - def _send_response(self, flow: http.HTTPFlow, endpoint: dict): + 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( @@ -665,7 +698,7 @@ def _send_response(self, flow: http.HTTPFlow, endpoint: dict): self.config.get("default_latency_ms", 0), ) if latency_ms > 0: - time.sleep(latency_ms / 1000.0) + await asyncio.sleep(latency_ms / 1000.0) # Build response headers resp_headers = {"Content-Type": content_type} @@ -707,7 +740,7 @@ def _send_response(self, flow: http.HTTPFlow, endpoint: dict): flow.response = http.Response.make(status, body, resp_headers) flow.metadata["_jmp_mocked"] = True - def _handle_sequence( + async def _handle_sequence( self, flow: http.HTTPFlow, key: str, endpoint: dict, ): """Handle stateful response sequences. @@ -724,16 +757,16 @@ def _handle_sequence( for step in sequence: repeat = step.get("repeat", float("inf")) if call_num < position + repeat: - self._send_response(flow, step) + await self._send_response(flow, step) self._sequence_state[key] += 1 return position += repeat # Past the end of the sequence: use last entry - self._send_response(flow, sequence[-1]) + await self._send_response(flow, sequence[-1]) self._sequence_state[key] += 1 - def _handle_rules( + async def _handle_rules( self, flow: http.HTTPFlow, key: str, endpoint: dict, ): """Handle conditional mock rules. @@ -747,9 +780,9 @@ def _handle_rules( for rule in rules: if self._matches_conditions(rule, flow): if "sequence" in rule: - self._handle_sequence(flow, key, rule) + await self._handle_sequence(flow, key, rule) else: - self._send_response(flow, rule) + await self._send_response(flow, rule) return # No rule matched — passthrough @@ -762,6 +795,10 @@ def _handle_addon(self, flow: http.HTTPFlow, endpoint: dict): 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 diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py index 268578f92..9e0d1d542 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py @@ -108,18 +108,19 @@ class MitmproxyClient(DriverClient): # ── Lifecycle ─────────────────────────────────────────────── def start(self, mode: str = "mock", web_ui: bool = False, - replay_file: str = "") -> str: + 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) + return self.call("start", mode, web_ui, replay_file, port) def stop(self) -> str: """Stop the proxy process. @@ -130,18 +131,19 @@ def stop(self) -> str: return self.call("stop") def restart(self, mode: str = "", web_ui: bool = False, - replay_file: str = "") -> str: + 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) + return self.call("restart", mode, web_ui, replay_file, port) # ── Status ────────────────────────────────────────────────── @@ -182,9 +184,11 @@ def base(): ) @click.option("--web-ui", "-w", is_flag=True, help="Enable mitmweb browser UI.") @click.option("--replay-file", default="", help="Flow file for replay mode.") - def start_cmd(mode: str, web_ui: bool, replay_file: str): + @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)) + click.echo(self.start(mode=mode, web_ui=web_ui, replay_file=replay_file, port=port)) @base.command("stop") def stop_cmd(): @@ -200,9 +204,12 @@ def stop_cmd(): ) @click.option("--web-ui", "-w", is_flag=True, help="Enable mitmweb browser UI.") @click.option("--replay-file", default="", help="Flow file for replay mode.") - def restart_cmd(mode: str | None, web_ui: bool, replay_file: str): + @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)) + click.echo(self.restart(mode=mode or "", web_ui=web_ui, + replay_file=replay_file, port=port)) # ── Status command ───────────────────────────────────── @@ -262,11 +269,21 @@ def scenario_group(): def scenario_load_cmd(scenario_file: str): """Load a mock scenario from a YAML or JSON file. - SCENARIO_FILE is a path to a scenario file. Relative paths - are resolved against the current working directory. + SCENARIO_FILE is a path to a local scenario file. The file + is read on the client and uploaded to the exporter. """ - resolved = str(Path(scenario_file).resolve()) - click.echo(self.load_mock_scenario(resolved)) + local_path = Path(scenario_file) + 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 + click.echo( + self.load_mock_scenario_content(local_path.name, content) + ) # ── Flow file commands ───────────────────────────────── @@ -360,7 +377,14 @@ async def portforward(*, client, method, local_host, local_port): auth_url = f"{url}/?token=jumpstarter" click.echo(f"mitmweb UI available at: {auth_url}") click.echo("Press Ctrl+C to stop forwarding.") - Event().wait() + 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: + pass # ── CA certificate ───────────────────────────────────── @@ -372,7 +396,6 @@ def cert_cmd(output: str): OUTPUT is the local file path to write the PEM certificate to. Defaults to mitmproxy-ca-cert.pem in the current directory. """ - from pathlib import Path pem = self.get_ca_cert() out = Path(output) @@ -584,6 +607,21 @@ def load_mock_scenario(self, scenario_file: str) -> str: """ 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, diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py index 7c6bc4702..59e670bc4 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py @@ -124,13 +124,13 @@ class MitmproxyDriver(Driver): # ── Configuration (from exporter YAML) ────────────────────── - listen: dict = field(default_factory=dict) + listen: ListenConfig | dict = field(default_factory=dict) """Proxy listener address (host/port). See :class:`ListenConfig`.""" - web: dict = field(default_factory=dict) + web: WebConfig | dict = field(default_factory=dict) """mitmweb UI address (host/port). See :class:`WebConfig`.""" - directories: dict = field(default_factory=dict) + directories: DirectoriesConfig | dict = field(default_factory=dict) """Directory layout. See :class:`DirectoriesConfig`.""" ssl_insecure: bool = True @@ -145,9 +145,12 @@ class MitmproxyDriver(Driver): def __post_init__(self): if hasattr(super(), "__post_init__"): super().__post_init__() - self.listen = ListenConfig.model_validate(self.listen) - self.web = WebConfig.model_validate(self.web) - self.directories = DirectoriesConfig.model_validate(self.directories) + 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) ──────────────────────── @@ -178,6 +181,9 @@ def __post_init__(self): 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: @@ -186,9 +192,21 @@ def client(cls) -> str: # ── 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 = "") -> str: + replay_file: str = "", port: int = 0) -> str: """Start the mitmproxy process. Args: @@ -201,10 +219,13 @@ def start(self, mode: str = "mock", web_ui: bool = False, 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 " @@ -241,7 +262,7 @@ def start(self, mode: str = "mock", web_ui: bool = False, self._process = subprocess.Popen( cmd, - stdout=subprocess.PIPE, + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, ) @@ -249,10 +270,33 @@ def start(self, mode: str = "mock", web_ui: bool = False, 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() - return f"Failed to start: {stderr[:500]}" + # 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 @@ -336,6 +380,32 @@ def _build_start_message(self, mode: str, web_ui: bool) -> str: 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. @@ -370,6 +440,11 @@ def stop(self) -> str: 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 @@ -387,7 +462,7 @@ def stop(self) -> str: @export def restart(self, mode: str = "", web_ui: bool = False, - replay_file: str = "") -> str: + 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. @@ -403,7 +478,7 @@ def restart(self, mode: str = "", web_ui: bool = False, self.stop() time.sleep(1) - return self.start(restart_mode, restart_web, replay_file) + return self.start(restart_mode, restart_web, replay_file, port) # ── Status ────────────────────────────────────────────────── @@ -890,6 +965,45 @@ def load_mock_scenario(self, scenario_file: str) -> str: 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) or v1 flat format + if "endpoints" in raw: + self._mock_endpoints = raw["endpoints"] + else: + self._mock_endpoints = raw + + self._write_mock_config() + return ( + f"Loaded {len(self._mock_endpoints)} endpoint(s) " + f"from {filename}" + ) + # ── Flow file management ──────────────────────────────────── @export From d089919a9b76d67ea420f078da2d19b526d7154e Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Wed, 25 Feb 2026 09:15:12 -0500 Subject: [PATCH 10/24] Improve DX --- .../bundled_addon.py | 196 +++++- .../jumpstarter_driver_mitmproxy/client.py | 376 +++++++++++- .../jumpstarter_driver_mitmproxy/driver.py | 557 +++++++++++++++++- 3 files changed, 1096 insertions(+), 33 deletions(-) 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 index 2806b05b0..2e3926e73 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py @@ -425,6 +425,7 @@ def __init__(self): 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 @@ -745,14 +746,84 @@ async def _handle_sequence( ): """Handle stateful response sequences. - Each entry in the "sequence" list has an optional "repeat" - count. The addon tracks how many times each endpoint has - been called and advances through the sequence. + 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] - # Find which step we're on position = 0 for step in sequence: repeat = step.get("repeat", float("inf")) @@ -892,12 +963,123 @@ def websocket_message(self, flow: http.HTTPFlow): 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) - return { + 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, @@ -907,7 +1089,11 @@ def _build_capture_event( "body": flow.request.get_text() or "", "response_status": response_status, "was_mocked": bool(flow.metadata.get("_jmp_mocked")), + "duration_ms": duration_ms, + "response_size": response_size, } + event.update(body_info) + return event def response(self, flow: http.HTTPFlow): """Log all responses and emit capture events.""" diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py index 9e0d1d542..b0360856c 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py @@ -36,6 +36,8 @@ def test_update_check(client): from __future__ import annotations +import base64 +import fnmatch import json from contextlib import contextmanager from ipaddress import IPv6Address, ip_address @@ -44,6 +46,7 @@ def test_update_check(client): from typing import Any, Generator import click +import yaml from jumpstarter.client import DriverClient from jumpstarter.client.decorators import driver_click_group @@ -257,20 +260,15 @@ def mock_clear_cmd(): """Remove all mock endpoint definitions.""" click.echo(self.clear_mocks()) - # ── Scenario commands ───────────────────────────────── - - @base.group("scenario") - def scenario_group(): - """Mock scenario management.""" - pass - - @scenario_group.command("load") + @mock_group.command("load") @click.argument("scenario_file") - def scenario_load_cmd(scenario_file: str): + def mock_load_cmd(scenario_file: str): """Load a mock scenario from a YAML or JSON file. SCENARIO_FILE is a path to a local scenario file. The file - is read on the client and uploaded to the exporter. + is read on the client and uploaded to the exporter. Any + companion files referenced by 'file:' entries are also + uploaded automatically. """ local_path = Path(scenario_file) if not local_path.exists(): @@ -281,6 +279,10 @@ def scenario_load_cmd(scenario_file: str): 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) ) @@ -305,10 +307,32 @@ def flow_list_cmd(): # ── Capture commands ─────────────────────────────────── - @base.group("capture") - def capture_group(): - """Request capture management.""" - pass + @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(): @@ -319,15 +343,92 @@ def capture_list_cmd(): return click.echo(f"{len(reqs)} captured request(s):") for r in reqs: - status = r.get("response_status", "") - status_str = f" -> {status}" if status else "" - click.echo(f" {r.get('method')} {r.get('path')}{status_str}") + 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("export") + @click.option( + "-o", "--output", + type=click.Path(), + default=None, + help="Output directory for scenario.yaml and response files.", + ) + @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_export_cmd(output: str | None, filter_pattern: str, + exclude_mocked: bool): + """Export captured traffic as a scenario. + + 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. + + When -o is given, creates the 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, + ) + + if output: + out_dir = Path(output) + 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()) + else: + click.echo(yaml_str) + # ── Web UI forwarding ────────────────────────────────── @base.command("web") @@ -384,7 +485,15 @@ async def portforward(*, client, method, local_host, local_port): while not stop.wait(timeout=0.5): pass except KeyboardInterrupt: - pass + 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 ───────────────────────────────────── @@ -790,6 +899,17 @@ def get_captured_requests(self) -> list[dict]: """ 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. @@ -798,6 +918,74 @@ def clear_captured_requests(self) -> str: """ 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. @@ -1041,6 +1229,103 @@ def mock_timeout(self, method: str, path: str) -> str: # ── 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/passthrough tag (fixed 13-char column — length of "[passthrough]") + if was_mocked: + tag = click.style("[mocked]".ljust(13), fg="green") + else: + tag = click.style("[passthrough]", fg="yellow") + + 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: @@ -1068,3 +1353,58 @@ def _human_size(nbytes: int) -> str: return f"{nbytes:.0f} {unit}" if unit == "B" else f"{nbytes:.1f} {unit}" nbytes /= 1024 return f"{nbytes:.1f} TB" + + +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. + """ + entries: list[dict] = [] + for ep in endpoints.values(): + if not isinstance(ep, dict): + continue + entries.append(ep) + for rule in ep.get("rules", []): + if isinstance(rule, dict): + entries.append(rule) + for step in ep.get("sequence", []): + if isinstance(step, dict): + entries.append(step) + 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 + + endpoints = raw.get("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 index 59e670bc4..508589ea1 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py @@ -28,6 +28,9 @@ from __future__ import annotations +import asyncio +import base64 +import fnmatch import json import logging import os @@ -37,9 +40,11 @@ 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 @@ -48,6 +53,207 @@ 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. + """ + 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("_") + 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.""" @@ -334,10 +540,6 @@ def _apply_mode_flags( if mode == "mock": self._load_startup_mocks() self._write_mock_config() - addon_path = Path(self.directories.addons) / "mock_addon.py" - if not addon_path.exists(): - self._generate_default_addon(addon_path) - cmd.extend(["-s", str(addon_path)]) elif mode == "record": timestamp = time.strftime("%Y%m%d_%H%M%S") @@ -359,6 +561,15 @@ def _apply_mode_flags( "--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: @@ -955,9 +1166,12 @@ def load_mock_scenario(self, scenario_file: str) -> str: # Handle v2 format (with "endpoints" wrapper) or v1 flat format if "endpoints" in raw: - self._mock_endpoints = raw["endpoints"] + endpoints = raw["endpoints"] else: - self._mock_endpoints = raw + endpoints = raw + + # Convert URL-keyed endpoints to METHOD /path format + self._mock_endpoints = _convert_url_endpoints(endpoints) self._write_mock_config() return ( @@ -994,9 +1208,12 @@ def load_mock_scenario_content(self, filename: str, content: str) -> str: # Handle v2 format (with "endpoints" wrapper) or v1 flat format if "endpoints" in raw: - self._mock_endpoints = raw["endpoints"] + endpoints = raw["endpoints"] else: - self._mock_endpoints = raw + endpoints = raw + + # Convert URL-keyed endpoints to METHOD /path format + self._mock_endpoints = _convert_url_endpoints(endpoints) self._write_mock_config() return ( @@ -1073,6 +1290,31 @@ def get_captured_requests(self) -> str: 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. @@ -1085,6 +1327,289 @@ def clear_captured_requests(self) -> str: 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. + + 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: @@ -1118,6 +1643,11 @@ def wait_for_request(self, method: str, path: str, 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") @@ -1253,9 +1783,11 @@ def _load_startup_mocks(self): else: raw = json.load(f) if "endpoints" in raw: - self._mock_endpoints = raw["endpoints"] + endpoints = raw["endpoints"] else: - self._mock_endpoints = raw + 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) @@ -1323,6 +1855,10 @@ def _generate_default_addon(self, path: Path): '/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 @@ -1409,3 +1945,4 @@ def response(self, flow: http.HTTPFlow): with open(path, "w") as f: f.write(addon_code) logger.info("Generated fallback v2 addon: %s", path) + From ff92c79635f4b1d93b112331630df8acecb137e3 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Wed, 25 Feb 2026 11:22:17 -0500 Subject: [PATCH 11/24] Fix examples to show proper format and demo --- .../jumpstarter-driver-mitmproxy/README.md | 18 ++- .../demo/exporter.yaml | 6 +- .../demo/scenarios/backend-outage.yaml | 18 --- .../scenarios/backend-outage/scenario.yaml | 37 +++++++ .../demo/scenarios/happy-path.yaml | 37 ------- .../demo/scenarios/happy-path/scenario.yaml | 81 ++++++++++++++ .../demo/scenarios/update-available.yaml | 39 ------- .../scenarios/update-available/scenario.yaml | 55 +++++++++ .../demo/test_demo.py | 8 +- .../examples/exporter.yaml | 2 +- .../jumpstarter_driver_mitmproxy/client.py | 104 +++++++++++------- .../jumpstarter_driver_mitmproxy/driver.py | 35 ++++++ 12 files changed, 297 insertions(+), 143 deletions(-) delete mode 100644 python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/backend-outage.yaml create mode 100644 python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/backend-outage/scenario.yaml delete mode 100644 python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/happy-path.yaml create mode 100644 python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/happy-path/scenario.yaml delete mode 100644 python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/update-available.yaml create mode 100644 python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/update-available/scenario.yaml diff --git a/python/packages/jumpstarter-driver-mitmproxy/README.md b/python/packages/jumpstarter-driver-mitmproxy/README.md index 8231924b0..341575255 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/README.md +++ b/python/packages/jumpstarter-driver-mitmproxy/README.md @@ -111,15 +111,26 @@ j proxy status # show proxy status ```console j proxy mock list # list configured mocks j proxy mock clear # remove all mocks -j proxy scenario load happy-path.yaml # load a scenario file +j proxy mock load happy-path.yaml # load a scenario file +j proxy mock load my-capture/ # load a saved capture directory ``` -### Traffic Inspection +### 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 @@ -349,7 +360,8 @@ endpoints: Load from CLI or Python: ```console -j proxy scenario load happy-path.yaml +j proxy mock load happy-path.yaml +j proxy mock load my-capture/ # directory from 'capture save' ``` ```python diff --git a/python/packages/jumpstarter-driver-mitmproxy/demo/exporter.yaml b/python/packages/jumpstarter-driver-mitmproxy/demo/exporter.yaml index f2da6d7bb..9fb59cfab 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/demo/exporter.yaml +++ b/python/packages/jumpstarter-driver-mitmproxy/demo/exporter.yaml @@ -6,7 +6,7 @@ # # Then inside the shell: # j proxy start -m mock -w -# j proxy scenario load scenarios/happy-path.yaml +# j proxy mock load scenarios/happy-path # j proxy mock clear apiVersion: jumpstarter.dev/v1alpha1 @@ -19,8 +19,8 @@ export: type: jumpstarter_driver_mitmproxy.driver.MitmproxyDriver config: listen: - host: "127.0.0.1" - port: 8080 + host: "192.168.105.10" + port: 8887 web: host: "127.0.0.1" port: 8081 diff --git a/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/backend-outage.yaml b/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/backend-outage.yaml deleted file mode 100644 index a822519a5..000000000 --- a/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/backend-outage.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# Backend-outage scenario: all API endpoints return 503. -# Demonstrates failure simulation — the DUT sees "Service Unavailable" -# for every request while this scenario is active. - -endpoints: - GET /api/v1/*: - status: 503 - body: - error: Service Unavailable - message: "Backend is down for maintenance" - source: mock - - POST /api/v1/*: - status: 503 - body: - error: Service Unavailable - message: "Backend is down for maintenance" - source: mock 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..887837853 --- /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://example.com/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.yaml b/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/happy-path.yaml deleted file mode 100644 index 8c3f422d2..000000000 --- a/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/happy-path.yaml +++ /dev/null @@ -1,37 +0,0 @@ -# Happy-path scenario: all endpoints return mock success data. -# Device ID is "DUT-MOCK-999" and firmware "2.5.1" — contrast with the -# real backend's "DUT-REAL-001" / "1.0.0" to see the switch visually. - -endpoints: - GET /api/v1/status: - status: 200 - body: - device_id: DUT-MOCK-999 - status: online - firmware_version: "2.5.1" - uptime_s: 172800 - source: mock - - GET /api/v1/updates/check: - status: 200 - body: - update_available: false - current_version: "2.5.1" - latest_version: "2.5.1" - source: mock - - POST /api/v1/telemetry: - status: 200 - body: - accepted: true - source: mock - - GET /api/v1/config: - 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/happy-path/scenario.yaml b/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/happy-path/scenario.yaml new file mode 100644 index 000000000..fb5d71ad1 --- /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://example.com/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://example.com/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://example.com/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://example.com/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.yaml b/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/update-available.yaml deleted file mode 100644 index 2b4d30a2b..000000000 --- a/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/update-available.yaml +++ /dev/null @@ -1,39 +0,0 @@ -# Update-available scenario: OTA update reported as available. -# Same as happy path but updates/check returns update_available: true -# with a newer firmware version. - -endpoints: - GET /api/v1/status: - status: 200 - body: - device_id: DUT-MOCK-999 - status: online - firmware_version: "2.5.1" - uptime_s: 172800 - source: mock - - GET /api/v1/updates/check: - 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 - - POST /api/v1/telemetry: - status: 200 - body: - accepted: true - source: mock - - GET /api/v1/config: - 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..627a72c53 --- /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://example.com/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://example.com/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://example.com/api/v1/telemetry: + - method: POST + status: 200 + body: + accepted: true + source: mock + + http://example.com/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 index 91b3bb4bc..a587132ec 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/demo/test_demo.py +++ b/python/packages/jumpstarter-driver-mitmproxy/demo/test_demo.py @@ -141,7 +141,7 @@ class TestScenarioLoading: def test_load_happy_path( self, backend_server, proxy_client, http_session, ): - proxy_client.load_mock_scenario(str(SCENARIOS_DIR / "happy-path.yaml")) + proxy_client.load_mock_scenario(str(SCENARIOS_DIR / "happy-path")) time.sleep(1) resp = http_session.get( @@ -156,7 +156,7 @@ def test_load_update_available( self, backend_server, proxy_client, http_session, ): proxy_client.load_mock_scenario( - str(SCENARIOS_DIR / "update-available.yaml"), + str(SCENARIOS_DIR / "update-available"), ) time.sleep(1) @@ -172,7 +172,7 @@ def test_load_backend_outage( self, backend_server, proxy_client, http_session, ): proxy_client.load_mock_scenario( - str(SCENARIOS_DIR / "backend-outage.yaml"), + str(SCENARIOS_DIR / "backend-outage"), ) time.sleep(1) @@ -187,7 +187,7 @@ def test_mock_scenario_context_manager( ): """mock_scenario() loads on entry, clears on exit.""" with proxy_client.mock_scenario( - str(SCENARIOS_DIR / "happy-path.yaml"), + str(SCENARIOS_DIR / "happy-path"), ): time.sleep(1) resp = http_session.get( diff --git a/python/packages/jumpstarter-driver-mitmproxy/examples/exporter.yaml b/python/packages/jumpstarter-driver-mitmproxy/examples/exporter.yaml index 5d2054903..9e2870bc5 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/examples/exporter.yaml +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/exporter.yaml @@ -17,7 +17,7 @@ # j proxy stop # stop the proxy # j proxy restart [-m passthrough] # restart with new mode # j proxy status # show proxy status -# j proxy scenario load happy.yaml # load mock scenario +# 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 diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py index b0360856c..a20bb08b5 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py @@ -263,14 +263,17 @@ def mock_clear_cmd(): @mock_group.command("load") @click.argument("scenario_file") def mock_load_cmd(scenario_file: str): - """Load a mock scenario from a YAML or JSON file. + """Load a mock scenario from a YAML/JSON file or a directory. - SCENARIO_FILE is a path to a local scenario file. The file - is read on the client and uploaded to the exporter. Any - companion files referenced by 'file:' entries are also - uploaded automatically. + 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 @@ -305,6 +308,20 @@ def flow_list_cmd(): 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) @@ -374,13 +391,8 @@ def capture_watch_cmd(filter_pattern: str): except KeyboardInterrupt: pass - @capture_group.command("export") - @click.option( - "-o", "--output", - type=click.Path(), - default=None, - help="Output directory for scenario.yaml and response files.", - ) + @capture_group.command("save") + @click.argument("directory", type=click.Path()) @click.option( "-f", "--filter", "filter_pattern", @@ -392,42 +404,39 @@ def capture_watch_cmd(filter_pattern: str): is_flag=True, help="Skip requests served by the mock addon.", ) - def capture_export_cmd(output: str | None, filter_pattern: str, - exclude_mocked: bool): - """Export captured traffic as a scenario. + 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. - When -o is given, creates the directory and writes - scenario.yaml plus any response files inside it. + 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, ) - if output: - out_dir = Path(output) - 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()) - else: - click.echo(yaml_str) + 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 ────────────────────────────────── @@ -853,6 +862,24 @@ def list_flow_files(self) -> list[dict]: """ 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: @@ -1151,11 +1178,12 @@ def mock_scenario( Loads a scenario file on entry and clears all mocks on exit. Args: - scenario_file: Path to scenario file (.json, .yaml, .yml). + scenario_file: Path to a scenario file (.json, .yaml, .yml) + or a scenario directory containing ``scenario.yaml``. Example:: - with proxy.mock_scenario("update-available.yaml"): + with proxy.mock_scenario("update-available"): # all endpoints from the scenario are active test_full_update_flow() """ diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py index 508589ea1..a09da850f 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py @@ -1151,6 +1151,8 @@ def load_mock_scenario(self, scenario_file: str) -> str: 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}" @@ -1245,6 +1247,39 @@ def list_flow_files(self) -> str: }) 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 From ae78beee2325ebe5c91205de02ba72f6f7b9c451 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Wed, 25 Feb 2026 11:23:05 -0500 Subject: [PATCH 12/24] Fix exporter.yaml --- .../packages/jumpstarter-driver-mitmproxy/demo/exporter.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/packages/jumpstarter-driver-mitmproxy/demo/exporter.yaml b/python/packages/jumpstarter-driver-mitmproxy/demo/exporter.yaml index 9fb59cfab..59ea41d06 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/demo/exporter.yaml +++ b/python/packages/jumpstarter-driver-mitmproxy/demo/exporter.yaml @@ -19,8 +19,8 @@ export: type: jumpstarter_driver_mitmproxy.driver.MitmproxyDriver config: listen: - host: "192.168.105.10" - port: 8887 + host: "127.0.0.1" + port: 8080 web: host: "127.0.0.1" port: 8081 From 0dedc0f29d04dd1d02218b4fb3ca27a61c737b0e Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Wed, 25 Feb 2026 11:27:47 -0500 Subject: [PATCH 13/24] Update demo --- .../demo/scenarios/backend-outage/scenario.yaml | 2 +- .../demo/scenarios/happy-path/scenario.yaml | 8 ++++---- .../demo/scenarios/update-available/scenario.yaml | 8 ++++---- .../jumpstarter-driver-mitmproxy/demo/test_demo.py | 8 ++++---- 4 files changed, 13 insertions(+), 13 deletions(-) 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 index 887837853..f0d6229be 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/backend-outage/scenario.yaml +++ b/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/backend-outage/scenario.yaml @@ -6,7 +6,7 @@ # the monitoring system does not raise false alerts. endpoints: - http://example.com/api/v1/*: + http://127.0.0.1:9000/api/v1/*: - method: GET match: headers_absent: 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 index fb5d71ad1..069efa4bc 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/happy-path/scenario.yaml +++ b/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/happy-path/scenario.yaml @@ -9,7 +9,7 @@ # match.headers — require specific request headers endpoints: - http://example.com/api/v1/status: + http://127.0.0.1:9000/api/v1/status: - method: GET match: headers: @@ -33,7 +33,7 @@ endpoints: uptime_s: 172800 source: mock - http://example.com/api/v1/updates/check: + http://127.0.0.1:9000/api/v1/updates/check: - method: GET match: query: @@ -53,7 +53,7 @@ endpoints: latest_version: "2.5.1" source: mock - http://example.com/api/v1/telemetry: + http://127.0.0.1:9000/api/v1/telemetry: - method: POST match: body_json: @@ -69,7 +69,7 @@ endpoints: accepted: true source: mock - http://example.com/api/v1/config: + http://127.0.0.1:9000/api/v1/config: - method: GET status: 200 body: 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 index 627a72c53..d5d970678 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/update-available/scenario.yaml +++ b/python/packages/jumpstarter-driver-mitmproxy/demo/scenarios/update-available/scenario.yaml @@ -5,7 +5,7 @@ # all other devices receive the update notification. endpoints: - http://example.com/api/v1/status: + http://127.0.0.1:9000/api/v1/status: - method: GET status: 200 body: @@ -15,7 +15,7 @@ endpoints: uptime_s: 172800 source: mock - http://example.com/api/v1/updates/check: + http://127.0.0.1:9000/api/v1/updates/check: - method: GET match: headers: @@ -36,14 +36,14 @@ endpoints: size_bytes: 524288000 source: mock - http://example.com/api/v1/telemetry: + http://127.0.0.1:9000/api/v1/telemetry: - method: POST status: 200 body: accepted: true source: mock - http://example.com/api/v1/config: + http://127.0.0.1:9000/api/v1/config: - method: GET status: 200 body: diff --git a/python/packages/jumpstarter-driver-mitmproxy/demo/test_demo.py b/python/packages/jumpstarter-driver-mitmproxy/demo/test_demo.py index a587132ec..8e0cd56bd 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/demo/test_demo.py +++ b/python/packages/jumpstarter-driver-mitmproxy/demo/test_demo.py @@ -145,7 +145,7 @@ def test_load_happy_path( time.sleep(1) resp = http_session.get( - "http://example.com/api/v1/status", timeout=10, + f"{BACKEND_URL}/api/v1/status", timeout=10, ) data = resp.json() assert data["source"] == "mock" @@ -161,7 +161,7 @@ def test_load_update_available( time.sleep(1) resp = http_session.get( - "http://example.com/api/v1/updates/check", timeout=10, + f"{BACKEND_URL}/api/v1/updates/check", timeout=10, ) data = resp.json() assert data["source"] == "mock" @@ -177,7 +177,7 @@ def test_load_backend_outage( time.sleep(1) resp = http_session.get( - "http://example.com/api/v1/status", timeout=10, + f"{BACKEND_URL}/api/v1/status", timeout=10, ) assert resp.status_code == 503 assert resp.json()["error"] == "Service Unavailable" @@ -191,7 +191,7 @@ def test_mock_scenario_context_manager( ): time.sleep(1) resp = http_session.get( - "http://example.com/api/v1/status", timeout=10, + f"{BACKEND_URL}/api/v1/status", timeout=10, ) assert resp.json()["source"] == "mock" From af43dffd2571062377e5be39be0b8c2fe21be676 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Wed, 25 Feb 2026 11:40:47 -0500 Subject: [PATCH 14/24] Improve exporter config scenario loading --- .../jumpstarter_driver_mitmproxy/driver.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py index a09da850f..6d8c06839 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py @@ -321,7 +321,8 @@ class MitmproxyDriver(Driver): directories: data: /opt/jumpstarter/mitmproxy ssl_insecure: true - mock_scenario: happy-path.yaml + 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 @@ -343,7 +344,12 @@ class MitmproxyDriver(Driver): """Skip upstream SSL certificate verification (useful for dev/test).""" mock_scenario: str = "" - """Scenario file to auto-load on startup (relative to mocks dir or absolute).""" + """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.""" @@ -1811,6 +1817,8 @@ def _load_startup_mocks(self): 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"): From d0d098a04c2462cd121fc5f66fdfd5278a450a15 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Wed, 25 Feb 2026 11:56:47 -0500 Subject: [PATCH 15/24] Fix docs warnings --- python/packages/jumpstarter-driver-mitmproxy/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/packages/jumpstarter-driver-mitmproxy/README.md b/python/packages/jumpstarter-driver-mitmproxy/README.md index 341575255..7f5f3f82e 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/README.md +++ b/python/packages/jumpstarter-driver-mitmproxy/README.md @@ -75,7 +75,7 @@ export: | `mock_scenario` | Scenario file to auto-load on startup | str | `""` | | `mocks` | Inline mock endpoint definitions | dict | `{}` | -See [`examples/exporter.yaml`](examples/exporter.yaml) for a full exporter config with DUT Link, serial, and video drivers. +See `examples/exporter.yaml` in the package source for a full exporter config with DUT Link, serial, and video drivers. ## Modes @@ -372,7 +372,7 @@ with proxy.mock_scenario("happy-path.yaml"): run_tests() ``` -See [`examples/scenarios/`](examples/scenarios/) for complete scenario examples including conditional rules, templates, and sequences. +See `examples/scenarios/` in the package source for complete scenario examples including conditional rules, templates, and sequences. ## Web UI Port Forwarding From da223e86a15cf162299a4f313f8804dd5c2bd2b7 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Wed, 25 Feb 2026 12:11:26 -0500 Subject: [PATCH 16/24] Fix CodeRabbit nits --- .../demo/conftest.py | 6 +++++- .../demo/dut_simulator.py | 3 ++- .../examples/addons/_template.py | 7 +++++++ .../examples/addons/data_stream_websocket.py | 1 + .../examples/addons/hls_audio_stream.py | 1 - .../examples/addons/mjpeg_stream.py | 17 +++++++++++------ .../bundled_addon.py | 2 +- .../jumpstarter_driver_mitmproxy/driver.py | 4 ++-- .../driver_integration_test.py | 5 ++++- .../jumpstarter_driver_mitmproxy/driver_test.py | 4 ++-- .../jumpstarter-driver-mitmproxy/pyproject.toml | 6 +++++- 11 files changed, 40 insertions(+), 16 deletions(-) diff --git a/python/packages/jumpstarter-driver-mitmproxy/demo/conftest.py b/python/packages/jumpstarter-driver-mitmproxy/demo/conftest.py index 7a5404473..085fbc786 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/demo/conftest.py +++ b/python/packages/jumpstarter-driver-mitmproxy/demo/conftest.py @@ -82,5 +82,9 @@ def proxy_client(proxy): 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}"} + 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 index a63493c58..37e9f122e 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/demo/dut_simulator.py +++ b/python/packages/jumpstarter-driver-mitmproxy/demo/dut_simulator.py @@ -18,6 +18,7 @@ import json import sys import time +from urllib.parse import urlsplit import requests @@ -82,7 +83,7 @@ def run_cycle(session: requests.Session, backend: str): ] for method, url in endpoints: - path = url.split(backend)[-1] + path = urlsplit(url).path try: if method == "POST": resp = session.post( diff --git a/python/packages/jumpstarter-driver-mitmproxy/examples/addons/_template.py b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/_template.py index a4ef9f5bb..f03d343e6 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/examples/addons/_template.py +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/_template.py @@ -103,3 +103,10 @@ def websocket_message(self, flow: http.HTTPFlow, config: dict): # "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 index 73ad54cd3..289007ce0 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/examples/addons/data_stream_websocket.py +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/data_stream_websocket.py @@ -163,6 +163,7 @@ def websocket_message(self, flow: http.HTTPFlow, config: dict): 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"], 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 index 604d4f359..2d610f97c 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/examples/addons/hls_audio_stream.py +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/hls_audio_stream.py @@ -78,7 +78,6 @@ class Handler: def __init__(self): self._sequence_counters: dict[str, int] = {} - self._segment_cache: bytes | None = None def handle(self, flow: http.HTTPFlow, config: dict) -> bool: """Route HLS requests to the appropriate handler.""" diff --git a/python/packages/jumpstarter-driver-mitmproxy/examples/addons/mjpeg_stream.py b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/mjpeg_stream.py index a6256bec2..590127866 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/examples/addons/mjpeg_stream.py +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/mjpeg_stream.py @@ -177,16 +177,19 @@ def handle(self, flow: http.HTTPFlow, config: dict) -> bool: 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, resolution, + flow, camera_id, frames_dir, files_dir, resolution, ) return True elif resource == "stream.mjpeg": self._serve_mjpeg_stream( - flow, camera_id, frames_dir, resolution, fps, + flow, camera_id, frames_dir, files_dir, resolution, fps, ) return True @@ -197,10 +200,11 @@ def _serve_snapshot( 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, resolution) + frame = self._get_frame(camera_id, frames_dir, files_dir, resolution) flow.response = http.Response.make( 200, @@ -217,6 +221,7 @@ def _serve_mjpeg_stream( flow: http.HTTPFlow, camera_id: str, frames_dir: str, + files_dir: Path, resolution: list[int], fps: int, ): @@ -248,7 +253,7 @@ def _serve_mjpeg_stream( parts = [] for _ in range(num_frames): frame = self._get_frame( - camera_id, frames_dir, resolution, + camera_id, frames_dir, files_dir, resolution, ) parts.append( f"--{boundary}\r\n" @@ -280,6 +285,7 @@ def _get_frame( self, camera_id: str, frames_dir: str, + files_dir: Path, resolution: list[int], ) -> bytes: """Get the next frame for a camera. @@ -291,8 +297,7 @@ def _get_frame( self._frame_counters[camera_id] = counter + 1 # Try loading from files directory - files_base = Path("/opt/jumpstarter/mitmproxy/mock-files") - frame_dir = files_base / frames_dir + frame_dir = files_dir / frames_dir if frame_dir.exists(): frames = sorted(frame_dir.glob("*.jpg")) 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 index 2e3926e73..0a7d90fe8 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py @@ -920,7 +920,7 @@ def _read_file(self, relative_path: str) -> bytes | None: try: file_path = file_path.resolve() files_dir_resolved = self.files_dir.resolve() - if not str(file_path).startswith(str(files_dir_resolved)): + if not file_path.is_relative_to(files_dir_resolved): ctx.log.error(f"Path traversal blocked: {relative_path}") return None except (OSError, ValueError): diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py index 6d8c06839..48791f09b 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py @@ -258,14 +258,14 @@ def _query_file_suffix(query: dict) -> str: class ListenConfig(BaseModel): """Proxy listener address configuration.""" - host: str = "0.0.0.0" + host: str = "127.0.0.1" port: int = 8080 class WebConfig(BaseModel): """mitmweb UI address configuration.""" - host: str = "0.0.0.0" + host: str = "127.0.0.1" port: int = 8081 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 index 9a73526df..745dc7d27 100644 --- 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 @@ -131,9 +131,12 @@ def test_stop_proxy(self, client, proxy_port): assert status["running"] is False assert status["mode"] == "stopped" - def test_start_passthrough_mode(self, client): + 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 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 index 271291383..5b6ef78a1 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver_test.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver_test.py @@ -604,9 +604,9 @@ def test_defaults_from_data_dir(self): 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 == "0.0.0.0" + assert d.listen.host == "127.0.0.1" assert d.listen.port == 8080 - assert d.web.host == "0.0.0.0" + assert d.web.host == "127.0.0.1" assert d.web.port == 8081 finally: d._stop_capture_server() diff --git a/python/packages/jumpstarter-driver-mitmproxy/pyproject.toml b/python/packages/jumpstarter-driver-mitmproxy/pyproject.toml index 24350db84..654851ac5 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/pyproject.toml +++ b/python/packages/jumpstarter-driver-mitmproxy/pyproject.toml @@ -15,7 +15,11 @@ MitmproxyDriver = "jumpstarter_driver_mitmproxy.driver:MitmproxyDriver" addopts = "--cov --cov-report=html --cov-report=xml" log_cli = true log_cli_level = "INFO" -testpaths = ["jumpstarter_driver_mitmproxy"] +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"] From 295d6c04ae034b2af0ea9da0682ddbac09203edb Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sat, 28 Feb 2026 14:25:59 -0500 Subject: [PATCH 17/24] Improve path handling and cleanup of spools --- .../jumpstarter_driver_mitmproxy/driver.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py index 48791f09b..e6f90bdbe 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py @@ -208,6 +208,8 @@ def _write_captured_file( # 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) @@ -671,6 +673,11 @@ def stop(self) -> str: # 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}" @@ -1455,6 +1462,10 @@ def _build_grouped_endpoints( ) -> 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 @@ -1791,6 +1802,25 @@ def _stop_capture_server(self): 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. From 3c3170e148ebfc10a5b029055077d7f092a8b7bd Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sat, 28 Feb 2026 18:52:48 -0500 Subject: [PATCH 18/24] Add support for patching specific JSON keys --- .../bundled_addon.py | 87 ++++++- .../jumpstarter_driver_mitmproxy/client.py | 111 ++++++++- .../jumpstarter_driver_mitmproxy/driver.py | 45 +++- .../driver_test.py | 228 ++++++++++++++++++ 4 files changed, 455 insertions(+), 16 deletions(-) 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 index 0a7d90fe8..f25b56014 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py @@ -67,6 +67,54 @@ def _resolve_dotted_path(obj, path: str): 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]``. + - Scalar/list patch values replace the target value. + """ + for key, value in patch.items(): + m = _ARRAY_KEY_RE.match(key) + if m: + base_key, index = m.group(1), int(m.group(2)) + array = target[base_key] + 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 + + +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) ────────── @@ -682,6 +730,11 @@ async def request(self, flow: http.HTTPFlow): 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"] + return + # Handle regular response await self._send_response(flow, endpoint) @@ -1089,6 +1142,7 @@ def _build_capture_event( "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, } @@ -1096,8 +1150,39 @@ def _build_capture_event( return event def response(self, flow: http.HTTPFlow): - """Log all responses and emit capture events.""" + """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}" + ) + ctx.log.debug( f"{flow.request.method} {flow.request.pretty_url} " f"→ {flow.response.status_code}" diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py index a20bb08b5..356b06e64 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py @@ -613,6 +613,36 @@ def set_mock_file(self, method: str, path: str, content_type, status, json.dumps(headers or {}), ) + def set_mock_patch(self, method: str, path: str, + patches: dict) -> 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.) + path: URL path to match. + patches: Dict to deep-merge into the response body. Use + ``key[N]`` syntax for array indexing. + + 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), + ) + def set_mock_with_latency(self, method: str, path: str, status: int = 200, body: dict | list | str = "", @@ -1169,6 +1199,40 @@ def mock_endpoint( 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, @@ -1345,11 +1409,14 @@ def _format_capture_entry(entry: dict) -> str: # Format response size (fixed 8-char column) size_str = click.style(_human_size(response_size).rjust(8), fg="bright_black") - # Mock/passthrough tag (fixed 13-char column — length of "[passthrough]") - if was_mocked: + # 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="yellow") + tag = click.style("[passthrough]", fg="bright_black") return f" {ts_str} {styled_method} {path_col} {styled_status} {dur_str} {size_str} {tag}" @@ -1386,19 +1453,32 @@ def _human_size(nbytes: int) -> str: 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. + 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 not isinstance(ep, dict): + if isinstance(ep, list): + # URL-keyed list format: each item is an endpoint dict + items = ep + elif isinstance(ep, dict): + items = [ep] + else: continue - entries.append(ep) - for rule in ep.get("rules", []): - if isinstance(rule, dict): - entries.append(rule) - for step in ep.get("sequence", []): - if isinstance(step, dict): - entries.append(step) + for item in items: + if not isinstance(item, dict): + continue + entries.append(item) + # Check nested response dict + 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 @@ -1422,7 +1502,12 @@ def _upload_scenario_files( if not isinstance(raw, dict): return - endpoints = raw.get("endpoints", raw) + if "endpoints" in raw: + endpoints = raw["endpoints"] + elif "mocks" in raw: + endpoints = raw["mocks"] + else: + endpoints = raw if not isinstance(endpoints, dict): return diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py index e6f90bdbe..923635903 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py @@ -130,6 +130,9 @@ def _flatten_entry(entry: dict) -> tuple[str, dict]: 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") @@ -886,6 +889,36 @@ def set_mock_file(self, method: str, path: str, 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) -> 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.) + path: URL path to match. + patches_json: JSON string of the patch dict to deep-merge + into the response body. + + 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}" + self._mock_endpoints[key] = {"patch": patches} + 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, @@ -1179,9 +1212,12 @@ def load_mock_scenario(self, scenario_file: str) -> str: except (json.JSONDecodeError, yaml.YAMLError, OSError) as e: return f"Failed to load scenario: {e}" - # Handle v2 format (with "endpoints" wrapper) or v1 flat format + # 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 @@ -1221,9 +1257,12 @@ def load_mock_scenario_content(self, filename: str, content: str) -> str: if not isinstance(raw, dict): return "Invalid scenario: expected a JSON/YAML object" - # Handle v2 format (with "endpoints" wrapper) or v1 flat format + # 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 @@ -1857,6 +1896,8 @@ def _load_startup_mocks(self): 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 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 index 5b6ef78a1..191f2ab39 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver_test.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver_test.py @@ -640,3 +640,231 @@ def test_inline_mocks_preloaded(self, tmp_path): 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" + ) From c971e21f3151c1e27d5183a07f1791b349cce0d5 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Thu, 5 Mar 2026 16:36:46 -0500 Subject: [PATCH 19/24] Add header matching --- .../jumpstarter_driver_mitmproxy/bundled_addon.py | 12 +++++++++++- .../jumpstarter_driver_mitmproxy/client.py | 8 ++++++-- .../jumpstarter_driver_mitmproxy/driver.py | 13 ++++++++++--- 3 files changed, 27 insertions(+), 6 deletions(-) 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 index f25b56014..bed62acc5 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py @@ -616,7 +616,8 @@ def _collect_wildcard_matches( prefix = pat_path.rstrip("*") match_method = ( - pat_method == method + pat_method == "*" + or pat_method == method or (is_websocket and pat_method == "WEBSOCKET") ) @@ -733,6 +734,8 @@ async def request(self, flow: http.HTTPFlow): # 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 @@ -1183,6 +1186,13 @@ def response(self, flow: http.HTTPFlow): 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}" diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py index 356b06e64..4c1999518 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py @@ -614,7 +614,8 @@ def set_mock_file(self, method: str, path: str, ) def set_mock_patch(self, method: str, path: str, - patches: dict) -> 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 @@ -622,10 +623,12 @@ def set_mock_patch(self, method: str, path: str, body before delivery to the DUT. Args: - method: HTTP method (GET, POST, etc.) + 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. @@ -641,6 +644,7 @@ def set_mock_patch(self, method: str, path: str, """ 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, diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py index 923635903..8c395f845 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py @@ -891,7 +891,8 @@ def set_mock_file(self, method: str, path: str, @export def set_mock_patch(self, method: str, path: str, - patches_json: str) -> 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 @@ -899,10 +900,12 @@ def set_mock_patch(self, method: str, path: str, body before delivery to the DUT. Args: - method: HTTP method (GET, POST, etc.) + 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. @@ -915,7 +918,11 @@ def set_mock_patch(self, method: str, path: str, return "Patches must be a JSON object" key = f"{method.upper()} {path}" - self._mock_endpoints[key] = {"patch": patches} + 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}" From adca552b725ce53417a1adc7de3c88cc45a89ff4 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Thu, 5 Mar 2026 16:44:04 -0500 Subject: [PATCH 20/24] Fix default mitmproxy spool directory --- .../jumpstarter_driver_mitmproxy/driver.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py index 8c395f845..f08de9719 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py @@ -278,9 +278,11 @@ 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 = "/opt/jumpstarter/mitmproxy" + data: str = "" conf: str = "" flows: str = "" addons: str = "" @@ -289,6 +291,12 @@ class DirectoriesConfig(BaseModel): @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: @@ -324,7 +332,7 @@ class MitmproxyDriver(Driver): web: port: 8081 directories: - data: /opt/jumpstarter/mitmproxy + 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 From 2196fd03fa23aa1d5df458baa0ee7e64c29635a8 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Sun, 8 Mar 2026 14:23:10 -0400 Subject: [PATCH 21/24] Strip conditional request headers when state flag is set When strip_conditional_headers is enabled via state.json, remove If-Modified-Since/If-None-Match/If-Range headers from requests before forwarding upstream. This ensures the server returns 200 with full response bodies instead of 304 Not Modified, which is needed during golden session recording to capture complete response data. Co-Authored-By: Claude Opus 4.6 --- .../jumpstarter_driver_mitmproxy/bundled_addon.py | 6 ++++++ 1 file changed, 6 insertions(+) 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 index bed62acc5..187668243 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py @@ -703,6 +703,12 @@ 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] From d2738ec1c98c4e103c5b6863aea1ec362a998da9 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Tue, 17 Mar 2026 15:05:50 -0400 Subject: [PATCH 22/24] Make _deep_merge_patch resilient to per-key errors Previously, a KeyError or IndexError on any key (e.g. subscription[0] when the real response has an empty array) would abort the entire merge loop, preventing subsequent keys from being applied. Now errors are caught per-key, logged, and skipped so remaining patch keys still apply. Co-Authored-By: Claude Opus 4.6 --- .../bundled_addon.py | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) 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 index 187668243..c1adba869 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py @@ -77,20 +77,28 @@ def _deep_merge_patch(target, patch): - Keys with ``[N]`` suffix target array elements: ``"modules[0]"`` navigates to ``target["modules"][0]``. - 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(): - m = _ARRAY_KEY_RE.match(key) - if m: - base_key, index = m.group(1), int(m.group(2)) - array = target[base_key] - if isinstance(value, dict): - _deep_merge_patch(array[index], value) + try: + m = _ARRAY_KEY_RE.match(key) + if m: + base_key, index = m.group(1), int(m.group(2)) + array = target[base_key] + 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: - array[index] = value - elif isinstance(value, dict) and isinstance(target.get(key), dict): - _deep_merge_patch(target[key], value) - else: - target[key] = value + 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): From 27cc357ce4ba6f373bf3aa745a4fb89ccb7f0e21 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Tue, 17 Mar 2026 15:18:13 -0400 Subject: [PATCH 23/24] Auto-create missing arrays in _deep_merge_patch When a patch key like `subscription[0]` targets an array that doesn't exist in the response, create the array and pad with empty dicts up to the required index. This fixes subscription scenario patching against INACTIVE headunits where the `subscription` key is absent entirely. Co-Authored-By: Claude Opus 4.6 --- .../jumpstarter_driver_mitmproxy/bundled_addon.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 index c1adba869..8fddec8c6 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py @@ -75,7 +75,8 @@ def _deep_merge_patch(target, patch): - Dict patch values recurse into the matching target key. - Keys with ``[N]`` suffix target array elements: ``"modules[0]"`` - navigates to ``target["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. @@ -85,7 +86,13 @@ def _deep_merge_patch(target, patch): 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: From 90f0ee2b83506e5e88e2005cd83d217fa6fdde72 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Thu, 19 Mar 2026 11:17:14 -0400 Subject: [PATCH 24/24] Fix docs build and uv.lock --- .../reference/package-apis/drivers/index.md | 2 + .../jumpstarter_driver_mitmproxy/client.py | 29 +++-- python/uv.lock | 123 ++++++++++++++---- 3 files changed, 118 insertions(+), 36 deletions(-) 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/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py index 4c1999518..8714fe9a2 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py @@ -1454,6 +1454,20 @@ def _human_size(nbytes: int) -> str: 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. @@ -1464,25 +1478,14 @@ def _collect_file_entries(endpoints: dict) -> list[dict]: entries: list[dict] = [] for ep in endpoints.values(): if isinstance(ep, list): - # URL-keyed list format: each item is an endpoint dict items = ep elif isinstance(ep, dict): items = [ep] else: continue for item in items: - if not isinstance(item, dict): - continue - entries.append(item) - # Check nested response dict - 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) + if isinstance(item, dict): + entries.extend(_collect_entries_from_item(item)) return entries diff --git a/python/uv.lock b/python/uv.lock index b491ab6b4..53b9554ac 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -55,6 +55,7 @@ members = [ "jumpstarter-driver-yepkit", "jumpstarter-example-automotive", "jumpstarter-example-soc-pytest", + "jumpstarter-example-xcp-ecu", "jumpstarter-imagehash", "jumpstarter-kubernetes", "jumpstarter-protocol", @@ -65,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" }, ] @@ -1276,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" }, @@ -1876,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" }, @@ -1918,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" }, @@ -3314,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" } @@ -3504,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" @@ -5384,27 +5417,27 @@ wheels = [ [[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]] @@ -5809,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"