From 6398a5223a89b3c7598f0d14d4338b4767f70f42 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Mon, 2 Feb 2026 15:12:28 -0700 Subject: [PATCH] ESP32 Bluetooth test harness MCP server UART-controlled ESP32 peripheral for automated E2E Bluetooth testing. Dual-mode (Classic BT + BLE) via Bluedroid on original ESP32. Firmware (ESP-IDF v5.x, 2511 lines C): - NDJSON protocol over UART1 (115200 baud) - System commands: ping, reset, get_info, get_status - Classic BT: GAP, SPP, all 4 SSP pairing modes - BLE: GATTS, advertising, GATT service/characteristic management - 6 device personas: headset, speaker, keyboard, sensor, phone, bare - Event reporter: thread-safe async event queue to host Python MCP server (FastMCP, 1626 lines): - Async serial client with command/response correlation - Event queue with wait_for pattern matching - Tools: connection, configure, classic, ble, persona, events - MCP resources: esp32://status, esp32://events, esp32://personas Tests: 74 unit tests passing, 5 integration test stubs (skip without hardware) --- .gitignore | 29 + .mcp.json | 17 + Makefile | 62 + docs/hardware-setup.md | 249 +++ docs/protocol-spec.md | 250 +++ docs/test-scenarios.md | 263 +++ firmware/CMakeLists.txt | 5 + firmware/main/CMakeLists.txt | 17 + firmware/main/bt_ble.c | 903 +++++++++++ firmware/main/bt_ble.h | 17 + firmware/main/bt_classic.c | 628 ++++++++ firmware/main/bt_classic.h | 19 + firmware/main/cmd_dispatcher.c | 140 ++ firmware/main/cmd_dispatcher.h | 6 + firmware/main/event_reporter.c | 172 ++ firmware/main/event_reporter.h | 35 + firmware/main/main.c | 100 ++ firmware/main/personas.c | 270 ++++ firmware/main/personas.h | 9 + firmware/main/protocol.h | 67 + firmware/main/uart_handler.c | 136 ++ firmware/main/uart_handler.h | 9 + firmware/sdkconfig.defaults | 35 + pyproject.toml | 51 + src/mcbluetooth_esp32/__init__.py | 3 + src/mcbluetooth_esp32/event_queue.py | 167 ++ src/mcbluetooth_esp32/protocol.py | 221 +++ src/mcbluetooth_esp32/resources.py | 82 + src/mcbluetooth_esp32/serial_client.py | 297 ++++ src/mcbluetooth_esp32/server.py | 74 + src/mcbluetooth_esp32/tools/__init__.py | 0 src/mcbluetooth_esp32/tools/ble.py | 257 +++ src/mcbluetooth_esp32/tools/classic.py | 124 ++ src/mcbluetooth_esp32/tools/configure.py | 111 ++ src/mcbluetooth_esp32/tools/connection.py | 178 +++ src/mcbluetooth_esp32/tools/events.py | 67 + src/mcbluetooth_esp32/tools/persona.py | 42 + tests/__init__.py | 0 tests/integration/__init__.py | 0 tests/integration/conftest.py | 40 + tests/integration/test_ble_gatt.py | 20 + tests/integration/test_ssp_just_works.py | 29 + tests/integration/test_ssp_numeric.py | 16 + tests/integration/test_ssp_passkey.py | 16 + tests/test_event_queue.py | 285 ++++ tests/test_protocol.py | 285 ++++ tests/test_serial_client.py | 308 ++++ uv.lock | 1754 +++++++++++++++++++++ 48 files changed, 7865 insertions(+) create mode 100644 .gitignore create mode 100644 .mcp.json create mode 100644 Makefile create mode 100644 docs/hardware-setup.md create mode 100644 docs/protocol-spec.md create mode 100644 docs/test-scenarios.md create mode 100644 firmware/CMakeLists.txt create mode 100644 firmware/main/CMakeLists.txt create mode 100644 firmware/main/bt_ble.c create mode 100644 firmware/main/bt_ble.h create mode 100644 firmware/main/bt_classic.c create mode 100644 firmware/main/bt_classic.h create mode 100644 firmware/main/cmd_dispatcher.c create mode 100644 firmware/main/cmd_dispatcher.h create mode 100644 firmware/main/event_reporter.c create mode 100644 firmware/main/event_reporter.h create mode 100644 firmware/main/main.c create mode 100644 firmware/main/personas.c create mode 100644 firmware/main/personas.h create mode 100644 firmware/main/protocol.h create mode 100644 firmware/main/uart_handler.c create mode 100644 firmware/main/uart_handler.h create mode 100644 firmware/sdkconfig.defaults create mode 100644 pyproject.toml create mode 100644 src/mcbluetooth_esp32/__init__.py create mode 100644 src/mcbluetooth_esp32/event_queue.py create mode 100644 src/mcbluetooth_esp32/protocol.py create mode 100644 src/mcbluetooth_esp32/resources.py create mode 100644 src/mcbluetooth_esp32/serial_client.py create mode 100644 src/mcbluetooth_esp32/server.py create mode 100644 src/mcbluetooth_esp32/tools/__init__.py create mode 100644 src/mcbluetooth_esp32/tools/ble.py create mode 100644 src/mcbluetooth_esp32/tools/classic.py create mode 100644 src/mcbluetooth_esp32/tools/configure.py create mode 100644 src/mcbluetooth_esp32/tools/connection.py create mode 100644 src/mcbluetooth_esp32/tools/events.py create mode 100644 src/mcbluetooth_esp32/tools/persona.py create mode 100644 tests/__init__.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/test_ble_gatt.py create mode 100644 tests/integration/test_ssp_just_works.py create mode 100644 tests/integration/test_ssp_numeric.py create mode 100644 tests/integration/test_ssp_passkey.py create mode 100644 tests/test_event_queue.py create mode 100644 tests/test_protocol.py create mode 100644 tests/test_serial_client.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d93f34d --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +dist/ +build/ +*.egg +.venv/ + +# ESP-IDF build artifacts +firmware/build/ +firmware/sdkconfig +firmware/sdkconfig.old + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store + +# Environment +.env + +# Test artifacts +.pytest_cache/ +htmlcov/ +.coverage diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..4cb1e43 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,17 @@ +{ + "mcpServers": { + "mcbluetooth-esp32-local": { + "type": "stdio", + "command": "uv", + "args": [ + "run", + "--directory", + "/home/rpm/claude/mcbluetooth-esp32", + "mcbluetooth-esp32" + ], + "env": { + "ESP32_SERIAL_PORT": "/dev/ttyUSB4" + } + } + } +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0bc3cda --- /dev/null +++ b/Makefile @@ -0,0 +1,62 @@ +.PHONY: help flash monitor menuconfig build clean test lint format serve + +IDF_PATH ?= $(HOME)/esp/esp-idf +SERIAL_PORT ?= /dev/ttyUSB0 +SERIAL_BAUD ?= 115200 +FIRMWARE_DIR := firmware + +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ + awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' + +# --- Firmware --- + +build: ## Build ESP32 firmware + cd $(FIRMWARE_DIR) && idf.py build + +flash: ## Flash firmware to ESP32 + cd $(FIRMWARE_DIR) && idf.py -p $(SERIAL_PORT) flash + +monitor: ## Open serial monitor (ESP-IDF) + cd $(FIRMWARE_DIR) && idf.py -p $(SERIAL_PORT) monitor + +flash-monitor: ## Flash and open monitor + cd $(FIRMWARE_DIR) && idf.py -p $(SERIAL_PORT) flash monitor + +menuconfig: ## Open ESP-IDF menuconfig + cd $(FIRMWARE_DIR) && idf.py menuconfig + +clean-firmware: ## Clean firmware build + cd $(FIRMWARE_DIR) && idf.py fullclean + +# --- Python MCP Server --- + +serve: ## Run MCP server (for development) + uv run mcbluetooth-esp32 + +test: ## Run Python tests + uv run pytest tests/ -v + +test-unit: ## Run unit tests only (no hardware) + uv run pytest tests/ -v --ignore=tests/integration + +test-integration: ## Run integration tests (requires ESP32) + uv run pytest tests/integration/ -v + +lint: ## Lint Python code + uv run ruff check src/ tests/ + +format: ## Format Python code + uv run ruff format src/ tests/ + +# --- Utilities --- + +raw-monitor: ## Raw serial monitor with screen + screen $(SERIAL_PORT) $(SERIAL_BAUD) + +ping: ## Quick ping test via Python + uv run python -c "import asyncio; from mcbluetooth_esp32.serial_client import SerialClient; \ + async def t(): \ + c = SerialClient('$(SERIAL_PORT)'); await c.connect(); \ + r = await c.send_command('ping'); print(r); await c.disconnect(); \ + asyncio.run(t())" diff --git a/docs/hardware-setup.md b/docs/hardware-setup.md new file mode 100644 index 0000000..443c05b --- /dev/null +++ b/docs/hardware-setup.md @@ -0,0 +1,249 @@ +# Hardware Setup Guide + +How to set up the ESP32 hardware and build environment for the mcbluetooth-esp32 test harness. + +## Requirements + +- **ESP32 dev board** -- must be an original ESP32 (ESP32-D0WD or similar) with Classic Bluetooth support. ESP32-S3, ESP32-C3, ESP32-H2, and ESP32-S2 lack the BR/EDR radio and will not work for Classic BT pairing tests. +- **USB cable** (USB-A to micro-USB or USB-C depending on your board) +- **Linux host** with BlueZ installed (for the `mcbluetooth` MCP server on the other side of E2E tests) +- **ESP-IDF v5.x** toolchain + +## Verified Hardware + +| Property | Value | +|----------|-------| +| Chip | ESP32-D0WD-V3 (rev 3.1) | +| Flash | 4MB | +| Crystal | 40MHz | +| Features | Wi-Fi, Bluetooth (dual-mode), Dual Core, 240MHz | + +Any ESP32 board based on the original ESP32 chip should work. Commonly available boards include ESP32-DevKitC, ESP32-WROOM-32, and NodeMCU-32S. + +## Wiring + +### Default: USB only + +For most development, a single USB cable handles both flashing and protocol communication. The ESP32's built-in USB-to-UART bridge (typically CP2102 or CH340) provides the serial link. + +The firmware uses **UART1** (GPIO4/GPIO5) for the NDJSON protocol and keeps **UART0** for ESP-IDF console logging. When using USB only, the USB bridge connects to UART0 by default -- so you will need either: + +1. A board that routes UART1 through the USB bridge (uncommon), or +2. A separate USB-UART adapter connected to GPIO4/GPIO5 (described below) + +For quick testing with the firmware's default pin assignment, connect a USB-UART adapter. + +### Dedicated UART (GPIO4/GPIO5) + +Connect a USB-UART adapter (e.g., FTDI FT232R, CP2102, CH340) to the ESP32: + +``` +ESP32 GPIO4 (TX) ----> USB-UART adapter RX +ESP32 GPIO5 (RX) <---- USB-UART adapter TX +ESP32 GND ----> USB-UART adapter GND +``` + +The adapter appears as a second `/dev/ttyUSB*` device on the host. Use this device path for `ESP32_SERIAL_PORT`. + +Do not connect voltage lines (VCC/3V3) between the adapter and the ESP32 if the board is already powered via its own USB port. + +### Pin reassignment + +If GPIO4/GPIO5 conflict with other peripherals on your board, change the pin definitions in `firmware/main/uart_handler.c`: + +```c +#define UART_TX_PIN GPIO_NUM_4 +#define UART_RX_PIN GPIO_NUM_5 +``` + +Rebuild and reflash after changing pins. + +## ESP-IDF Setup + +### 1. Install ESP-IDF v5.x + +Follow the official installation guide: https://docs.espressif.com/projects/esp-idf/en/stable/esp32/get-started/ + +On Arch Linux: + +```bash +# Install dependencies +sudo pacman -S cmake ninja python + +# Clone ESP-IDF +mkdir -p ~/esp && cd ~/esp +git clone --recursive https://github.com/espressif/esp-idf.git +cd esp-idf +./install.sh esp32 + +# Activate the environment (add to .bashrc or run each session) +. ~/esp/esp-idf/export.sh +``` + +### 2. Set the target + +```bash +cd firmware +idf.py set-target esp32 +``` + +This only needs to be done once per checkout. It configures the build system for the ESP32 chip. + +### 3. Build + +```bash +idf.py build +``` + +Or using the project Makefile from the repository root: + +```bash +make build +``` + +### 4. Flash + +```bash +idf.py -p /dev/ttyUSB4 flash +``` + +Or: + +```bash +make flash SERIAL_PORT=/dev/ttyUSB4 +``` + +### 5. Monitor (optional) + +Open the ESP-IDF serial monitor to see console logs from UART0: + +```bash +idf.py -p /dev/ttyUSB4 monitor +``` + +Press `Ctrl+]` to exit the monitor. + +### 6. Flash and monitor in one step + +```bash +make flash-monitor SERIAL_PORT=/dev/ttyUSB4 +``` + +## Quick Verification + +### Using the MCP server + +```bash +# Set the serial port and start the server +ESP32_SERIAL_PORT=/dev/ttyUSB4 uvx mcbluetooth-esp32 +``` + +Then from a Claude Code session with the MCP server configured, call: + +1. `esp32_connect` -- opens the serial link +2. `esp32_ping` -- should return `{"pong": true}` +3. `esp32_get_info` -- should return chip model, firmware version, MAC address + +### Using the Makefile ping target + +```bash +make ping SERIAL_PORT=/dev/ttyUSB4 +``` + +This runs a standalone Python script that connects, sends a `ping` command, prints the response, and disconnects. + +### Raw serial check + +If everything else fails, use `screen` or `minicom` to send raw JSON: + +```bash +screen /dev/ttyUSB4 115200 +``` + +Type (all on one line, then press Enter): + +```json +{"type":"cmd","id":"1","cmd":"ping"} +``` + +You should see: + +```json +{"type":"resp","id":"1","status":"ok","data":{"pong":true}} +``` + +Press `Ctrl+A` then `K` then `Y` to exit screen. + +## sdkconfig + +The project ships `firmware/sdkconfig.defaults` with the required Bluetooth configuration pre-set: + +| Setting | Value | Purpose | +|---------|-------|---------| +| `CONFIG_BT_ENABLED` | y | Enable Bluetooth controller | +| `CONFIG_BT_BLUEDROID_ENABLED` | y | Use Bluedroid host stack | +| `CONFIG_BT_CLASSIC_ENABLED` | y | Enable BR/EDR (Classic BT) | +| `CONFIG_BT_BLE_ENABLED` | y | Enable BLE | +| `CONFIG_BT_SSP_ENABLED` | y | Enable Secure Simple Pairing | +| `CONFIG_BT_SPP_ENABLED` | y | Enable Serial Port Profile | +| `CONFIG_BT_GATTS_ENABLE` | y | Enable GATT Server | +| `CONFIG_BTDM_CTRL_MODE_BTDM` | y | Dual-mode controller (Classic + BLE simultaneously) | +| `CONFIG_NVS_ENABLED` | y | Non-volatile storage for bonding data | + +Do not modify these unless you understand the implications. Disabling `CONFIG_BT_CLASSIC_ENABLED` breaks all Classic BT pairing tests. Disabling `CONFIG_BT_SSP_ENABLED` forces legacy PIN-only pairing. + +## Troubleshooting + +### Permission denied on `/dev/ttyUSB*` + +Add your user to the `dialout` group (or `uucp` on Arch Linux): + +```bash +# Debian/Ubuntu +sudo usermod -aG dialout $USER + +# Arch Linux +sudo usermod -aG uucp $USER +``` + +Log out and back in for the group change to take effect. + +### Device not found + +Check what serial devices are present: + +```bash +ls -la /dev/ttyUSB* /dev/ttyACM* +``` + +If nothing appears, verify the USB cable is a data cable (not charge-only) and that the board's USB-UART chip driver is loaded: + +```bash +dmesg | tail -20 +``` + +Look for lines mentioning `cp210x`, `ch341`, or `ftdi_sio`. + +### Flash fails or hangs + +Some ESP32 boards require holding the **BOOT** button during the initial flash sequence. Hold BOOT, press and release EN (reset), then release BOOT. The flash should proceed. + +If the board has auto-download circuitry (most DevKitC boards do), this should not be necessary. + +### No response over UART + +1. **Verify TX/RX pin assignment.** The firmware uses UART1 on GPIO4 (TX) and GPIO5 (RX). If your adapter is connected to different pins, update `uart_handler.c`. + +2. **Check baud rate.** Both sides must use 115200. Verify in `screen` or your terminal emulator. + +3. **Check the correct serial device.** If the board has two USB-UART interfaces (one for UART0 console, one for UART1 protocol), make sure you are talking to the right one. + +4. **Look at UART0 console output.** Connect the ESP-IDF monitor to the console port. Boot messages and error logs appear there. If you see `UART1 ready (TX=4 RX=5 @ 115200 baud)` in the log, the firmware started correctly. + +### Build errors about missing Bluetooth headers + +Make sure `idf.py set-target esp32` was run. The ESP32-S3 and ESP32-C3 targets do not expose Classic BT APIs, which causes build failures. + +### NVS errors on first boot + +If the console shows `NVS partition issue, erasing and re-initializing`, this is expected on first flash or after a partition table change. The firmware handles it automatically by erasing and reinitializing NVS. diff --git a/docs/protocol-spec.md b/docs/protocol-spec.md new file mode 100644 index 0000000..08dcf1c --- /dev/null +++ b/docs/protocol-spec.md @@ -0,0 +1,250 @@ +# UART Protocol Specification + +Authoritative reference for the JSON-over-UART protocol used between the Python MCP server (`mcbluetooth-esp32`) and the ESP32 firmware. + +## Overview + +- **Format:** NDJSON (newline-delimited JSON) -- one JSON object per line, terminated by `\n` +- **Baud rate:** 115200 +- **Frame format:** 8N1 (8 data bits, no parity, 1 stop bit) +- **Max line length:** 2048 bytes (lines exceeding this are silently dropped) +- **UART peripheral:** ESP32 UART1 on GPIO4 (TX) and GPIO5 (RX), keeping UART0 free for ESP-IDF console logging +- **Encoding:** UTF-8 +- **Newlines:** `\n` only. Carriage returns (`\r`) are stripped by the firmware reader. + +There are three message types flowing over the link: + +| Direction | Type | Purpose | +|-----------|------|---------| +| Host --> ESP32 | `cmd` | Request an action or query | +| ESP32 --> Host | `resp` | Direct reply to a command (correlated by `id`) | +| ESP32 --> Host | `event` | Asynchronous notification (no correlation ID) | + +## Message Formats + +### Command (host --> ESP32) + +```json +{"type":"cmd","id":"1","cmd":"ping","params":{}} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | string | yes | Always `"cmd"` | +| `id` | string | yes | Monotonically increasing request ID. The ESP32 echoes this in the response. | +| `cmd` | string | yes | Command name (see Command Reference below) | +| `params` | object | no | Command-specific parameters. Omitted or `{}` if the command takes none. | + +### Response (ESP32 --> host) + +```json +{"type":"resp","id":"1","status":"ok","data":{"pong":true}} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | string | yes | Always `"resp"` | +| `id` | string | yes | Echoed from the command that triggered this response | +| `status` | string | yes | `"ok"` on success, `"error"` on failure | +| `data` | object | yes | Command-specific payload. On error, contains `{"error":"description"}`. | + +### Event (ESP32 --> host, unsolicited) + +```json +{"type":"event","event":"pair_request","data":{"address":"AA:BB:CC:DD:EE:FF","type":"numeric_comparison","passkey":123456},"ts":12345} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | string | yes | Always `"event"` | +| `event` | string | yes | Event name (see Event Reference below) | +| `data` | object | yes | Event-specific payload | +| `ts` | number | yes | Millisecond timestamp from `esp_timer_get_time() / 1000` (time since boot) | + +--- + +## Command Reference + +### System Commands + +| Command | Parameters | Response Data | Description | +|---------|-----------|---------------|-------------| +| `ping` | none | `{"pong":true}` | Heartbeat check. Verifies the serial link is alive. | +| `reset` | none | `{}` | Reboots the ESP32. The response may not arrive before the link drops. | +| `get_info` | none | `{"chip_model":"ESP32","features":["wifi","bt","ble"],"revision":3,"cores":2,"fw_version":"0.1.0","free_heap":240000,"bt_mac":"AA:BB:CC:DD:EE:FF"}` | Hardware and firmware details. | +| `get_status` | none | `{"uptime_ms":12345,"free_heap":230000,"bt_enabled":false,"ble_enabled":false}` | Current device state. | + +### Configuration Commands + +| Command | Parameters | Response Data | Description | +|---------|-----------|---------------|-------------| +| `configure` | `name` (string, optional), `io_cap` (string, optional), `device_class` (int, optional), `pin_code` (string, optional) | Applied config values | Set one or more Bluetooth properties atomically. Any parameter not sent retains its current value. | +| `load_persona` | `persona` (string) | `{"persona":"headset","device_name":"BT Headset","io_cap":"no_io","classic":true,"ble":true,"device_class":"0x200404","services":[...]}` | Load a preset device profile. Sets name, IO capability, Class of Device, and advertised services in one operation. | +| `list_personas` | none | `{"personas":[...]}` | List all available persona presets and their configurations. | +| `classic_set_ssp_mode` | `mode` (string) | Applied mode | Set the SSP association model. See SSP Modes below. | + +**Valid `mode` values for `classic_set_ssp_mode`:** + +| Mode | Description | +|------|-------------| +| `just_works` | No user interaction. Lowest security. Both sides auto-accept. | +| `numeric_comparison` | Both sides display a 6-digit passkey. User confirms they match. | +| `passkey_entry` | Remote device must type a passkey displayed on the ESP32. | +| `passkey_display` | ESP32 displays a passkey that the remote device enters. | + +### Classic Bluetooth Commands + +| Command | Parameters | Response Data | Description | +|---------|-----------|---------------|-------------| +| `classic_enable` | none | BT state | Enable the BR/EDR (Classic Bluetooth) stack. | +| `classic_disable` | none | BT state | Disable Classic Bluetooth. Device becomes invisible to inquiry scans. | +| `classic_set_discoverable` | `discoverable` (bool), `timeout` (int, seconds, 0=forever) | Discoverable state | Control whether the ESP32 responds to Bluetooth inquiry scans. | +| `classic_pair_respond` | `address` (string), `accept` (bool), `passkey` (int, optional), `pin` (string, optional) | Pairing result | Accept or reject an incoming pairing request from the given address. | + +### BLE Commands + +| Command | Parameters | Response Data | Description | +|---------|-----------|---------------|-------------| +| `ble_enable` | none | BLE state | Initialize the BLE stack. Required before advertising or GATT operations. | +| `ble_disable` | none | BLE state | Shut down BLE. Stops advertising and tears down GATT services. | +| `ble_advertise` | `enable` (bool), `interval_ms` (int, default 100) | Advertising state | Start or stop BLE advertisement broadcasts. | +| `ble_set_adv_data` | `name` (string, optional), `service_uuids` (string[], optional), `manufacturer_data` (hex string, optional) | Applied adv data | Configure the contents of BLE advertisement packets. Call before starting advertising. | + +### GATT Commands + +| Command | Parameters | Response Data | Description | +|---------|-----------|---------------|-------------| +| `gatt_add_service` | `uuid` (string), `primary` (bool, default true) | `{"handle":N}` | Create a GATT service. Returns a handle used when adding characteristics. | +| `gatt_add_characteristic` | `service_handle` (int), `uuid` (string), `properties` (string[]), `value` (hex string, optional) | `{"handle":N}` | Add a characteristic to a service. Properties: `"read"`, `"write"`, `"notify"`, `"indicate"`. | +| `gatt_set_value` | `char_handle` (int), `value` (hex string) | `{}` | Update a characteristic's stored value. | +| `gatt_notify` | `char_handle` (int) | `{}` | Push the current value to all subscribed BLE clients as a notification. | +| `gatt_clear` | none | `{}` | Remove all services and characteristics, resetting the GATT server to blank. | + +**Hex string encoding:** Values are represented as lowercase hex strings without a prefix. For example, a temperature of 25.00 C encoded as a little-endian `int16` in hundredths of a degree would be `"c409"` (2500 = 0x09C4, little-endian = `c4 09`). + +--- + +## Event Reference + +| Event | Data Fields | Trigger | +|-------|-------------|---------| +| `boot` | `fw_version` (string), `chip_model` (string), `cores` (int), `revision` (int), `free_heap` (int) | Emitted once immediately after firmware startup. | +| `pair_request` | `address` (string), `type` (string: `"just_works"`, `"numeric_comparison"`, `"passkey_entry"`, `"legacy_pin"`), `passkey` (int) | A remote device has initiated pairing. Requires a `classic_pair_respond` to proceed. | +| `pair_complete` | `address` (string), `success` (bool) | Pairing attempt finished (accepted, rejected, or timed out). | +| `connect` | `address` (string), `transport` (string: `"classic"` or `"ble"`) | A remote device has established a connection. | +| `disconnect` | `address` (string), `transport` (string: `"classic"` or `"ble"`) | A remote device has disconnected. | +| `gatt_read` | `handle` (int), `address` (string) | A BLE client read a characteristic value. | +| `gatt_write` | `handle` (int), `address` (string), `value` (hex string), `length` (int) | A BLE client wrote to a characteristic. | +| `gatt_subscribe` | `handle` (int), `subscribed` (bool) | A BLE client enabled or disabled notifications on a characteristic. | + +--- + +## IO Capabilities + +The `io_cap` parameter controls which Secure Simple Pairing (SSP) association model the Bluetooth stack negotiates. The pairing model is determined by the capabilities of **both** devices. + +| `io_cap` string | ESP-IDF Constant | Typical SSP Outcome | +|-----------------|------------------|---------------------| +| `display_only` | `ESP_IO_CAP_OUT` | Passkey Display (ESP32 shows, remote enters) | +| `display_yesno` | `ESP_IO_CAP_IO` | Numeric Comparison (both display 6-digit code, user confirms) | +| `keyboard_only` | `ESP_IO_CAP_IN` | Passkey Entry (remote displays, ESP32 enters) | +| `no_io` | `ESP_IO_CAP_NONE` | Just Works (no user interaction, no MITM protection) | +| `keyboard_display` | `ESP_IO_CAP_KBDISP` | Numeric Comparison or Passkey Entry depending on remote | + +**SSP negotiation matrix** (simplified, both sides must agree): + +| Local \ Remote | `no_io` | `display_only` | `display_yesno` | `keyboard_only` | `keyboard_display` | +|----------------|---------|----------------|-----------------|-----------------|-------------------| +| `no_io` | Just Works | Just Works | Just Works | Just Works | Just Works | +| `display_only` | Just Works | Just Works | Just Works | Passkey Entry | Passkey Entry | +| `display_yesno` | Just Works | Just Works | Numeric Comp | Passkey Entry | Numeric Comp | +| `keyboard_only` | Just Works | Passkey Entry | Passkey Entry | Passkey Entry | Passkey Entry | +| `keyboard_display` | Just Works | Passkey Entry | Numeric Comp | Passkey Entry | Numeric Comp | + +--- + +## Error Handling + +### Unknown commands + +An unrecognized command returns an error response with the original command echoed back: + +```json +{"type":"resp","id":"5","status":"error","data":{"error":"unknown_command","cmd":"foobar"}} +``` + +### Invalid JSON + +If the host sends a line that does not parse as valid JSON, the firmware responds with `id` set to `"?"`: + +```json +{"type":"resp","id":"?","status":"error","data":"invalid JSON"} +``` + +### Missing required fields + +If a command is missing required parameters, the firmware returns: + +```json +{"type":"resp","id":"3","status":"error","data":{"error":"missing 'persona' param"}} +``` + +### Timeouts + +The host should use a **5-second default timeout** when waiting for a response. The Python `SerialClient` implements this via `asyncio.wait_for` and raises `CommandTimeout` on expiry. + +Exceptions: +- The `reset` command may not produce a response at all (the device reboots before the reply flushes). Treat the link drop as success. +- `classic_pair_respond` may take longer if the remote device is slow. Consider a 10-second timeout for pairing operations. + +### Line overflow + +Lines exceeding 2048 bytes are discarded by both sides without any error response. The firmware resets its read buffer; the Python client logs a warning and drops the line. + +--- + +## Wire Examples + +**Full ping/pong exchange:** + +``` +Host TX: {"type":"cmd","id":"1","cmd":"ping"}\n +ESP32 TX: {"type":"resp","id":"1","status":"ok","data":{"pong":true}}\n +``` + +**Configure with IO capability:** + +``` +Host TX: {"type":"cmd","id":"2","cmd":"configure","params":{"name":"MyDevice","io_cap":"display_yesno"}}\n +ESP32 TX: {"type":"resp","id":"2","status":"ok","data":{"name":"MyDevice","io_cap":"display_yesno"}}\n +``` + +**Boot event (ESP32 sends immediately after power-on):** + +``` +ESP32 TX: {"type":"event","event":"boot","data":{"fw_version":"0.1.0","chip_model":"ESP32","cores":2,"revision":3,"free_heap":283648},"ts":42}\n +``` + +**Pair request event followed by host response:** + +``` +ESP32 TX: {"type":"event","event":"pair_request","data":{"address":"AA:BB:CC:DD:EE:FF","type":"numeric_comparison","passkey":482901},"ts":15234}\n +Host TX: {"type":"cmd","id":"7","cmd":"classic_pair_respond","params":{"address":"AA:BB:CC:DD:EE:FF","accept":true,"passkey":482901}}\n +ESP32 TX: {"type":"resp","id":"7","status":"ok","data":{}}\n +ESP32 TX: {"type":"event","event":"pair_complete","data":{"address":"AA:BB:CC:DD:EE:FF","success":true},"ts":15891}\n +``` + +--- + +## Personas + +Personas are bundled device presets stored in the firmware. Loading one configures the device name, IO capability, Class of Device, and returns the list of GATT service UUIDs the persona expects. + +| Persona | Device Name | IO Capability | Class of Device | Classic | BLE | Services | +|---------|------------|---------------|-----------------|---------|-----|----------| +| `headset` | BT Headset | `no_io` | `0x200404` (Audio, Headset) | yes | yes | Battery (0x180F), Device Info (0x180A) | +| `speaker` | BT Speaker | `no_io` | `0x200414` (Audio, Loudspeaker) | yes | yes | Battery (0x180F), Device Info (0x180A) | +| `keyboard` | BT Keyboard | `keyboard_only` | `0x002540` (Peripheral, Keyboard) | yes | yes | HID (0x1812), Battery (0x180F) | +| `sensor` | Environment Sensor | `no_io` | none (BLE only) | no | yes | Env Sensing (0x181A), Battery (0x180F) | +| `phone` | Test Phone | `keyboard_display` | `0x5A020C` (Phone) | yes | yes | Phonebook (0x1130), Device Info (0x180A) | +| `bare` | ESP32-Test | `display_yesno` | `0x1F00` (Uncategorized) | yes | yes | none | diff --git a/docs/test-scenarios.md b/docs/test-scenarios.md new file mode 100644 index 0000000..d5b64ea --- /dev/null +++ b/docs/test-scenarios.md @@ -0,0 +1,263 @@ +# E2E Test Scenarios + +## Overview + +Tests require both MCP servers running simultaneously: +- **mcbluetooth** — controls the Linux BlueZ stack (host side) +- **mcbluetooth-esp32** — controls the ESP32 peripheral (device side) + +An LLM orchestrates both servers, issuing tool calls to each side to execute the test flow. + +## Prerequisites + +```bash +# Terminal 1: Start ESP32 MCP server +ESP32_SERIAL_PORT=/dev/ttyUSB4 uvx mcbluetooth-esp32 + +# Terminal 2: mcbluetooth is already running (or add to Claude Code config) +``` + +--- + +## Test 1: SSP Just Works + +Both devices have `no_io` capability — pairing auto-completes without user interaction. + +### Flow + +```python +# ESP32 side: Configure as headset (no_io) +esp32_load_persona("headset") # io_cap=no_io → Just Works +esp32_classic_enable() +esp32_classic_set_discoverable(True) + +# Linux side: Discover and pair +bt_scan(adapter="hci0", mode="classic", timeout=10) +# → Find "BT Headset" in scan results + +bt_pair(adapter="hci0", address="D8:13:2A:7F:47:C0", pairing_mode="auto") +# Just Works: auto-accepts on both sides + +# Verify +esp32_wait_event("pair_complete", timeout=15) +# → {"address": "XX:XX:XX:XX:XX:XX", "success": true} + +bt_device_info(adapter="hci0", address="D8:13:2A:7F:47:C0") +# → paired: true +``` + +### Expected Result +- Pairing completes without any passkey exchange +- Both sides report success + +--- + +## Test 2: SSP Numeric Comparison + +Both devices have `display_yesno` — both display a 6-digit passkey that must match. + +### Flow + +```python +# ESP32 side: Configure as phone (keyboard_display → numeric comparison) +esp32_load_persona("phone") +esp32_classic_enable() +esp32_classic_set_discoverable(True) + +# Linux side: Scan and initiate pairing +bt_scan(adapter="hci0", mode="classic") +bt_pair(adapter="hci0", address="D8:13:2A:7F:47:C0", pairing_mode="interactive") + +# Both sides receive the passkey +esp32_wait_event("pair_request", timeout=15) +# → {"type": "numeric_comparison", "passkey": 123456, "address": "..."} + +bt_pairing_status() +# → passkey: 123456 (should match ESP32's passkey!) + +# Both sides confirm +esp32_classic_pair_respond(address="XX:XX:XX:XX:XX:XX", accept=True) +bt_pair_confirm(adapter="hci0", address="D8:13:2A:7F:47:C0", accept=True) + +# Verify +esp32_wait_event("pair_complete") +# → {"success": true} +``` + +### Expected Result +- Both sides display the same 6-digit passkey +- After both confirm, pairing succeeds + +--- + +## Test 3: SSP Passkey Entry + +One side displays a passkey, the other must enter it. + +### Flow (ESP32 displays, Linux enters) + +```python +# ESP32: display_only → shows passkey +esp32_configure(io_cap="display_only") +esp32_classic_enable() +esp32_classic_set_discoverable(True) + +# Linux: Initiate pairing +bt_pair(adapter="hci0", address="D8:13:2A:7F:47:C0", pairing_mode="interactive") + +# ESP32 displays passkey +esp32_wait_event("pair_request") +# → {"type": "passkey_display", "passkey": 654321} + +# Linux enters the passkey +bt_pair_confirm(adapter="hci0", address="D8:13:2A:7F:47:C0", + passkey=654321, accept=True) + +# Verify +esp32_wait_event("pair_complete") +# → {"success": true} +``` + +### Flow (Linux displays, ESP32 enters) + +```python +# ESP32: keyboard_only → must enter passkey +esp32_configure(io_cap="keyboard_only") +esp32_classic_enable() +esp32_classic_set_discoverable(True) + +# Linux initiates pairing — Linux displays passkey +bt_pair(adapter="hci0", address="D8:13:2A:7F:47:C0", pairing_mode="interactive") + +bt_pairing_status() +# → passkey: 789012 (displayed on Linux) + +# ESP32 receives passkey entry request +esp32_wait_event("pair_request") +# → {"type": "passkey_entry"} + +# ESP32 enters the passkey shown on Linux +esp32_classic_pair_respond(address="XX:XX:XX:XX:XX:XX", accept=True, passkey=789012) + +# Verify +esp32_wait_event("pair_complete") +``` + +--- + +## Test 4: Legacy PIN + +ESP32 configured with a legacy PIN code. + +```python +esp32_configure(pin_code="1234") +esp32_classic_enable() +esp32_classic_set_discoverable(True) + +bt_pair(adapter="hci0", address="D8:13:2A:7F:47:C0", pairing_mode="interactive") + +# Linux enters PIN +bt_pair_confirm(adapter="hci0", address="D8:13:2A:7F:47:C0", + pin="1234", accept=True) +``` + +--- + +## Test 5: BLE GATT Read/Write + +ESP32 creates an Environmental Sensing service with a Temperature characteristic. + +```python +# ESP32: Set up as sensor +esp32_load_persona("sensor") +esp32_gatt_add_service(uuid="0000181a-0000-1000-8000-00805f9b34fb", primary=True) +# → {"service_handle": 40} + +esp32_gatt_add_characteristic( + service_handle=40, + uuid="00002a6e-0000-1000-8000-00805f9b34fb", # Temperature + properties=["read", "notify"], + value="e803" # 25.0°C (0x03e8 = 1000 in little-endian → 10.00°C? or raw) +) +# → {"char_handle": 42} + +esp32_ble_advertise(enable=True) + +# Linux: Scan, connect, read +bt_ble_scan(adapter="hci0", timeout=5) +bt_connect(adapter="hci0", address="D8:13:2A:7F:47:C0") +bt_ble_read(adapter="hci0", address="D8:13:2A:7F:47:C0", + char_uuid="00002a6e-0000-1000-8000-00805f9b34fb") +# → {"hex": "e803", ...} + +# ESP32: Update value +esp32_gatt_set_value(char_handle=42, value="f003") # 25.5°C +``` + +--- + +## Test 6: BLE GATT Notifications + +```python +# Linux: Subscribe to temperature notifications +bt_ble_notify(adapter="hci0", address="D8:13:2A:7F:47:C0", + char_uuid="00002a6e-0000-1000-8000-00805f9b34fb", + enable=True) + +# ESP32: Verify subscription +esp32_wait_event("gatt_subscribe") +# → {"char_handle": 42, "subscribed": true} + +# ESP32: Update and notify +esp32_gatt_set_value(char_handle=42, value="0004") +esp32_gatt_notify(char_handle=42) + +# Linux should receive the updated value +bt_ble_read(adapter="hci0", address="D8:13:2A:7F:47:C0", + char_uuid="00002a6e-0000-1000-8000-00805f9b34fb") +# → {"hex": "0004"} +``` + +--- + +## Test 7: Persona Switching + +Verify device identity changes are visible from the Linux side. + +```python +# Load different personas and scan each time +for persona in ["headset", "speaker", "keyboard", "sensor", "phone", "bare"]: + esp32_load_persona(persona) + esp32_classic_enable() + esp32_classic_set_discoverable(True) + + bt_scan(adapter="hci0", mode="both", timeout=5) + # Verify device name and class match persona definition + + esp32_classic_disable() +``` + +--- + +## Running Tests + +```bash +# Unit tests only (no hardware needed) +make test-unit + +# Integration tests (requires ESP32 on /dev/ttyUSB4) +ESP32_SERIAL_PORT=/dev/ttyUSB4 make test-integration + +# Full suite +make test +``` + +## Test Matrix + +| Test | SSP Mode | ESP32 IO Cap | Linux IO Cap | Auto? | +|------|----------|--------------|--------------|-------| +| Just Works | NoInputNoOutput | no_io | no_io | Yes | +| Numeric Comparison | NumericComparison | keyboard_display | display_yesno | No (confirm) | +| Passkey Entry (ESP32 displays) | PasskeyEntry | display_only | keyboard_only | No (enter) | +| Passkey Entry (Linux displays) | PasskeyEntry | keyboard_only | display_only | No (enter) | +| Legacy PIN | LegacyPIN | n/a | n/a | No (PIN) | diff --git a/firmware/CMakeLists.txt b/firmware/CMakeLists.txt new file mode 100644 index 0000000..c0a68b8 --- /dev/null +++ b/firmware/CMakeLists.txt @@ -0,0 +1,5 @@ +# ESP-IDF top-level project CMakeLists.txt +cmake_minimum_required(VERSION 3.16) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(mcbluetooth_esp32) diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt new file mode 100644 index 0000000..6736c74 --- /dev/null +++ b/firmware/main/CMakeLists.txt @@ -0,0 +1,17 @@ +idf_component_register( + SRCS + "main.c" + "uart_handler.c" + "cmd_dispatcher.c" + "bt_classic.c" + "bt_ble.c" + "personas.c" + "event_reporter.c" + INCLUDE_DIRS "." + REQUIRES + driver + bt + nvs_flash + json + esp_event +) diff --git a/firmware/main/bt_ble.c b/firmware/main/bt_ble.c new file mode 100644 index 0000000..17f91d0 --- /dev/null +++ b/firmware/main/bt_ble.c @@ -0,0 +1,903 @@ +/* + * bt_ble.c -- BLE peripheral using Bluedroid GATTS (ESP-IDF v5.x). + * + * Provides GATT server with up to 8 services, each with up to 8 + * characteristics. Commands arrive as NDJSON from the host over UART; + * events (connect, read, write, subscribe) are pushed back asynchronously + * via the event_reporter queue. + */ + +#include "bt_ble.h" +#include "protocol.h" +#include "uart_handler.h" +#include "event_reporter.h" + +#include "esp_bt.h" +#include "esp_gap_ble_api.h" +#include "esp_gatts_api.h" +#include "esp_bt_main.h" +#include "esp_gatt_common_api.h" +#include "esp_log.h" + +#include +#include +#include + +#define TAG "ble" + +#define MAX_SERVICES 8 +#define MAX_CHARS_PER_SVC 8 +#define MAX_CHAR_VALUE_LEN 512 +#define GATTS_APP_ID 0 + +/* ------------------------------------------------------------------ */ +/* Data structures */ +/* ------------------------------------------------------------------ */ + +typedef struct { + uint16_t handle; + uint8_t uuid[16]; + uint8_t value[MAX_CHAR_VALUE_LEN]; + uint16_t value_len; + uint8_t properties; + uint16_t cccd_handle; + bool notifications_enabled; +} char_entry_t; + +typedef struct { + uint16_t handle; + uint8_t uuid[16]; + bool primary; + int num_chars; + char_entry_t chars[MAX_CHARS_PER_SVC]; +} service_entry_t; + +static struct { + bool enabled; + bool advertising; + int num_services; + service_entry_t services[MAX_SERVICES]; + esp_gatt_if_t gatts_if; + uint16_t conn_id; + bool connected; +} s_ble; + +/* + * Async response plumbing: GATTS create/add_char events need to reply to the + * command that triggered them. Commands are processed sequentially on the + * main UART task, so a single pending slot is safe. + */ +static char s_pending_cmd_id[64]; + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +static int parse_uuid(const char *uuid_str, uint8_t *uuid_out) +{ + if (!uuid_str) return -1; + + /* Strip hyphens into a 32-char hex string */ + char hex[33]; + int h = 0; + for (int i = 0; uuid_str[i] && h < 32; i++) { + if (uuid_str[i] != '-') { + hex[h++] = uuid_str[i]; + } + } + hex[h] = '\0'; + + if (h != 32) { + ESP_LOGE(TAG, "UUID must be 32 hex digits (got %d)", h); + return -1; + } + + /* Parse 16 bytes, store in little-endian order for ESP BLE stack */ + for (int i = 0; i < 16; i++) { + unsigned int byte; + if (sscanf(hex + (15 - i) * 2, "%2x", &byte) != 1) return -1; + uuid_out[i] = (uint8_t)byte; + } + return 0; +} + +static int hex_to_bytes(const char *hex, uint8_t *out, int max_len) +{ + if (!hex) return 0; + int len = (int)strlen(hex) / 2; + if (len > max_len) len = max_len; + for (int i = 0; i < len; i++) { + unsigned int b; + if (sscanf(hex + i * 2, "%2x", &b) != 1) return i; + out[i] = (uint8_t)b; + } + return len; +} + +static void bytes_to_hex(const uint8_t *data, int len, char *out) +{ + for (int i = 0; i < len; i++) { + sprintf(out + i * 2, "%02x", data[i]); + } + out[len * 2] = '\0'; +} + +static uint8_t properties_from_json(cJSON *arr) +{ + uint8_t props = 0; + if (!cJSON_IsArray(arr)) return props; + + cJSON *item; + cJSON_ArrayForEach(item, arr) { + if (!cJSON_IsString(item)) continue; + const char *s = item->valuestring; + if (strcmp(s, "read") == 0) props |= ESP_GATT_CHAR_PROP_BIT_READ; + else if (strcmp(s, "write") == 0) props |= ESP_GATT_CHAR_PROP_BIT_WRITE; + else if (strcmp(s, "write_nr") == 0) props |= ESP_GATT_CHAR_PROP_BIT_WRITE_NR; + else if (strcmp(s, "notify") == 0) props |= ESP_GATT_CHAR_PROP_BIT_NOTIFY; + else if (strcmp(s, "indicate") == 0) props |= ESP_GATT_CHAR_PROP_BIT_INDICATE; + } + return props; +} + +static service_entry_t *find_service_by_handle(uint16_t handle) +{ + for (int i = 0; i < s_ble.num_services; i++) { + if (s_ble.services[i].handle == handle) + return &s_ble.services[i]; + } + return NULL; +} + +static char_entry_t *find_char_by_handle(uint16_t handle) +{ + for (int i = 0; i < s_ble.num_services; i++) { + service_entry_t *svc = &s_ble.services[i]; + for (int j = 0; j < svc->num_chars; j++) { + if (svc->chars[j].handle == handle) + return &svc->chars[j]; + } + } + return NULL; +} + +static char_entry_t *find_char_by_cccd(uint16_t cccd_handle) +{ + for (int i = 0; i < s_ble.num_services; i++) { + service_entry_t *svc = &s_ble.services[i]; + for (int j = 0; j < svc->num_chars; j++) { + if (svc->chars[j].cccd_handle == cccd_handle) + return &svc->chars[j]; + } + } + return NULL; +} + +static void send_pending_response(const char *status, cJSON *data) +{ + if (s_pending_cmd_id[0] == '\0') return; + uart_send_response(s_pending_cmd_id, status, data); + s_pending_cmd_id[0] = '\0'; +} + +static void set_pending(const char *id) +{ + strncpy(s_pending_cmd_id, id, sizeof(s_pending_cmd_id) - 1); + s_pending_cmd_id[sizeof(s_pending_cmd_id) - 1] = '\0'; +} + +/* BDA to string */ +static void bda_to_str(const uint8_t *bda, char *out) +{ + sprintf(out, "%02X:%02X:%02X:%02X:%02X:%02X", + bda[0], bda[1], bda[2], bda[3], bda[4], bda[5]); +} + +/* ------------------------------------------------------------------ */ +/* Advertising params */ +/* ------------------------------------------------------------------ */ + +static esp_ble_adv_params_t s_adv_params = { + .adv_int_min = 0x20, /* 20 ms */ + .adv_int_max = 0x40, /* 40 ms */ + .adv_type = ADV_TYPE_IND, + .own_addr_type = BLE_ADDR_TYPE_PUBLIC, + .channel_map = ADV_CHNL_ALL, + .adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY, +}; + +/* ------------------------------------------------------------------ */ +/* GAP event handler */ +/* ------------------------------------------------------------------ */ + +static void gap_event_handler(esp_gap_ble_cb_event_t event, + esp_ble_gap_cb_param_t *param) +{ + switch (event) { + case ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT: + esp_ble_gap_start_advertising(&s_adv_params); + break; + + case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: + if (param->adv_start_cmpl.status == ESP_BT_STATUS_SUCCESS) { + ESP_LOGI(TAG, "advertising started"); + s_ble.advertising = true; + } else { + ESP_LOGE(TAG, "advertising start failed: %d", + param->adv_start_cmpl.status); + } + break; + + case ESP_GAP_BLE_ADV_STOP_COMPLETE_EVT: + ESP_LOGI(TAG, "advertising stopped"); + s_ble.advertising = false; + break; + + default: + break; + } +} + +/* ------------------------------------------------------------------ */ +/* GATTS event handler */ +/* ------------------------------------------------------------------ */ + +static void gatts_event_handler(esp_gatts_cb_event_t event, + esp_gatt_if_t gatts_if, + esp_ble_gatts_cb_param_t *param) +{ + switch (event) { + + case ESP_GATTS_REG_EVT: + if (param->reg.status == ESP_GATT_OK) { + s_ble.gatts_if = gatts_if; + ESP_LOGI(TAG, "GATTS app registered, if=%d", gatts_if); + } else { + ESP_LOGE(TAG, "GATTS register failed: %d", param->reg.status); + } + break; + + case ESP_GATTS_CREATE_EVT: { + uint16_t svc_handle = param->create.service_handle; + ESP_LOGI(TAG, "service created, handle=%d", svc_handle); + + /* Store handle in the most recently added service entry */ + if (s_ble.num_services > 0) { + service_entry_t *svc = &s_ble.services[s_ble.num_services - 1]; + svc->handle = svc_handle; + } + + esp_ble_gatts_start_service(svc_handle); + + cJSON *data = cJSON_CreateObject(); + cJSON_AddNumberToObject(data, "service_handle", svc_handle); + send_pending_response(STATUS_OK, data); + break; + } + + case ESP_GATTS_ADD_CHAR_EVT: { + if (param->add_char.status != ESP_GATT_OK) { + ESP_LOGE(TAG, "add char failed: %d", param->add_char.status); + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", "add_char_failed"); + send_pending_response(STATUS_ERROR, err); + break; + } + + uint16_t char_handle = param->add_char.attr_handle; + ESP_LOGI(TAG, "characteristic added, handle=%d", char_handle); + + /* Find the service and update the most recently added char */ + service_entry_t *svc = find_service_by_handle(param->add_char.service_handle); + if (svc && svc->num_chars > 0) { + char_entry_t *ch = &svc->chars[svc->num_chars - 1]; + ch->handle = char_handle; + } + + cJSON *data = cJSON_CreateObject(); + cJSON_AddNumberToObject(data, "char_handle", char_handle); + send_pending_response(STATUS_OK, data); + break; + } + + case ESP_GATTS_ADD_CHAR_DESCR_EVT: { + if (param->add_char_descr.status != ESP_GATT_OK) { + ESP_LOGE(TAG, "add descriptor failed: %d", param->add_char_descr.status); + break; + } + + /* Store CCCD handle on the owning characteristic */ + uint16_t descr_handle = param->add_char_descr.attr_handle; + service_entry_t *svc = find_service_by_handle(param->add_char_descr.service_handle); + if (svc && svc->num_chars > 0) { + svc->chars[svc->num_chars - 1].cccd_handle = descr_handle; + } + ESP_LOGI(TAG, "CCCD descriptor added, handle=%d", descr_handle); + break; + } + + case ESP_GATTS_READ_EVT: { + char addr_str[18]; + bda_to_str(param->read.bda, addr_str); + event_report_gatt_read(param->read.handle, addr_str); + + char_entry_t *ch = find_char_by_handle(param->read.handle); + + esp_gatt_rsp_t rsp; + memset(&rsp, 0, sizeof(rsp)); + rsp.attr_value.handle = param->read.handle; + + if (ch) { + uint16_t send_len = ch->value_len; + if (send_len > ESP_GATT_MAX_ATTR_LEN) + send_len = ESP_GATT_MAX_ATTR_LEN; + rsp.attr_value.len = send_len; + memcpy(rsp.attr_value.value, ch->value, send_len); + } + + esp_ble_gatts_send_response(gatts_if, param->read.conn_id, + param->read.trans_id, + ESP_GATT_OK, &rsp); + break; + } + + case ESP_GATTS_WRITE_EVT: { + char addr_str[18]; + bda_to_str(param->write.bda, addr_str); + + /* CCCD write -- subscription toggle */ + char_entry_t *cccd_ch = find_char_by_cccd(param->write.handle); + if (cccd_ch) { + bool subscribed = false; + if (param->write.len == 2) { + uint16_t val = param->write.value[0] | (param->write.value[1] << 8); + subscribed = (val != 0); + } + cccd_ch->notifications_enabled = subscribed; + event_report_gatt_subscribe(cccd_ch->handle, subscribed); + + if (param->write.need_rsp) { + esp_ble_gatts_send_response(gatts_if, param->write.conn_id, + param->write.trans_id, + ESP_GATT_OK, NULL); + } + break; + } + + /* Normal characteristic write */ + char_entry_t *ch = find_char_by_handle(param->write.handle); + if (ch && param->write.len <= MAX_CHAR_VALUE_LEN) { + memcpy(ch->value, param->write.value, param->write.len); + ch->value_len = param->write.len; + } + + event_report_gatt_write(param->write.handle, addr_str, + param->write.value, param->write.len); + + if (param->write.need_rsp) { + esp_ble_gatts_send_response(gatts_if, param->write.conn_id, + param->write.trans_id, + ESP_GATT_OK, NULL); + } + break; + } + + case ESP_GATTS_CONNECT_EVT: { + s_ble.conn_id = param->connect.conn_id; + s_ble.connected = true; + char addr_str[18]; + bda_to_str(param->connect.remote_bda, addr_str); + ESP_LOGI(TAG, "connected: %s conn_id=%d", addr_str, param->connect.conn_id); + event_report_connect(addr_str, "ble"); + break; + } + + case ESP_GATTS_DISCONNECT_EVT: { + s_ble.connected = false; + char addr_str[18]; + bda_to_str(param->disconnect.remote_bda, addr_str); + ESP_LOGI(TAG, "disconnected: %s reason=0x%x", addr_str, param->disconnect.reason); + event_report_disconnect(addr_str, "ble"); + + /* Restart advertising if we were advertising before the connection */ + if (s_ble.advertising) { + esp_ble_gap_start_advertising(&s_adv_params); + } + break; + } + + case ESP_GATTS_START_EVT: + ESP_LOGI(TAG, "service started, handle=%d status=%d", + param->start.service_handle, param->start.status); + break; + + case ESP_GATTS_STOP_EVT: + ESP_LOGI(TAG, "service stopped, handle=%d", param->stop.service_handle); + break; + + case ESP_GATTS_DELETE_EVT: + ESP_LOGI(TAG, "service deleted, handle=%d", param->del.service_handle); + break; + + default: + break; + } +} + +/* ------------------------------------------------------------------ */ +/* Command handlers */ +/* ------------------------------------------------------------------ */ + +void cmd_ble_enable(const char *id, cJSON *params) +{ + (void)params; + + if (s_ble.enabled) { + uart_send_response(id, STATUS_OK, NULL); + return; + } + + /* Release classic BT memory if we only need BLE */ + esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT(); + esp_err_t ret = esp_bt_controller_init(&bt_cfg); + if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) { + ESP_LOGE(TAG, "controller init failed: %s", esp_err_to_name(ret)); + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", esp_err_to_name(ret)); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + ret = esp_bt_controller_enable(ESP_BT_MODE_BLE); + if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) { + ESP_LOGE(TAG, "controller enable failed: %s", esp_err_to_name(ret)); + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", esp_err_to_name(ret)); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + ret = esp_bluedroid_init(); + if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) { + ESP_LOGE(TAG, "bluedroid init failed: %s", esp_err_to_name(ret)); + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", esp_err_to_name(ret)); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + ret = esp_bluedroid_enable(); + if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) { + ESP_LOGE(TAG, "bluedroid enable failed: %s", esp_err_to_name(ret)); + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", esp_err_to_name(ret)); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + esp_ble_gap_register_callback(gap_event_handler); + esp_ble_gatts_register_callback(gatts_event_handler); + + ret = esp_ble_gatts_app_register(GATTS_APP_ID); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "GATTS app register failed: %s", esp_err_to_name(ret)); + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", esp_err_to_name(ret)); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + esp_ble_gatt_set_local_mtu(517); + + s_ble.enabled = true; + ESP_LOGI(TAG, "BLE enabled"); + uart_send_response(id, STATUS_OK, NULL); +} + +void cmd_ble_disable(const char *id, cJSON *params) +{ + (void)params; + + if (!s_ble.enabled) { + uart_send_response(id, STATUS_OK, NULL); + return; + } + + if (s_ble.advertising) { + esp_ble_gap_stop_advertising(); + s_ble.advertising = false; + } + + esp_ble_gatts_app_unregister(s_ble.gatts_if); + esp_bluedroid_disable(); + esp_bluedroid_deinit(); + esp_bt_controller_disable(); + esp_bt_controller_deinit(); + + s_ble.enabled = false; + s_ble.gatts_if = 0; + ESP_LOGI(TAG, "BLE disabled"); + uart_send_response(id, STATUS_OK, NULL); +} + +void cmd_ble_advertise(const char *id, cJSON *params) +{ + if (!s_ble.enabled) { + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", "ble_not_enabled"); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + bool enable = true; + cJSON *j_enable = cJSON_GetObjectItem(params, "enable"); + if (cJSON_IsBool(j_enable)) { + enable = cJSON_IsTrue(j_enable); + } + + cJSON *j_interval = cJSON_GetObjectItem(params, "interval_ms"); + if (cJSON_IsNumber(j_interval)) { + /* Convert ms to 0.625ms units */ + uint16_t raw = (uint16_t)(j_interval->valuedouble / 0.625); + if (raw < 0x20) raw = 0x20; + if (raw > 0x4000) raw = 0x4000; + s_adv_params.adv_int_min = raw; + s_adv_params.adv_int_max = raw; + } + + if (enable) { + esp_err_t ret = esp_ble_gap_start_advertising(&s_adv_params); + if (ret != ESP_OK) { + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", esp_err_to_name(ret)); + uart_send_response(id, STATUS_ERROR, err); + return; + } + s_ble.advertising = true; + } else { + esp_ble_gap_stop_advertising(); + s_ble.advertising = false; + } + + uart_send_response(id, STATUS_OK, NULL); +} + +void cmd_ble_set_adv_data(const char *id, cJSON *params) +{ + if (!s_ble.enabled) { + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", "ble_not_enabled"); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + cJSON *j_name = cJSON_GetObjectItem(params, "name"); + if (cJSON_IsString(j_name)) { + esp_ble_gap_set_device_name(j_name->valuestring); + } + + esp_ble_adv_data_t adv_data = { + .set_scan_rsp = false, + .include_name = true, + .include_txpower = true, + .min_interval = 0x0006, + .max_interval = 0x0010, + .appearance = 0x00, + .manufacturer_len = 0, + .p_manufacturer_data = NULL, + .service_data_len = 0, + .p_service_data = NULL, + .service_uuid_len = 0, + .p_service_uuid = NULL, + .flag = (ESP_BLE_ADV_FLAG_GEN_DISC | ESP_BLE_ADV_FLAG_BREDR_NOT_SPT), + }; + + /* Service UUIDs */ + uint8_t svc_uuid_buf[16 * 8]; /* up to 8 UUIDs */ + int svc_uuid_len = 0; + cJSON *j_uuids = cJSON_GetObjectItem(params, "service_uuids"); + if (cJSON_IsArray(j_uuids)) { + cJSON *j_uuid; + cJSON_ArrayForEach(j_uuid, j_uuids) { + if (cJSON_IsString(j_uuid) && svc_uuid_len < (int)sizeof(svc_uuid_buf) - 16) { + if (parse_uuid(j_uuid->valuestring, svc_uuid_buf + svc_uuid_len) == 0) { + svc_uuid_len += 16; + } + } + } + } + if (svc_uuid_len > 0) { + adv_data.service_uuid_len = svc_uuid_len; + adv_data.p_service_uuid = svc_uuid_buf; + } + + /* Manufacturer data */ + uint8_t mfr_buf[32]; + int mfr_len = 0; + cJSON *j_mfr = cJSON_GetObjectItem(params, "manufacturer_data"); + if (cJSON_IsString(j_mfr)) { + mfr_len = hex_to_bytes(j_mfr->valuestring, mfr_buf, sizeof(mfr_buf)); + } + if (mfr_len > 0) { + adv_data.manufacturer_len = mfr_len; + adv_data.p_manufacturer_data = mfr_buf; + } + + esp_err_t ret = esp_ble_gap_config_adv_data(&adv_data); + if (ret != ESP_OK) { + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", esp_err_to_name(ret)); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + uart_send_response(id, STATUS_OK, NULL); +} + +void cmd_gatt_add_service(const char *id, cJSON *params) +{ + if (!s_ble.enabled) { + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", "ble_not_enabled"); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + if (s_ble.num_services >= MAX_SERVICES) { + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", "max_services_reached"); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + cJSON *j_uuid = cJSON_GetObjectItem(params, "uuid"); + if (!cJSON_IsString(j_uuid)) { + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", "missing uuid"); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + bool primary = true; + cJSON *j_primary = cJSON_GetObjectItem(params, "primary"); + if (cJSON_IsBool(j_primary)) { + primary = cJSON_IsTrue(j_primary); + } + + /* Prepare the service entry (handle filled in by CREATE_EVT) */ + service_entry_t *svc = &s_ble.services[s_ble.num_services]; + memset(svc, 0, sizeof(*svc)); + svc->primary = primary; + if (parse_uuid(j_uuid->valuestring, svc->uuid) != 0) { + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", "invalid uuid"); + uart_send_response(id, STATUS_ERROR, err); + return; + } + s_ble.num_services++; + + /* Build service ID for ESP API */ + esp_gatt_srvc_id_t srvc_id; + memset(&srvc_id, 0, sizeof(srvc_id)); + srvc_id.is_primary = primary; + srvc_id.id.inst_id = 0; + srvc_id.id.uuid.len = ESP_UUID_LEN_128; + memcpy(srvc_id.id.uuid.uuid.uuid128, svc->uuid, 16); + + /* num_handle: 1 (svc) + per char: 1 (char decl) + 1 (char val) + 1 (CCCD) = 3 each, + 1 spare */ + uint16_t num_handle = 1 + MAX_CHARS_PER_SVC * 3 + 1; + + set_pending(id); + esp_err_t ret = esp_ble_gatts_create_service(s_ble.gatts_if, &srvc_id, num_handle); + if (ret != ESP_OK) { + s_ble.num_services--; + s_pending_cmd_id[0] = '\0'; + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", esp_err_to_name(ret)); + uart_send_response(id, STATUS_ERROR, err); + } +} + +void cmd_gatt_add_characteristic(const char *id, cJSON *params) +{ + if (!s_ble.enabled) { + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", "ble_not_enabled"); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + cJSON *j_svc_handle = cJSON_GetObjectItem(params, "service_handle"); + cJSON *j_uuid = cJSON_GetObjectItem(params, "uuid"); + cJSON *j_props = cJSON_GetObjectItem(params, "properties"); + + if (!cJSON_IsNumber(j_svc_handle) || !cJSON_IsString(j_uuid)) { + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", "missing service_handle or uuid"); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + uint16_t svc_handle = (uint16_t)j_svc_handle->valuedouble; + service_entry_t *svc = find_service_by_handle(svc_handle); + if (!svc) { + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", "unknown service_handle"); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + if (svc->num_chars >= MAX_CHARS_PER_SVC) { + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", "max_chars_reached"); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + /* Prepare char entry (handle filled in by ADD_CHAR_EVT) */ + char_entry_t *ch = &svc->chars[svc->num_chars]; + memset(ch, 0, sizeof(*ch)); + + if (parse_uuid(j_uuid->valuestring, ch->uuid) != 0) { + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", "invalid uuid"); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + ch->properties = properties_from_json(j_props); + + /* Initial value */ + cJSON *j_value = cJSON_GetObjectItem(params, "value"); + if (cJSON_IsString(j_value)) { + ch->value_len = hex_to_bytes(j_value->valuestring, + ch->value, MAX_CHAR_VALUE_LEN); + } + + svc->num_chars++; + + /* Build UUID */ + esp_bt_uuid_t char_uuid; + char_uuid.len = ESP_UUID_LEN_128; + memcpy(char_uuid.uuid.uuid128, ch->uuid, 16); + + /* Permission flags derived from properties */ + esp_gatt_perm_t perm = 0; + if (ch->properties & ESP_GATT_CHAR_PROP_BIT_READ) + perm |= ESP_GATT_PERM_READ; + if (ch->properties & (ESP_GATT_CHAR_PROP_BIT_WRITE | ESP_GATT_CHAR_PROP_BIT_WRITE_NR)) + perm |= ESP_GATT_PERM_WRITE; + + /* Auto-add CCCD if notify or indicate */ + esp_attr_control_t auto_rsp = { .auto_rsp = ESP_GATT_RSP_BY_APP }; + + set_pending(id); + esp_err_t ret = esp_ble_gatts_add_char(svc_handle, &char_uuid, + perm, ch->properties, + NULL, &auto_rsp); + if (ret != ESP_OK) { + svc->num_chars--; + s_pending_cmd_id[0] = '\0'; + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", esp_err_to_name(ret)); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + /* If the char supports notify/indicate, add a CCCD descriptor */ + if (ch->properties & (ESP_GATT_CHAR_PROP_BIT_NOTIFY | ESP_GATT_CHAR_PROP_BIT_INDICATE)) { + esp_bt_uuid_t cccd_uuid; + cccd_uuid.len = ESP_UUID_LEN_16; + cccd_uuid.uuid.uuid16 = ESP_GATT_UUID_CHAR_CLIENT_CONFIG; + + esp_ble_gatts_add_char_descr(svc_handle, &cccd_uuid, + ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE, + NULL, NULL); + } +} + +void cmd_gatt_set_value(const char *id, cJSON *params) +{ + cJSON *j_handle = cJSON_GetObjectItem(params, "char_handle"); + cJSON *j_value = cJSON_GetObjectItem(params, "value"); + + if (!cJSON_IsNumber(j_handle) || !cJSON_IsString(j_value)) { + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", "missing char_handle or value"); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + uint16_t handle = (uint16_t)j_handle->valuedouble; + char_entry_t *ch = find_char_by_handle(handle); + if (!ch) { + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", "unknown char_handle"); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + ch->value_len = hex_to_bytes(j_value->valuestring, ch->value, MAX_CHAR_VALUE_LEN); + + esp_ble_gatts_set_attr_value(handle, ch->value_len, ch->value); + uart_send_response(id, STATUS_OK, NULL); +} + +void cmd_gatt_notify(const char *id, cJSON *params) +{ + if (!s_ble.connected) { + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", "not_connected"); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + cJSON *j_handle = cJSON_GetObjectItem(params, "char_handle"); + if (!cJSON_IsNumber(j_handle)) { + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", "missing char_handle"); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + uint16_t handle = (uint16_t)j_handle->valuedouble; + char_entry_t *ch = find_char_by_handle(handle); + if (!ch) { + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", "unknown char_handle"); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + if (!ch->notifications_enabled) { + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", "notifications_not_enabled"); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + bool need_confirm = (ch->properties & ESP_GATT_CHAR_PROP_BIT_INDICATE) != 0; + esp_err_t ret = esp_ble_gatts_send_indicate(s_ble.gatts_if, s_ble.conn_id, + handle, ch->value_len, + ch->value, need_confirm); + if (ret != ESP_OK) { + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", esp_err_to_name(ret)); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + uart_send_response(id, STATUS_OK, NULL); +} + +void cmd_gatt_clear(const char *id, cJSON *params) +{ + (void)params; + + /* Stop and delete all services */ + for (int i = 0; i < s_ble.num_services; i++) { + service_entry_t *svc = &s_ble.services[i]; + if (svc->handle != 0) { + esp_ble_gatts_stop_service(svc->handle); + esp_ble_gatts_delete_service(svc->handle); + } + } + + memset(s_ble.services, 0, sizeof(s_ble.services)); + s_ble.num_services = 0; + + ESP_LOGI(TAG, "GATT services cleared"); + uart_send_response(id, STATUS_OK, NULL); +} + +/* ------------------------------------------------------------------ */ +/* Init */ +/* ------------------------------------------------------------------ */ + +void bt_ble_init(void) +{ + memset(&s_ble, 0, sizeof(s_ble)); + s_pending_cmd_id[0] = '\0'; + ESP_LOGI(TAG, "BLE subsystem ready (max %d services, %d chars each)", + MAX_SERVICES, MAX_CHARS_PER_SVC); +} diff --git a/firmware/main/bt_ble.h b/firmware/main/bt_ble.h new file mode 100644 index 0000000..13b56e9 --- /dev/null +++ b/firmware/main/bt_ble.h @@ -0,0 +1,17 @@ +#pragma once + +#include "cJSON.h" + +/* Initialize BLE subsystem (Bluedroid GATTS) */ +void bt_ble_init(void); + +/* Command handlers (called from cmd_dispatcher) */ +void cmd_ble_enable(const char *id, cJSON *params); +void cmd_ble_disable(const char *id, cJSON *params); +void cmd_ble_advertise(const char *id, cJSON *params); +void cmd_ble_set_adv_data(const char *id, cJSON *params); +void cmd_gatt_add_service(const char *id, cJSON *params); +void cmd_gatt_add_characteristic(const char *id, cJSON *params); +void cmd_gatt_set_value(const char *id, cJSON *params); +void cmd_gatt_notify(const char *id, cJSON *params); +void cmd_gatt_clear(const char *id, cJSON *params); diff --git a/firmware/main/bt_classic.c b/firmware/main/bt_classic.c new file mode 100644 index 0000000..edc2988 --- /dev/null +++ b/firmware/main/bt_classic.c @@ -0,0 +1,628 @@ +/* + * bt_classic.c -- Classic Bluetooth GAP/SSP + minimal SPP peripheral. + * + * Provides pairing support across all four SSP association models + * (Just Works, Numeric Comparison, Passkey Display, Passkey Entry) + * plus legacy PIN for pre-2.1 devices. SPP is registered so remote + * devices have a service to connect to. + */ + +#include "bt_classic.h" +#include "protocol.h" +#include "uart_handler.h" +#include "event_reporter.h" + +#include "esp_bt.h" +#include "esp_bt_main.h" +#include "esp_gap_bt_api.h" +#include "esp_bt_device.h" +#include "esp_spp_api.h" +#include "esp_log.h" + +#include +#include +#include + +static const char *TAG = "classic"; + +#define SPP_SERVER_NAME "mcbt_spp" +#define SPP_TAG "spp" + +/* ------------------------------------------------------------------ */ +/* Module state */ +/* ------------------------------------------------------------------ */ + +typedef enum { + PAIR_TYPE_NONE = 0, + PAIR_TYPE_NUMERIC_COMPARISON, + PAIR_TYPE_PASSKEY_DISPLAY, + PAIR_TYPE_PASSKEY_ENTRY, + PAIR_TYPE_PIN_REQUEST, +} pair_type_t; + +static struct { + bool enabled; + bool discoverable; + esp_bt_io_cap_t io_cap; + char pin_code[17]; + bool ssp_enabled; + /* Pending pairing state */ + char pending_pair_address[18]; + char pending_pair_cmd_id[32]; + bool pair_pending; + pair_type_t pair_type; + /* SPP handle for the listening server */ + uint32_t spp_handle; +} classic_state = { + .io_cap = ESP_BT_IO_CAP_IO, /* display+yesno (numeric comparison) */ + .ssp_enabled = true, +}; + +/* ------------------------------------------------------------------ */ +/* Address helpers */ +/* ------------------------------------------------------------------ */ + +static bool parse_bd_addr(const char *str, esp_bd_addr_t addr) +{ + if (!str || strlen(str) < 17) { + return false; + } + unsigned int b[6]; + int rc = sscanf(str, "%02x:%02x:%02x:%02x:%02x:%02x", + &b[0], &b[1], &b[2], &b[3], &b[4], &b[5]); + if (rc != 6) { + /* try uppercase */ + rc = sscanf(str, "%02X:%02X:%02X:%02X:%02X:%02X", + &b[0], &b[1], &b[2], &b[3], &b[4], &b[5]); + } + if (rc != 6) { + return false; + } + for (int i = 0; i < 6; i++) { + addr[i] = (uint8_t)b[i]; + } + return true; +} + +static void bd_addr_to_str(const esp_bd_addr_t addr, char *str) +{ + sprintf(str, "%02X:%02X:%02X:%02X:%02X:%02X", + addr[0], addr[1], addr[2], addr[3], addr[4], addr[5]); +} + +/* ------------------------------------------------------------------ */ +/* GAP callback */ +/* ------------------------------------------------------------------ */ + +static void gap_cb(esp_bt_gap_cb_event_t event, esp_bt_gap_cb_param_t *param) +{ + char addr_str[18]; + + switch (event) { + + /* --- Pairing complete --- */ + case ESP_BT_GAP_AUTH_CMPL_EVT: { + bd_addr_to_str(param->auth_cmpl.bda, addr_str); + bool ok = (param->auth_cmpl.stat == ESP_BT_STATUS_SUCCESS); + + ESP_LOGI(TAG, "auth_cmpl: %s %s (mode=%d)", + addr_str, ok ? "success" : "FAIL", + param->auth_cmpl.auth_mode); + + event_report_pair_complete(addr_str, ok); + + /* If a pair_respond command is waiting, resolve it now. */ + if (classic_state.pair_pending && + strcmp(classic_state.pending_pair_address, addr_str) == 0) { + cJSON *d = cJSON_CreateObject(); + cJSON_AddStringToObject(d, "address", addr_str); + cJSON_AddBoolToObject(d, "success", ok); + uart_send_response(classic_state.pending_pair_cmd_id, STATUS_OK, d); + classic_state.pair_pending = false; + } + break; + } + + /* --- Numeric Comparison (SSP model 2) --- */ + case ESP_BT_GAP_CFM_REQ_EVT: { + bd_addr_to_str(param->cfm_req.bda, addr_str); + uint32_t passkey = param->cfm_req.num_val; + + ESP_LOGI(TAG, "cfm_req: %s passkey=%06" PRIu32, addr_str, passkey); + + event_report_pair_request(addr_str, "numeric_comparison", (int)passkey); + + /* Stash address so pair_respond can reply */ + strncpy(classic_state.pending_pair_address, addr_str, + sizeof(classic_state.pending_pair_address) - 1); + classic_state.pair_type = PAIR_TYPE_NUMERIC_COMPARISON; + break; + } + + /* --- Passkey Display (SSP model 3 -- we show, remote enters) --- */ + case ESP_BT_GAP_KEY_NOTIF_EVT: { + bd_addr_to_str(param->key_notif.bda, addr_str); + uint32_t passkey = param->key_notif.passkey; + + ESP_LOGI(TAG, "key_notif: %s passkey=%06" PRIu32, addr_str, passkey); + + event_report_pair_request(addr_str, "passkey_display", (int)passkey); + + /* Nothing to wait for -- the remote side enters the key. */ + break; + } + + /* --- Passkey Entry (SSP model 4 -- we must enter) --- */ + case ESP_BT_GAP_KEY_REQ_EVT: { + bd_addr_to_str(param->key_req.bda, addr_str); + + ESP_LOGI(TAG, "key_req: %s (waiting for passkey)", addr_str); + + event_report_pair_request(addr_str, "passkey_entry", 0); + + strncpy(classic_state.pending_pair_address, addr_str, + sizeof(classic_state.pending_pair_address) - 1); + classic_state.pair_type = PAIR_TYPE_PASSKEY_ENTRY; + break; + } + + /* --- Legacy PIN request (pre-2.1 devices) --- */ + case ESP_BT_GAP_PIN_REQ_EVT: { + bd_addr_to_str(param->pin_req.bda, addr_str); + bool min_16 = param->pin_req.min_16_digit; + + ESP_LOGI(TAG, "pin_req: %s min_16=%d", addr_str, min_16); + + /* Auto-reply if a PIN is pre-configured */ + if (classic_state.pin_code[0] != '\0') { + uint8_t pin_len = (uint8_t)strlen(classic_state.pin_code); + esp_bt_pin_code_t pin; + memset(pin, 0, sizeof(pin)); + memcpy(pin, classic_state.pin_code, pin_len); + esp_bt_gap_pin_reply(param->pin_req.bda, true, pin_len, pin); + + ESP_LOGI(TAG, "pin_req: auto-replied with stored PIN"); + + /* Still report the event so the host sees what happened */ + event_report_pair_request(addr_str, "pin_request", 0); + break; + } + + /* No stored PIN -- ask the host */ + event_report_pair_request(addr_str, "pin_request", 0); + + strncpy(classic_state.pending_pair_address, addr_str, + sizeof(classic_state.pending_pair_address) - 1); + classic_state.pair_type = PAIR_TYPE_PIN_REQUEST; + break; + } + + /* --- Discovery (we're a peripheral, not scanning -- just log) --- */ + case ESP_BT_GAP_DISC_STATE_CHANGED_EVT: + ESP_LOGI(TAG, "discovery state changed: %d", + param->disc_st_chg.state); + break; + + case ESP_BT_GAP_DISC_RES_EVT: + /* We don't initiate discovery, ignore. */ + break; + + default: + ESP_LOGD(TAG, "gap event %d (unhandled)", event); + break; + } +} + +/* ------------------------------------------------------------------ */ +/* SPP callback (minimal -- just report connects/disconnects) */ +/* ------------------------------------------------------------------ */ + +static void spp_cb(esp_spp_cb_event_t event, esp_spp_cb_param_t *param) +{ + char addr_str[18]; + + switch (event) { + + case ESP_SPP_INIT_EVT: + if (param->init.status == ESP_SPP_SUCCESS) { + esp_spp_start_srv(ESP_SPP_SEC_AUTHENTICATE, ESP_SPP_ROLE_SLAVE, + 0, SPP_SERVER_NAME); + ESP_LOGI(SPP_TAG, "SPP initialised, starting server"); + } else { + ESP_LOGE(SPP_TAG, "SPP init failed: %d", param->init.status); + } + break; + + case ESP_SPP_SRV_OPEN_EVT: + bd_addr_to_str(param->srv_open.rem_bda, addr_str); + classic_state.spp_handle = param->srv_open.handle; + ESP_LOGI(SPP_TAG, "SPP client connected: %s (handle=%" PRIu32 ")", + addr_str, param->srv_open.handle); + event_report_connect(addr_str, "classic"); + break; + + case ESP_SPP_CLOSE_EVT: + ESP_LOGI(SPP_TAG, "SPP disconnected (handle=%" PRIu32 ")", + param->close.handle); + /* We don't have the remote address in CLOSE_EVT on all IDF versions, + * so report with handle info. */ + { + cJSON *d = cJSON_CreateObject(); + cJSON_AddNumberToObject(d, "handle", (double)param->close.handle); + cJSON_AddStringToObject(d, "transport", "classic"); + event_report(EVT_DISCONNECT, d); + } + if (classic_state.spp_handle == param->close.handle) { + classic_state.spp_handle = 0; + } + break; + + case ESP_SPP_START_EVT: + if (param->start.status == ESP_SPP_SUCCESS) { + ESP_LOGI(SPP_TAG, "SPP server started (scn=%d)", param->start.scn); + } else { + ESP_LOGE(SPP_TAG, "SPP server start failed: %d", + param->start.status); + } + break; + + case ESP_SPP_DATA_IND_EVT: + /* Data received over SPP -- log but don't process for now. */ + ESP_LOGI(SPP_TAG, "SPP data: %d bytes on handle %" PRIu32, + param->data_ind.len, param->data_ind.handle); + break; + + default: + ESP_LOGD(SPP_TAG, "spp event %d", event); + break; + } +} + +/* ------------------------------------------------------------------ */ +/* IO capability string mapping */ +/* ------------------------------------------------------------------ */ + +static bool str_to_io_cap(const char *s, esp_bt_io_cap_t *out) +{ + if (!s || !out) return false; + + if (strcmp(s, IO_CAP_DISPLAY_ONLY) == 0) { + *out = ESP_BT_IO_CAP_OUT; + } else if (strcmp(s, IO_CAP_DISPLAY_YESNO) == 0) { + *out = ESP_BT_IO_CAP_IO; + } else if (strcmp(s, IO_CAP_KEYBOARD_ONLY) == 0) { + *out = ESP_BT_IO_CAP_IN; + } else if (strcmp(s, IO_CAP_NO_IO) == 0) { + *out = ESP_BT_IO_CAP_NONE; + } else if (strcmp(s, IO_CAP_KEYBOARD_DISPLAY) == 0) { + *out = ESP_BT_IO_CAP_IO; /* closest match in Bluedroid */ + } else { + return false; + } + return true; +} + +/* ------------------------------------------------------------------ */ +/* Command handlers */ +/* ------------------------------------------------------------------ */ + +void cmd_classic_enable(const char *id, cJSON *params) +{ + (void)params; + + if (classic_state.enabled) { + uart_send_response(id, STATUS_OK, cJSON_CreateString("already enabled")); + return; + } + + /* Release BLE-only memory when running dual-mode. + * esp_bt_controller_mem_release(ESP_BT_MODE_BLE) would be called + * if we only wanted Classic; here we keep both available. + * If the controller is already initialised (e.g. BLE brought it up), + * skip controller init. */ + esp_bt_controller_status_t ctrl_status = esp_bt_controller_get_status(); + + if (ctrl_status == ESP_BT_CONTROLLER_STATUS_IDLE) { + esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT(); + esp_err_t err = esp_bt_controller_init(&bt_cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "controller init failed: %s", esp_err_to_name(err)); + uart_send_response(id, STATUS_ERROR, + cJSON_CreateString(esp_err_to_name(err))); + return; + } + } + + if (esp_bt_controller_get_status() == ESP_BT_CONTROLLER_STATUS_INITED) { + esp_err_t err = esp_bt_controller_enable(ESP_BT_MODE_BTDM); + if (err != ESP_OK) { + ESP_LOGE(TAG, "controller enable failed: %s", esp_err_to_name(err)); + uart_send_response(id, STATUS_ERROR, + cJSON_CreateString(esp_err_to_name(err))); + return; + } + } + + /* Bluedroid init + enable */ + esp_bluedroid_status_t bd_status = esp_bluedroid_get_status(); + + if (bd_status == ESP_BLUEDROID_STATUS_UNINITIALIZED) { + esp_err_t err = esp_bluedroid_init(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "bluedroid init failed: %s", esp_err_to_name(err)); + uart_send_response(id, STATUS_ERROR, + cJSON_CreateString(esp_err_to_name(err))); + return; + } + } + + if (esp_bluedroid_get_status() == ESP_BLUEDROID_STATUS_INITIALIZED) { + esp_err_t err = esp_bluedroid_enable(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "bluedroid enable failed: %s", esp_err_to_name(err)); + uart_send_response(id, STATUS_ERROR, + cJSON_CreateString(esp_err_to_name(err))); + return; + } + } + + /* Register GAP callback */ + esp_bt_gap_register_callback(gap_cb); + + /* Configure SSP IO capability */ + esp_bt_sp_param_t iocap = classic_state.io_cap; + esp_bt_gap_set_security_param(ESP_BT_SP_IOCAP_MODE, &iocap, + sizeof(iocap)); + ESP_LOGI(TAG, "SSP io_cap set to %d", classic_state.io_cap); + + /* Initialise and register SPP */ + esp_spp_register_callback(spp_cb); + + esp_spp_cfg_t spp_cfg = { + .mode = ESP_SPP_MODE_CB, + .enable_l2cap_ertm = false, + }; + esp_err_t err = esp_spp_enhanced_init(&spp_cfg); + if (err != ESP_OK) { + ESP_LOGW(TAG, "spp_enhanced_init: %s (trying legacy init)", + esp_err_to_name(err)); + /* Fall back for older IDF builds that lack enhanced init */ + esp_spp_init(ESP_SPP_MODE_CB); + } + + classic_state.enabled = true; + + ESP_LOGI(TAG, "Classic BT enabled"); + uart_send_response(id, STATUS_OK, NULL); +} + +void cmd_classic_disable(const char *id, cJSON *params) +{ + (void)params; + + if (!classic_state.enabled) { + uart_send_response(id, STATUS_OK, cJSON_CreateString("already disabled")); + return; + } + + esp_spp_deinit(); + esp_bluedroid_disable(); + + classic_state.enabled = false; + classic_state.discoverable = false; + classic_state.pair_pending = false; + classic_state.spp_handle = 0; + + ESP_LOGI(TAG, "Classic BT disabled"); + uart_send_response(id, STATUS_OK, NULL); +} + +void cmd_classic_set_discoverable(const char *id, cJSON *params) +{ + if (!classic_state.enabled) { + uart_send_response(id, STATUS_ERROR, + cJSON_CreateString("classic BT not enabled")); + return; + } + + bool discoverable = true; + const cJSON *j = cJSON_GetObjectItem(params, "discoverable"); + if (cJSON_IsBool(j)) { + discoverable = cJSON_IsTrue(j); + } + + if (discoverable) { + esp_bt_gap_set_scan_mode(ESP_BT_CONNECTABLE, + ESP_BT_GENERAL_DISCOVERABLE); + ESP_LOGI(TAG, "now discoverable + connectable"); + } else { + esp_bt_gap_set_scan_mode(ESP_BT_NON_CONNECTABLE, + ESP_BT_NON_DISCOVERABLE); + ESP_LOGI(TAG, "now non-discoverable + non-connectable"); + } + + classic_state.discoverable = discoverable; + + cJSON *data = cJSON_CreateObject(); + cJSON_AddBoolToObject(data, "discoverable", discoverable); + uart_send_response(id, STATUS_OK, data); +} + +void cmd_classic_pair_respond(const char *id, cJSON *params) +{ + if (!classic_state.enabled) { + uart_send_response(id, STATUS_ERROR, + cJSON_CreateString("classic BT not enabled")); + return; + } + + const cJSON *j_addr = cJSON_GetObjectItem(params, "address"); + const cJSON *j_accept = cJSON_GetObjectItem(params, "accept"); + const cJSON *j_pass = cJSON_GetObjectItem(params, "passkey"); + const cJSON *j_pin = cJSON_GetObjectItem(params, "pin"); + + if (!cJSON_IsString(j_addr)) { + uart_send_response(id, STATUS_ERROR, + cJSON_CreateString("missing 'address'")); + return; + } + + esp_bd_addr_t addr; + if (!parse_bd_addr(j_addr->valuestring, addr)) { + uart_send_response(id, STATUS_ERROR, + cJSON_CreateString("invalid address format")); + return; + } + + bool accept = cJSON_IsBool(j_accept) ? cJSON_IsTrue(j_accept) : true; + + /* Determine which pairing reply to issue based on pending type */ + switch (classic_state.pair_type) { + + case PAIR_TYPE_NUMERIC_COMPARISON: + esp_bt_gap_ssp_confirm_reply(addr, accept); + ESP_LOGI(TAG, "ssp_confirm_reply: %s accept=%d", + j_addr->valuestring, accept); + break; + + case PAIR_TYPE_PASSKEY_ENTRY: + if (!cJSON_IsNumber(j_pass)) { + uart_send_response(id, STATUS_ERROR, + cJSON_CreateString("passkey_entry requires 'passkey'")); + return; + } + esp_bt_gap_ssp_passkey_reply(addr, accept, + (uint32_t)j_pass->valueint); + ESP_LOGI(TAG, "ssp_passkey_reply: %s accept=%d passkey=%d", + j_addr->valuestring, accept, j_pass->valueint); + break; + + case PAIR_TYPE_PIN_REQUEST: { + if (!cJSON_IsString(j_pin)) { + uart_send_response(id, STATUS_ERROR, + cJSON_CreateString("pin_request requires 'pin'")); + return; + } + const char *pin_str = j_pin->valuestring; + uint8_t pin_len = (uint8_t)strlen(pin_str); + if (pin_len == 0 || pin_len > 16) { + uart_send_response(id, STATUS_ERROR, + cJSON_CreateString("pin must be 1-16 characters")); + return; + } + esp_bt_pin_code_t pin; + memset(pin, 0, sizeof(pin)); + memcpy(pin, pin_str, pin_len); + esp_bt_gap_pin_reply(addr, accept, pin_len, pin); + ESP_LOGI(TAG, "pin_reply: %s accept=%d len=%d", + j_addr->valuestring, accept, pin_len); + break; + } + + default: + uart_send_response(id, STATUS_ERROR, + cJSON_CreateString("no pending pair request")); + return; + } + + /* Stash the command id so AUTH_CMPL can send the final response */ + strncpy(classic_state.pending_pair_cmd_id, id, + sizeof(classic_state.pending_pair_cmd_id) - 1); + classic_state.pending_pair_cmd_id[sizeof(classic_state.pending_pair_cmd_id) - 1] = '\0'; + classic_state.pair_pending = true; + + /* Don't send the response yet -- AUTH_CMPL will do it. */ +} + +void cmd_classic_set_ssp_mode(const char *id, cJSON *params) +{ + if (!classic_state.enabled) { + uart_send_response(id, STATUS_ERROR, + cJSON_CreateString("classic BT not enabled")); + return; + } + + const cJSON *j_mode = cJSON_GetObjectItem(params, "mode"); + if (!cJSON_IsString(j_mode)) { + uart_send_response(id, STATUS_ERROR, + cJSON_CreateString("missing 'mode'")); + return; + } + + const char *mode = j_mode->valuestring; + esp_bt_io_cap_t new_cap; + + if (strcmp(mode, "just_works") == 0) { + new_cap = ESP_BT_IO_CAP_NONE; + } else if (strcmp(mode, "numeric_comparison") == 0) { + new_cap = ESP_BT_IO_CAP_IO; + } else if (strcmp(mode, "passkey_entry") == 0) { + new_cap = ESP_BT_IO_CAP_IN; + } else if (strcmp(mode, "passkey_display") == 0) { + new_cap = ESP_BT_IO_CAP_OUT; + } else { + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", "unknown mode"); + cJSON_AddStringToObject(err, "valid", + "just_works, numeric_comparison, passkey_entry, passkey_display"); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + classic_state.io_cap = new_cap; + + esp_bt_sp_param_t iocap = new_cap; + esp_bt_gap_set_security_param(ESP_BT_SP_IOCAP_MODE, &iocap, + sizeof(iocap)); + + ESP_LOGI(TAG, "SSP mode set to '%s' (io_cap=%d)", mode, new_cap); + + cJSON *data = cJSON_CreateObject(); + cJSON_AddStringToObject(data, "mode", mode); + cJSON_AddNumberToObject(data, "io_cap", new_cap); + uart_send_response(id, STATUS_OK, data); +} + +/* ------------------------------------------------------------------ */ +/* Configure helpers (called from the configure command handler) */ +/* ------------------------------------------------------------------ */ + +void bt_classic_set_io_cap(const char *io_cap_str) +{ + esp_bt_io_cap_t cap; + if (str_to_io_cap(io_cap_str, &cap)) { + classic_state.io_cap = cap; + ESP_LOGI(TAG, "io_cap configured: %s -> %d", io_cap_str, cap); + + /* Apply immediately if already enabled */ + if (classic_state.enabled) { + esp_bt_sp_param_t iocap = cap; + esp_bt_gap_set_security_param(ESP_BT_SP_IOCAP_MODE, &iocap, + sizeof(iocap)); + } + } else { + ESP_LOGW(TAG, "unknown io_cap string: '%s'", io_cap_str); + } +} + +void bt_classic_set_device_class(uint32_t cod) +{ + esp_err_t err = esp_bt_gap_set_cod(cod, ESP_BT_SET_COD_ALL); + if (err == ESP_OK) { + ESP_LOGI(TAG, "CoD set to 0x%06" PRIx32, cod); + } else { + ESP_LOGE(TAG, "set_cod failed: %s", esp_err_to_name(err)); + } +} + +/* ------------------------------------------------------------------ */ +/* Init (called early, before any commands) */ +/* ------------------------------------------------------------------ */ + +void bt_classic_init(void) +{ + memset(&classic_state, 0, sizeof(classic_state)); + classic_state.io_cap = ESP_BT_IO_CAP_IO; + classic_state.ssp_enabled = true; + ESP_LOGI(TAG, "Classic BT module ready"); +} diff --git a/firmware/main/bt_classic.h b/firmware/main/bt_classic.h new file mode 100644 index 0000000..062f414 --- /dev/null +++ b/firmware/main/bt_classic.h @@ -0,0 +1,19 @@ +/* + * bt_classic.h -- Classic Bluetooth GAP/SSP + SPP peripheral. + */ + +#pragma once +#include "cJSON.h" + +void bt_classic_init(void); + +/* Command handlers (dispatched from cmd_dispatcher) */ +void cmd_classic_enable(const char *id, cJSON *params); +void cmd_classic_disable(const char *id, cJSON *params); +void cmd_classic_set_discoverable(const char *id, cJSON *params); +void cmd_classic_pair_respond(const char *id, cJSON *params); +void cmd_classic_set_ssp_mode(const char *id, cJSON *params); + +/* Called by configure command to set IO capabilities */ +void bt_classic_set_io_cap(const char *io_cap_str); +void bt_classic_set_device_class(uint32_t cod); diff --git a/firmware/main/cmd_dispatcher.c b/firmware/main/cmd_dispatcher.c new file mode 100644 index 0000000..edce37a --- /dev/null +++ b/firmware/main/cmd_dispatcher.c @@ -0,0 +1,140 @@ +#include "cmd_dispatcher.h" +#include "protocol.h" +#include "uart_handler.h" + +#include "esp_system.h" +#include "esp_chip_info.h" +#include "esp_timer.h" +#include "esp_log.h" +#include "esp_mac.h" +#include "esp_bt.h" +#include "cJSON.h" + +#include + +#define FW_VERSION "0.1.0" + +static const char *TAG = "dispatch"; + +typedef void (*cmd_handler_t)(const char *id, cJSON *params); + +typedef struct { + const char *name; + cmd_handler_t handler; +} cmd_entry_t; + +/* Forward declarations for handlers in other modules */ +extern void bt_classic_register_commands(void); +extern void bt_ble_register_commands(void); +extern void personas_register_commands(void); + +/* --- System command handlers --- */ + +static void handle_ping(const char *id, cJSON *params) +{ + (void)params; + cJSON *data = cJSON_CreateObject(); + cJSON_AddBoolToObject(data, "pong", cJSON_True); + uart_send_response(id, STATUS_OK, data); +} + +static void handle_reset(const char *id, cJSON *params) +{ + (void)params; + uart_send_response(id, STATUS_OK, NULL); + vTaskDelay(pdMS_TO_TICKS(100)); /* let the response flush */ + esp_restart(); +} + +static void handle_get_info(const char *id, cJSON *params) +{ + (void)params; + + esp_chip_info_t chip; + esp_chip_info(&chip); + + const char *model; + switch (chip.model) { + case CHIP_ESP32: model = "ESP32"; break; + case CHIP_ESP32S2: model = "ESP32-S2"; break; + case CHIP_ESP32S3: model = "ESP32-S3"; break; + case CHIP_ESP32C3: model = "ESP32-C3"; break; + case CHIP_ESP32H2: model = "ESP32-H2"; break; + default: model = "unknown"; break; + } + + /* Decode feature flags */ + cJSON *features = cJSON_CreateArray(); + if (chip.features & CHIP_FEATURE_WIFI_BGN) cJSON_AddItemToArray(features, cJSON_CreateString("wifi")); + if (chip.features & CHIP_FEATURE_BT) cJSON_AddItemToArray(features, cJSON_CreateString("bt")); + if (chip.features & CHIP_FEATURE_BLE) cJSON_AddItemToArray(features, cJSON_CreateString("ble")); + + /* BT MAC address */ + uint8_t mac[6]; + esp_read_mac(mac, ESP_MAC_BT); + char mac_str[18]; + snprintf(mac_str, sizeof(mac_str), "%02X:%02X:%02X:%02X:%02X:%02X", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + + cJSON *data = cJSON_CreateObject(); + cJSON_AddStringToObject(data, "chip_model", model); + cJSON_AddItemToObject(data, "features", features); + cJSON_AddNumberToObject(data, "revision", chip.revision); + cJSON_AddNumberToObject(data, "cores", chip.cores); + cJSON_AddStringToObject(data, "fw_version", FW_VERSION); + cJSON_AddNumberToObject(data, "free_heap", (double)esp_get_free_heap_size()); + cJSON_AddStringToObject(data, "bt_mac", mac_str); + + uart_send_response(id, STATUS_OK, data); +} + +static void handle_get_status(const char *id, cJSON *params) +{ + (void)params; + + int64_t uptime_ms = esp_timer_get_time() / 1000; + + cJSON *data = cJSON_CreateObject(); + cJSON_AddNumberToObject(data, "uptime_ms", (double)uptime_ms); + cJSON_AddNumberToObject(data, "free_heap", (double)esp_get_free_heap_size()); + cJSON_AddBoolToObject(data, "bt_enabled", cJSON_False); + cJSON_AddBoolToObject(data, "ble_enabled", cJSON_False); + + uart_send_response(id, STATUS_OK, data); +} + +/* --- Command table --- */ + +static const cmd_entry_t cmd_table[] = { + { CMD_PING, handle_ping }, + { CMD_RESET, handle_reset }, + { CMD_GET_INFO, handle_get_info }, + { CMD_GET_STATUS, handle_get_status }, + { NULL, NULL } /* sentinel */ +}; + +void cmd_dispatcher_init(void) +{ + ESP_LOGI(TAG, "command dispatcher ready"); + + /* Future: these will register their own handlers into the table */ + /* bt_classic_register_commands(); */ + /* bt_ble_register_commands(); */ + /* personas_register_commands(); */ +} + +void cmd_dispatch(const char *id, const char *cmd, cJSON *params) +{ + for (const cmd_entry_t *entry = cmd_table; entry->name != NULL; entry++) { + if (strcmp(entry->name, cmd) == 0) { + entry->handler(id, params); + return; + } + } + + ESP_LOGW(TAG, "unknown command: %s", cmd); + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", "unknown_command"); + cJSON_AddStringToObject(err, "cmd", cmd); + uart_send_response(id, STATUS_ERROR, err); +} diff --git a/firmware/main/cmd_dispatcher.h b/firmware/main/cmd_dispatcher.h new file mode 100644 index 0000000..b50fbde --- /dev/null +++ b/firmware/main/cmd_dispatcher.h @@ -0,0 +1,6 @@ +#pragma once + +#include "cJSON.h" + +void cmd_dispatcher_init(void); +void cmd_dispatch(const char *id, const char *cmd, cJSON *params); diff --git a/firmware/main/event_reporter.c b/firmware/main/event_reporter.c new file mode 100644 index 0000000..75b7e1f --- /dev/null +++ b/firmware/main/event_reporter.c @@ -0,0 +1,172 @@ +/* + * event_reporter.c -- Drain BT events from a FreeRTOS queue, emit NDJSON. + */ + +#include "event_reporter.h" +#include "protocol.h" +#include "uart_handler.h" + +#include +#include +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/task.h" +#include "cJSON.h" +#include "esp_log.h" + +#define TAG "event" + +#define EVENT_QUEUE_DEPTH 32 +#define REPORTER_STACK 4096 +#define REPORTER_PRIORITY 5 +#define EVENT_NAME_MAX 32 + +/* ------------------------------------------------------------------ */ +/* Queue payload */ +/* ------------------------------------------------------------------ */ + +typedef struct { + char event_name[EVENT_NAME_MAX]; + cJSON *data; /* ownership transfers to uart_send_event */ +} event_msg_t; + +static QueueHandle_t s_event_queue; + +/* ------------------------------------------------------------------ */ +/* Reporter task */ +/* ------------------------------------------------------------------ */ + +static void reporter_task(void *arg) +{ + (void)arg; + event_msg_t msg; + + for (;;) { + if (xQueueReceive(s_event_queue, &msg, portMAX_DELAY) == pdTRUE) { + /* + * uart_send_event() takes ownership of msg.data via + * cJSON_AddItemToObject -- the root (and therefore data) + * is freed inside uart_send_event. No delete here. + */ + uart_send_event(msg.event_name, msg.data); + } + } +} + +/* ------------------------------------------------------------------ */ +/* Public API */ +/* ------------------------------------------------------------------ */ + +void event_reporter_init(void) +{ + s_event_queue = xQueueCreate(EVENT_QUEUE_DEPTH, sizeof(event_msg_t)); + if (!s_event_queue) { + ESP_LOGE(TAG, "failed to create event queue"); + return; + } + + BaseType_t rc = xTaskCreate(reporter_task, "evt_rpt", + REPORTER_STACK, NULL, + REPORTER_PRIORITY, NULL); + if (rc != pdPASS) { + ESP_LOGE(TAG, "failed to create reporter task"); + } +} + +void event_report(const char *event_name, cJSON *data) +{ + if (!s_event_queue) { + ESP_LOGW(TAG, "reporter not initialised, dropping %s", event_name); + cJSON_Delete(data); + return; + } + + event_msg_t msg; + memset(&msg, 0, sizeof(msg)); + strncpy(msg.event_name, event_name, EVENT_NAME_MAX - 1); + msg.data = data; + + if (xQueueSend(s_event_queue, &msg, 0) != pdTRUE) { + ESP_LOGW(TAG, "event queue full, dropping %s", event_name); + cJSON_Delete(data); + } +} + +/* ------------------------------------------------------------------ */ +/* Convenience helpers */ +/* ------------------------------------------------------------------ */ + +void event_report_pair_request(const char *address, const char *type, + int passkey) +{ + cJSON *d = cJSON_CreateObject(); + cJSON_AddStringToObject(d, "address", address); + cJSON_AddStringToObject(d, "type", type); + cJSON_AddNumberToObject(d, "passkey", passkey); + event_report(EVT_PAIR_REQUEST, d); +} + +void event_report_pair_complete(const char *address, bool success) +{ + cJSON *d = cJSON_CreateObject(); + cJSON_AddStringToObject(d, "address", address); + cJSON_AddBoolToObject(d, "success", success); + event_report(EVT_PAIR_COMPLETE, d); +} + +void event_report_connect(const char *address, const char *transport) +{ + cJSON *d = cJSON_CreateObject(); + cJSON_AddStringToObject(d, "address", address); + cJSON_AddStringToObject(d, "transport", transport); + event_report(EVT_CONNECT, d); +} + +void event_report_disconnect(const char *address, const char *transport) +{ + cJSON *d = cJSON_CreateObject(); + cJSON_AddStringToObject(d, "address", address); + cJSON_AddStringToObject(d, "transport", transport); + event_report(EVT_DISCONNECT, d); +} + +void event_report_gatt_read(uint16_t char_handle, const char *address) +{ + cJSON *d = cJSON_CreateObject(); + cJSON_AddNumberToObject(d, "handle", char_handle); + cJSON_AddStringToObject(d, "address", address); + event_report(EVT_GATT_READ, d); +} + +void event_report_gatt_write(uint16_t char_handle, const char *address, + const uint8_t *value, uint16_t len) +{ + cJSON *d = cJSON_CreateObject(); + cJSON_AddNumberToObject(d, "handle", char_handle); + cJSON_AddStringToObject(d, "address", address); + + /* Encode raw bytes as a hex string (2 chars per byte + NUL). */ + char *hex = malloc((size_t)len * 2 + 1); + if (hex) { + for (uint16_t i = 0; i < len; i++) { + sprintf(hex + i * 2, "%02x", value[i]); + } + hex[len * 2] = '\0'; + cJSON_AddStringToObject(d, "value", hex); + free(hex); + } else { + cJSON_AddStringToObject(d, "value", ""); + ESP_LOGW(TAG, "hex alloc failed for %u bytes", len); + } + + cJSON_AddNumberToObject(d, "length", len); + event_report(EVT_GATT_WRITE, d); +} + +void event_report_gatt_subscribe(uint16_t char_handle, bool subscribed) +{ + cJSON *d = cJSON_CreateObject(); + cJSON_AddNumberToObject(d, "handle", char_handle); + cJSON_AddBoolToObject(d, "subscribed", subscribed); + event_report(EVT_GATT_SUBSCRIBE, d); +} diff --git a/firmware/main/event_reporter.h b/firmware/main/event_reporter.h new file mode 100644 index 0000000..6cfd5b9 --- /dev/null +++ b/firmware/main/event_reporter.h @@ -0,0 +1,35 @@ +/* + * event_reporter.h -- Thread-safe event queue for BT callback -> UART NDJSON. + * + * BT callbacks run on the Bluedroid task; touching UART from there is + * asking for trouble. Instead, callbacks push lightweight event structs + * onto a FreeRTOS queue. A dedicated reporter task drains the queue + * and serialises each event as a single NDJSON line over UART. + */ + +#pragma once + +#include +#include +#include "cJSON.h" + +/* Lifecycle */ +void event_reporter_init(void); + +/* + * Generic event push. Takes ownership of `data` (will be freed by the + * reporter task after serialisation). Safe to call from any task/ISR + * context that can tolerate a brief queue-send timeout. + */ +void event_report(const char *event_name, cJSON *data); + +/* Convenience helpers -- build the cJSON internally. */ +void event_report_pair_request(const char *address, const char *type, + int passkey); +void event_report_pair_complete(const char *address, bool success); +void event_report_connect(const char *address, const char *transport); +void event_report_disconnect(const char *address, const char *transport); +void event_report_gatt_read(uint16_t char_handle, const char *address); +void event_report_gatt_write(uint16_t char_handle, const char *address, + const uint8_t *value, uint16_t len); +void event_report_gatt_subscribe(uint16_t char_handle, bool subscribed); diff --git a/firmware/main/main.c b/firmware/main/main.c new file mode 100644 index 0000000..4401757 --- /dev/null +++ b/firmware/main/main.c @@ -0,0 +1,100 @@ +#include "protocol.h" +#include "uart_handler.h" +#include "cmd_dispatcher.h" + +#include "nvs_flash.h" +#include "esp_system.h" +#include "esp_chip_info.h" +#include "esp_timer.h" +#include "esp_log.h" +#include "esp_mac.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "cJSON.h" + +#include +#include + +#define FW_VERSION "0.1.0" + +static const char *TAG = "main"; + +static void send_boot_event(void) +{ + esp_chip_info_t chip; + esp_chip_info(&chip); + + const char *model; + switch (chip.model) { + case CHIP_ESP32: model = "ESP32"; break; + case CHIP_ESP32S2: model = "ESP32-S2"; break; + case CHIP_ESP32S3: model = "ESP32-S3"; break; + case CHIP_ESP32C3: model = "ESP32-C3"; break; + case CHIP_ESP32H2: model = "ESP32-H2"; break; + default: model = "unknown"; break; + } + + cJSON *data = cJSON_CreateObject(); + cJSON_AddStringToObject(data, "fw_version", FW_VERSION); + cJSON_AddStringToObject(data, "chip_model", model); + cJSON_AddNumberToObject(data, "cores", chip.cores); + cJSON_AddNumberToObject(data, "revision", chip.revision); + cJSON_AddNumberToObject(data, "free_heap", (double)esp_get_free_heap_size()); + + uart_send_event(EVT_BOOT, data); +} + +void app_main(void) +{ + /* NVS -- needed for Bluetooth bonding storage */ + esp_err_t ret = nvs_flash_init(); + if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_LOGW(TAG, "NVS partition issue, erasing and re-initializing"); + ESP_ERROR_CHECK(nvs_flash_erase()); + ESP_ERROR_CHECK(nvs_flash_init()); + } else { + ESP_ERROR_CHECK(ret); + } + + uart_handler_init(); + send_boot_event(); + cmd_dispatcher_init(); + + ESP_LOGI(TAG, "mcbluetooth-esp32 v%s ready", FW_VERSION); + + /* Main command loop */ + for (;;) { + char *line = uart_read_line(); + if (!line) { + continue; + } + + cJSON *root = cJSON_Parse(line); + if (!root) { + ESP_LOGE(TAG, "bad JSON: %.64s", line); + /* best-effort error -- no id available */ + uart_send_response("?", STATUS_ERROR, + cJSON_CreateString("invalid JSON")); + free(line); + continue; + } + + const cJSON *j_id = cJSON_GetObjectItem(root, "id"); + const cJSON *j_cmd = cJSON_GetObjectItem(root, "cmd"); + cJSON *j_params = cJSON_GetObjectItem(root, "params"); + + const char *id = cJSON_IsString(j_id) ? j_id->valuestring : "?"; + const char *cmd = cJSON_IsString(j_cmd) ? j_cmd->valuestring : NULL; + + if (!cmd) { + ESP_LOGE(TAG, "missing 'cmd' field"); + uart_send_response(id, STATUS_ERROR, + cJSON_CreateString("missing 'cmd' field")); + } else { + cmd_dispatch(id, cmd, j_params); + } + + cJSON_Delete(root); + free(line); + } +} diff --git a/firmware/main/personas.c b/firmware/main/personas.c new file mode 100644 index 0000000..d51057e --- /dev/null +++ b/firmware/main/personas.c @@ -0,0 +1,270 @@ +/* + * personas.c -- Device persona presets for BT test harness. + * + * Each persona configures the ESP32 to emulate a particular class of + * Bluetooth device (headset, keyboard, sensor, etc.) by setting the + * device name, IO capability, Class of Device, and advertising the + * expected GATT services. + */ + +#include "personas.h" +#include "protocol.h" +#include "uart_handler.h" +#include "bt_classic.h" +#include "bt_ble.h" + +#include "esp_bt_device.h" +#include "esp_gap_ble_api.h" +#include "esp_log.h" +#include "cJSON.h" + +#include + +static const char *TAG = "personas"; + +/* ------------------------------------------------------------------ */ +/* BT SIG base UUID: 0000xxxx-0000-1000-8000-00805f9b34fb */ +/* ------------------------------------------------------------------ */ + +/* Pre-built 128-bit UUID strings for standard services */ +static const char UUID_BATTERY[] = "0000180f-0000-1000-8000-00805f9b34fb"; +static const char UUID_DEVICE_INFO[] = "0000180a-0000-1000-8000-00805f9b34fb"; +static const char UUID_HID[] = "00001812-0000-1000-8000-00805f9b34fb"; +static const char UUID_ENV_SENSE[] = "0000181a-0000-1000-8000-00805f9b34fb"; +static const char UUID_PHONEBOOK[] = "00001130-0000-1000-8000-00805f9b34fb"; + +/* ------------------------------------------------------------------ */ +/* Per-persona service UUID lists (NULL-terminated) */ +/* ------------------------------------------------------------------ */ + +static const char *svc_headset[] = { UUID_BATTERY, UUID_DEVICE_INFO, NULL }; +static const char *svc_speaker[] = { UUID_BATTERY, UUID_DEVICE_INFO, NULL }; +static const char *svc_keyboard[] = { UUID_HID, UUID_BATTERY, NULL }; +static const char *svc_sensor[] = { UUID_ENV_SENSE, UUID_BATTERY, NULL }; +static const char *svc_phone[] = { UUID_PHONEBOOK, UUID_DEVICE_INFO, NULL }; +static const char *svc_bare[] = { NULL }; + +/* ------------------------------------------------------------------ */ +/* Persona table */ +/* ------------------------------------------------------------------ */ + +typedef struct { + const char *name; + const char *device_name; + const char *io_cap; + uint32_t device_class; + bool classic_enabled; + bool ble_enabled; + const char **service_uuids; +} persona_t; + +static const persona_t personas[] = { + { + .name = "headset", + .device_name = "BT Headset", + .io_cap = IO_CAP_NO_IO, + .device_class = 0x200404, /* Audio, Headset */ + .classic_enabled = true, + .ble_enabled = true, + .service_uuids = svc_headset, + }, + { + .name = "speaker", + .device_name = "BT Speaker", + .io_cap = IO_CAP_NO_IO, + .device_class = 0x200414, /* Audio, Loudspeaker */ + .classic_enabled = true, + .ble_enabled = true, + .service_uuids = svc_speaker, + }, + { + .name = "keyboard", + .device_name = "BT Keyboard", + .io_cap = IO_CAP_KEYBOARD_ONLY, + .device_class = 0x002540, /* Peripheral, Keyboard */ + .classic_enabled = true, + .ble_enabled = true, + .service_uuids = svc_keyboard, + }, + { + .name = "sensor", + .device_name = "Environment Sensor", + .io_cap = IO_CAP_NO_IO, + .device_class = 0, /* BLE only, no CoD */ + .classic_enabled = false, + .ble_enabled = true, + .service_uuids = svc_sensor, + }, + { + .name = "phone", + .device_name = "Test Phone", + .io_cap = IO_CAP_KEYBOARD_DISPLAY, + .device_class = 0x5A020C, /* Phone, Smart Phone */ + .classic_enabled = true, + .ble_enabled = true, + .service_uuids = svc_phone, + }, + { + .name = "bare", + .device_name = "ESP32-Test", + .io_cap = IO_CAP_DISPLAY_YESNO, + .device_class = 0x1F00, /* Uncategorized */ + .classic_enabled = true, + .ble_enabled = true, + .service_uuids = svc_bare, + }, +}; + +#define NUM_PERSONAS (sizeof(personas) / sizeof(personas[0])) + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +static const persona_t *find_persona(const char *name) +{ + for (size_t i = 0; i < NUM_PERSONAS; i++) { + if (strcmp(personas[i].name, name) == 0) { + return &personas[i]; + } + } + return NULL; +} + +/* + * Build a short-form UUID string ("0x180F") from a full 128-bit UUID. + * Caller must provide a buffer of at least 7 bytes. + */ +static void uuid128_to_short(const char *uuid128, char *out, size_t out_sz) +{ + /* Full form: "0000xxxx-0000-1000-8000-00805f9b34fb" */ + /* The 16-bit part sits at characters 4..7 */ + if (strlen(uuid128) >= 8) { + snprintf(out, out_sz, "0x%.4s", uuid128 + 4); + } else { + snprintf(out, out_sz, "0x????"); + } +} + +/* ------------------------------------------------------------------ */ +/* cmd_load_persona */ +/* ------------------------------------------------------------------ */ + +void cmd_load_persona(const char *id, cJSON *params) +{ + const cJSON *j_name = cJSON_GetObjectItem(params, "persona"); + if (!cJSON_IsString(j_name)) { + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", "missing 'persona' param"); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + const char *req_name = j_name->valuestring; + const persona_t *p = find_persona(req_name); + if (!p) { + cJSON *err = cJSON_CreateObject(); + cJSON_AddStringToObject(err, "error", "unknown persona"); + cJSON_AddStringToObject(err, "persona", req_name); + uart_send_response(id, STATUS_ERROR, err); + return; + } + + ESP_LOGI(TAG, "loading persona: %s (%s)", p->name, p->device_name); + + /* --- Set device name on both stacks --- */ + if (p->classic_enabled) { + esp_bt_dev_set_device_name(p->device_name); + } + if (p->ble_enabled) { + esp_ble_gap_set_device_name(p->device_name); + } + + /* --- IO capability (Classic BT SSP) --- */ + if (p->classic_enabled) { + bt_classic_set_io_cap(p->io_cap); + } + + /* --- Class of Device --- */ + if (p->classic_enabled && p->device_class != 0) { + bt_classic_set_device_class(p->device_class); + } + + /* --- Build response --- */ + cJSON *data = cJSON_CreateObject(); + cJSON_AddStringToObject(data, "persona", p->name); + cJSON_AddStringToObject(data, "device_name", p->device_name); + cJSON_AddStringToObject(data, "io_cap", p->io_cap); + cJSON_AddBoolToObject(data, "classic", p->classic_enabled); + cJSON_AddBoolToObject(data, "ble", p->ble_enabled); + + if (p->device_class != 0) { + char cod_str[12]; + snprintf(cod_str, sizeof(cod_str), "0x%06X", (unsigned)p->device_class); + cJSON_AddStringToObject(data, "device_class", cod_str); + } + + /* + * Include service UUIDs so the MCP client can set up GATT services + * via gatt_add_service / gatt_add_characteristic commands. + */ + cJSON *svc_arr = cJSON_CreateArray(); + for (const char **u = p->service_uuids; u && *u; u++) { + cJSON_AddItemToArray(svc_arr, cJSON_CreateString(*u)); + } + cJSON_AddItemToObject(data, "services", svc_arr); + + uart_send_response(id, STATUS_OK, data); +} + +/* ------------------------------------------------------------------ */ +/* cmd_list_personas */ +/* ------------------------------------------------------------------ */ + +void cmd_list_personas(const char *id, cJSON *params) +{ + (void)params; + + cJSON *arr = cJSON_CreateArray(); + + for (size_t i = 0; i < NUM_PERSONAS; i++) { + const persona_t *p = &personas[i]; + + cJSON *obj = cJSON_CreateObject(); + cJSON_AddStringToObject(obj, "name", p->name); + cJSON_AddStringToObject(obj, "device_name", p->device_name); + cJSON_AddStringToObject(obj, "io_cap", p->io_cap); + cJSON_AddBoolToObject(obj, "classic", p->classic_enabled); + cJSON_AddBoolToObject(obj, "ble", p->ble_enabled); + + if (p->device_class != 0) { + char cod_str[12]; + snprintf(cod_str, sizeof(cod_str), "0x%06X", (unsigned)p->device_class); + cJSON_AddStringToObject(obj, "device_class", cod_str); + } + + /* Short-form service UUIDs for readability */ + cJSON *svc_arr = cJSON_CreateArray(); + for (const char **u = p->service_uuids; u && *u; u++) { + char short_uuid[8]; + uuid128_to_short(*u, short_uuid, sizeof(short_uuid)); + cJSON_AddItemToArray(svc_arr, cJSON_CreateString(short_uuid)); + } + cJSON_AddItemToObject(obj, "services", svc_arr); + + cJSON_AddItemToArray(arr, obj); + } + + cJSON *data = cJSON_CreateObject(); + cJSON_AddItemToObject(data, "personas", arr); + uart_send_response(id, STATUS_OK, data); +} + +/* ------------------------------------------------------------------ */ +/* Init */ +/* ------------------------------------------------------------------ */ + +void personas_init(void) +{ + ESP_LOGI(TAG, "%d persona presets available", (int)NUM_PERSONAS); +} diff --git a/firmware/main/personas.h b/firmware/main/personas.h new file mode 100644 index 0000000..9f9481d --- /dev/null +++ b/firmware/main/personas.h @@ -0,0 +1,9 @@ +#pragma once + +#include "cJSON.h" + +void personas_init(void); + +/* Command handlers */ +void cmd_load_persona(const char *id, cJSON *params); +void cmd_list_personas(const char *id, cJSON *params); diff --git a/firmware/main/protocol.h b/firmware/main/protocol.h new file mode 100644 index 0000000..135a3ab --- /dev/null +++ b/firmware/main/protocol.h @@ -0,0 +1,67 @@ +#pragma once + +#include "driver/uart.h" + +/* UART configuration */ +#define PROTO_UART_NUM UART_NUM_1 +#define PROTO_BAUD_RATE 115200 +#define PROTO_TX_BUF_SIZE 4096 +#define PROTO_RX_BUF_SIZE 4096 +#define PROTO_MAX_LINE 2048 + +/* Message types */ +#define MSG_TYPE_CMD "cmd" +#define MSG_TYPE_RESP "resp" +#define MSG_TYPE_EVENT "event" + +/* Response status */ +#define STATUS_OK "ok" +#define STATUS_ERROR "error" + +/* System commands */ +#define CMD_PING "ping" +#define CMD_RESET "reset" +#define CMD_GET_INFO "get_info" +#define CMD_GET_STATUS "get_status" + +/* Configuration commands */ +#define CMD_CONFIGURE "configure" +#define CMD_LOAD_PERSONA "load_persona" +#define CMD_LIST_PERSONAS "list_personas" +#define CMD_CLASSIC_SET_SSP_MODE "classic_set_ssp_mode" + +/* Classic BT commands */ +#define CMD_CLASSIC_ENABLE "classic_enable" +#define CMD_CLASSIC_DISABLE "classic_disable" +#define CMD_CLASSIC_SET_DISCOVERABLE "classic_set_discoverable" +#define CMD_CLASSIC_PAIR_RESPOND "classic_pair_respond" + +/* BLE commands */ +#define CMD_BLE_ENABLE "ble_enable" +#define CMD_BLE_DISABLE "ble_disable" +#define CMD_BLE_ADVERTISE "ble_advertise" +#define CMD_BLE_SET_ADV_DATA "ble_set_adv_data" + +/* GATT commands */ +#define CMD_GATT_ADD_SERVICE "gatt_add_service" +#define CMD_GATT_ADD_CHARACTERISTIC "gatt_add_characteristic" +#define CMD_GATT_SET_VALUE "gatt_set_value" +#define CMD_GATT_NOTIFY "gatt_notify" +#define CMD_GATT_CLEAR "gatt_clear" + +/* Events */ +#define EVT_BOOT "boot" +#define EVT_PAIR_REQUEST "pair_request" +#define EVT_PAIR_COMPLETE "pair_complete" +#define EVT_CONNECT "connect" +#define EVT_DISCONNECT "disconnect" +#define EVT_GATT_READ "gatt_read" +#define EVT_GATT_WRITE "gatt_write" +#define EVT_GATT_SUBSCRIBE "gatt_subscribe" + +/* SSP IO capabilities */ +#define IO_CAP_DISPLAY_ONLY "display_only" +#define IO_CAP_DISPLAY_YESNO "display_yesno" +#define IO_CAP_KEYBOARD_ONLY "keyboard_only" +#define IO_CAP_NO_IO "no_io" +#define IO_CAP_KEYBOARD_DISPLAY "keyboard_display" diff --git a/firmware/main/uart_handler.c b/firmware/main/uart_handler.c new file mode 100644 index 0000000..73b0c60 --- /dev/null +++ b/firmware/main/uart_handler.c @@ -0,0 +1,136 @@ +#include "uart_handler.h" +#include "protocol.h" + +#include "driver/uart.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "cJSON.h" + +#include +#include + +static const char *TAG = "uart"; +static SemaphoreHandle_t tx_mutex; + +/* Pin assignments -- keep UART0 free for ESP-IDF console/logging */ +#define UART_TX_PIN GPIO_NUM_4 +#define UART_RX_PIN GPIO_NUM_5 + +void uart_handler_init(void) +{ + uart_config_t cfg = { + .baud_rate = PROTO_BAUD_RATE, + .data_bits = UART_DATA_8_BITS, + .parity = UART_PARITY_DISABLE, + .stop_bits = UART_STOP_BITS_1, + .flow_ctrl = UART_HW_FLOWCTRL_DISABLE, + .source_clk = UART_SCLK_DEFAULT, + }; + + ESP_ERROR_CHECK(uart_param_config(PROTO_UART_NUM, &cfg)); + ESP_ERROR_CHECK(uart_set_pin(PROTO_UART_NUM, UART_TX_PIN, UART_RX_PIN, + UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE)); + ESP_ERROR_CHECK(uart_driver_install(PROTO_UART_NUM, + PROTO_RX_BUF_SIZE, PROTO_TX_BUF_SIZE, + 0, NULL, 0)); + + tx_mutex = xSemaphoreCreateMutex(); + assert(tx_mutex); + + ESP_LOGI(TAG, "UART%d ready (TX=%d RX=%d @ %d baud)", + PROTO_UART_NUM, UART_TX_PIN, UART_RX_PIN, PROTO_BAUD_RATE); +} + +char *uart_read_line(void) +{ + static char buf[PROTO_MAX_LINE]; + int pos = 0; + + for (;;) { + uint8_t byte; + int n = uart_read_bytes(PROTO_UART_NUM, &byte, 1, portMAX_DELAY); + if (n <= 0) { + continue; + } + + if (byte == '\n') { + if (pos == 0) { + continue; /* skip empty lines */ + } + buf[pos] = '\0'; + return strdup(buf); + } + + if (byte == '\r') { + continue; /* ignore carriage returns */ + } + + if (pos < PROTO_MAX_LINE - 1) { + buf[pos++] = (char)byte; + } else { + /* line too long -- discard and reset */ + ESP_LOGE(TAG, "line overflow (%d bytes), discarding", pos); + pos = 0; + } + } +} + +void uart_send_line(const char *json_line) +{ + if (!json_line) { + return; + } + + xSemaphoreTake(tx_mutex, portMAX_DELAY); + + size_t len = strlen(json_line); + uart_write_bytes(PROTO_UART_NUM, json_line, len); + uart_write_bytes(PROTO_UART_NUM, "\n", 1); + + xSemaphoreGive(tx_mutex); +} + +void uart_send_response(const char *id, const char *status, cJSON *data) +{ + cJSON *root = cJSON_CreateObject(); + cJSON_AddStringToObject(root, "type", MSG_TYPE_RESP); + cJSON_AddStringToObject(root, "id", id); + cJSON_AddStringToObject(root, "status", status); + + if (data) { + cJSON_AddItemToObject(root, "data", data); + } else { + cJSON_AddObjectToObject(root, "data"); + } + + char *out = cJSON_PrintUnformatted(root); + uart_send_line(out); + + free(out); + cJSON_Delete(root); +} + +void uart_send_event(const char *event_name, cJSON *data) +{ + int64_t ts_ms = esp_timer_get_time() / 1000; + + cJSON *root = cJSON_CreateObject(); + cJSON_AddStringToObject(root, "type", MSG_TYPE_EVENT); + cJSON_AddStringToObject(root, "event", event_name); + + if (data) { + cJSON_AddItemToObject(root, "data", data); + } else { + cJSON_AddObjectToObject(root, "data"); + } + + cJSON_AddNumberToObject(root, "ts", (double)ts_ms); + + char *out = cJSON_PrintUnformatted(root); + uart_send_line(out); + + free(out); + cJSON_Delete(root); +} diff --git a/firmware/main/uart_handler.h b/firmware/main/uart_handler.h new file mode 100644 index 0000000..7240877 --- /dev/null +++ b/firmware/main/uart_handler.h @@ -0,0 +1,9 @@ +#pragma once + +#include "cJSON.h" + +void uart_handler_init(void); +char *uart_read_line(void); +void uart_send_line(const char *json_line); +void uart_send_response(const char *id, const char *status, cJSON *data); +void uart_send_event(const char *event_name, cJSON *data); diff --git a/firmware/sdkconfig.defaults b/firmware/sdkconfig.defaults new file mode 100644 index 0000000..4f241b6 --- /dev/null +++ b/firmware/sdkconfig.defaults @@ -0,0 +1,35 @@ +# Bluetooth (dual-mode: Classic + BLE via Bluedroid) +CONFIG_BT_ENABLED=y +CONFIG_BT_BLUEDROID_ENABLED=y +CONFIG_BT_CLASSIC_ENABLED=y +CONFIG_BT_BLE_ENABLED=y +CONFIG_BT_A2DP_ENABLE=n +CONFIG_BT_SPP_ENABLED=y +CONFIG_BT_SSP_ENABLED=y + +# GAP & GATTS +CONFIG_BT_GATTS_ENABLE=y +CONFIG_BT_GATTC_ENABLE=n + +# Bluetooth controller +CONFIG_BTDM_CTRL_MODE_BTDM=y + +# UART +CONFIG_ESP_CONSOLE_UART_NUM=0 + +# Stack sizes (Bluetooth needs generous stacks) +CONFIG_BT_BTU_TASK_STACK_SIZE=8192 +CONFIG_BTDM_CTRL_HCI_MODE_VHCI=y + +# NVS (for bonding storage) +CONFIG_NVS_ENABLED=y + +# Logging — keep it reasonable +CONFIG_LOG_DEFAULT_LEVEL_INFO=y +CONFIG_LOG_MAXIMUM_LEVEL_DEBUG=y + +# Partition table +CONFIG_PARTITION_TABLE_SINGLE_APP=y + +# FreeRTOS +CONFIG_FREERTOS_HZ=1000 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..61002f3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,51 @@ +[project] +name = "mcbluetooth-esp32" +version = "2026.02.02" +description = "ESP32 Bluetooth test harness MCP server — UART-controlled peripheral for E2E Bluetooth testing" +requires-python = ">=3.11" +license = {text = "MIT"} +authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}] +keywords = ["bluetooth", "esp32", "mcp", "testing", "ble", "ssp"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Testing", + "Topic :: System :: Hardware", +] + +dependencies = [ + "fastmcp>=2.14.4", + "pyserial-asyncio>=0.6", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.24", + "ruff>=0.8", +] + +[project.scripts] +mcbluetooth-esp32 = "mcbluetooth_esp32:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/mcbluetooth_esp32"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP"] +ignore = ["E501"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/src/mcbluetooth_esp32/__init__.py b/src/mcbluetooth_esp32/__init__.py new file mode 100644 index 0000000..0f9728c --- /dev/null +++ b/src/mcbluetooth_esp32/__init__.py @@ -0,0 +1,3 @@ +from mcbluetooth_esp32.server import main, mcp + +__all__ = ["mcp", "main"] diff --git a/src/mcbluetooth_esp32/event_queue.py b/src/mcbluetooth_esp32/event_queue.py new file mode 100644 index 0000000..39795b3 --- /dev/null +++ b/src/mcbluetooth_esp32/event_queue.py @@ -0,0 +1,167 @@ +"""Async event history and waiting system for ESP32 events.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass + +from .protocol import Event + + +@dataclass +class _EventWaiter: + """A pending wait condition with its associated future.""" + + match_fn: Callable[[Event], bool] + future: asyncio.Future[Event] + + +class EventQueue: + """Accumulates ESP32 events in a bounded history and allows callers to wait for specific events. + + Thread-safe for push operations via an asyncio lock. All async methods + must be called from the same event loop. + """ + + def __init__(self, max_events: int = 1000) -> None: + self._events: list[Event] = [] + self._max_events: int = max_events + self._waiters: list[_EventWaiter] = [] + self._lock: asyncio.Lock = asyncio.Lock() + + def push(self, event: Event) -> None: + """Append an event to history and resolve any matching waiters. + + Trims oldest events when the history exceeds *max_events*. + """ + self._events.append(event) + + # Trim oldest if over capacity + overflow = len(self._events) - self._max_events + if overflow > 0: + del self._events[:overflow] + + # Resolve matching waiters (iterate a copy so removal is safe) + resolved: list[_EventWaiter] = [] + for waiter in self._waiters: + if waiter.future.done(): + resolved.append(waiter) + continue + try: + if waiter.match_fn(event): + waiter.future.set_result(event) + resolved.append(waiter) + except Exception: + # Bad match function — don't let it break the queue + resolved.append(waiter) + + for waiter in resolved: + try: + self._waiters.remove(waiter) + except ValueError: + pass + + async def wait_for( + self, + event_name: str | None = None, + match: Callable[[Event], bool] | None = None, + timeout: float = 30.0, + ) -> Event: + """Wait for an event matching the given criteria. + + Args: + event_name: If provided, match events where ``event.event == event_name``. + match: Optional custom predicate. If both *event_name* and *match* + are given they are combined with AND logic. + timeout: Seconds to wait before raising ``asyncio.TimeoutError``. + + Returns: + The first matching ``Event``. + + Raises: + asyncio.TimeoutError: If no matching event arrives within *timeout*. + ValueError: If neither *event_name* nor *match* is provided. + """ + if event_name is None and match is None: + raise ValueError("at least one of event_name or match must be provided") + + match_fn = _build_match_fn(event_name, match) + + # Check existing history (most recent first) for an immediate match + async with self._lock: + for event in reversed(self._events): + try: + if match_fn(event): + return event + except Exception: + continue + + # No existing match — register a waiter + loop = asyncio.get_running_loop() + future: asyncio.Future[Event] = loop.create_future() + waiter = _EventWaiter(match_fn=match_fn, future=future) + + async with self._lock: + self._waiters.append(waiter) + + try: + return await asyncio.wait_for(future, timeout=timeout) + except TimeoutError: + # Clean up the waiter on timeout + try: + self._waiters.remove(waiter) + except ValueError: + pass + raise + + def get_events( + self, + event_name: str | None = None, + limit: int = 50, + since_ts: int | None = None, + ) -> list[Event]: + """Return recent events from history, optionally filtered. + + Args: + event_name: Only include events with this name. + limit: Maximum number of events to return. + since_ts: Only include events with ``ts >= since_ts`` (millisecond timestamp). + + Returns: + A list of matching events, most recent last, capped at *limit*. + """ + filtered: list[Event] = [] + for event in self._events: + if event_name is not None and event.event != event_name: + continue + if since_ts is not None and (event.ts is None or event.ts < since_ts): + continue + filtered.append(event) + + # Return the most recent `limit` entries + if len(filtered) > limit: + return filtered[-limit:] + return filtered + + def clear(self) -> None: + """Clear the event history. Active waiters are not cancelled.""" + self._events.clear() + + def __len__(self) -> int: + """Return the number of stored events.""" + return len(self._events) + + +def _build_match_fn( + event_name: str | None, + match: Callable[[Event], bool] | None, +) -> Callable[[Event], bool]: + """Combine event_name and custom match predicate into a single callable.""" + if event_name is not None and match is not None: + return lambda e: e.event == event_name and match(e) + if event_name is not None: + return lambda e: e.event == event_name + # match is guaranteed non-None by the caller's validation + assert match is not None + return match diff --git a/src/mcbluetooth_esp32/protocol.py b/src/mcbluetooth_esp32/protocol.py new file mode 100644 index 0000000..07a5b98 --- /dev/null +++ b/src/mcbluetooth_esp32/protocol.py @@ -0,0 +1,221 @@ +"""Python protocol layer mirroring the ESP32 firmware's protocol.h — NDJSON over UART.""" + +from __future__ import annotations + +import json +import threading +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class MsgType(StrEnum): + CMD = "cmd" + RESP = "resp" + EVENT = "event" + + +class Status(StrEnum): + OK = "ok" + ERROR = "error" + + +class IOCapability(StrEnum): + DISPLAY_ONLY = "display_only" + DISPLAY_YESNO = "display_yesno" + KEYBOARD_ONLY = "keyboard_only" + NO_IO = "no_io" + KEYBOARD_DISPLAY = "keyboard_display" + + +class Transport(StrEnum): + CLASSIC = "classic" + BLE = "ble" + BOTH = "both" + + +# --------------------------------------------------------------------------- +# Command strings (mirror firmware #defines) +# --------------------------------------------------------------------------- + +# System +CMD_PING = "ping" +CMD_RESET = "reset" +CMD_GET_INFO = "get_info" +CMD_GET_STATUS = "get_status" + +# Configuration +CMD_CONFIGURE = "configure" +CMD_LOAD_PERSONA = "load_persona" +CMD_LIST_PERSONAS = "list_personas" +CMD_CLASSIC_SET_SSP_MODE = "classic_set_ssp_mode" + +# Classic BT +CMD_CLASSIC_ENABLE = "classic_enable" +CMD_CLASSIC_DISABLE = "classic_disable" +CMD_CLASSIC_SET_DISCOVERABLE = "classic_set_discoverable" +CMD_CLASSIC_PAIR_RESPOND = "classic_pair_respond" + +# BLE +CMD_BLE_ENABLE = "ble_enable" +CMD_BLE_DISABLE = "ble_disable" +CMD_BLE_ADVERTISE = "ble_advertise" +CMD_BLE_SET_ADV_DATA = "ble_set_adv_data" + +# GATT +CMD_GATT_ADD_SERVICE = "gatt_add_service" +CMD_GATT_ADD_CHARACTERISTIC = "gatt_add_characteristic" +CMD_GATT_SET_VALUE = "gatt_set_value" +CMD_GATT_NOTIFY = "gatt_notify" +CMD_GATT_CLEAR = "gatt_clear" + +# Events +EVT_BOOT = "boot" +EVT_PAIR_REQUEST = "pair_request" +EVT_PAIR_COMPLETE = "pair_complete" +EVT_CONNECT = "connect" +EVT_DISCONNECT = "disconnect" +EVT_GATT_READ = "gatt_read" +EVT_GATT_WRITE = "gatt_write" +EVT_GATT_SUBSCRIBE = "gatt_subscribe" + +# --------------------------------------------------------------------------- +# Protocol constants +# --------------------------------------------------------------------------- + +BAUD_RATE: int = 115200 +MAX_LINE_LENGTH: int = 2048 + + +# --------------------------------------------------------------------------- +# Monotonic ID generator (thread-safe) +# --------------------------------------------------------------------------- + +_id_counter: int = 0 +_id_lock = threading.Lock() + + +def _next_id() -> str: + """Return a monotonically increasing string ID like '1', '2', ...""" + global _id_counter + with _id_lock: + _id_counter += 1 + return str(_id_counter) + + +# --------------------------------------------------------------------------- +# Dataclasses +# --------------------------------------------------------------------------- + + +@dataclass(slots=True) +class Command: + type: MsgType + id: str + cmd: str + params: dict[str, Any] = field(default_factory=dict) + + def to_json(self) -> str: + """Serialize to a single NDJSON line (no trailing newline).""" + obj: dict[str, Any] = {"type": self.type, "id": self.id, "cmd": self.cmd} + if self.params: + obj["params"] = self.params + return json.dumps(obj, separators=(",", ":")) + + +@dataclass(slots=True) +class Response: + type: MsgType + id: str + status: Status + data: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_json(cls, line: str) -> Response: + """Parse a JSON line known to be a response.""" + obj = json.loads(line) + return cls( + type=MsgType(obj["type"]), + id=obj["id"], + status=Status(obj["status"]), + data=obj.get("data", {}), + ) + + +@dataclass(slots=True) +class Event: + type: MsgType + event: str + data: dict[str, Any] = field(default_factory=dict) + ts: int | None = None + + @classmethod + def from_json(cls, line: str) -> Event: + """Parse a JSON line known to be an event.""" + obj = json.loads(line) + return cls( + type=MsgType(obj["type"]), + event=obj["event"], + data=obj.get("data", {}), + ts=obj.get("ts"), + ) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def build_command( + cmd: str, params: dict[str, Any] | None = None, cmd_id: str | None = None +) -> Command: + """Build a Command with an auto-generated monotonic ID if none is provided.""" + return Command( + type=MsgType.CMD, + id=cmd_id or _next_id(), + cmd=cmd, + params=params or {}, + ) + + +def parse_message(line: str) -> Command | Response | Event: + """Parse any NDJSON line into the appropriate message type. + + Raises ValueError if the line is not valid JSON or has an unknown message type. + """ + try: + obj = json.loads(line) + except json.JSONDecodeError as exc: + raise ValueError(f"invalid JSON: {exc}") from exc + + msg_type = obj.get("type") + + if msg_type == MsgType.CMD: + return Command( + type=MsgType.CMD, + id=obj["id"], + cmd=obj["cmd"], + params=obj.get("params", {}), + ) + + if msg_type == MsgType.RESP: + return Response( + type=MsgType.RESP, + id=obj["id"], + status=Status(obj["status"]), + data=obj.get("data", {}), + ) + + if msg_type == MsgType.EVENT: + return Event( + type=MsgType.EVENT, + event=obj["event"], + data=obj.get("data", {}), + ts=obj.get("ts"), + ) + + raise ValueError(f"unknown message type: {msg_type!r}") diff --git a/src/mcbluetooth_esp32/resources.py b/src/mcbluetooth_esp32/resources.py new file mode 100644 index 0000000..b3a9ae1 --- /dev/null +++ b/src/mcbluetooth_esp32/resources.py @@ -0,0 +1,82 @@ +"""MCP resources -- read-only state queries exposed as esp32:// URIs.""" + +from __future__ import annotations + +import json + +from fastmcp import FastMCP + +from .serial_client import NotConnected, get_client + +# Static persona catalog. Mirrors the presets defined in the ESP32 firmware. +_PERSONAS: dict[str, dict] = { + "headset": { + "name": "ESP32-Headset", + "io_cap": "no_io", + "transport": "classic", + "services": ["handsfree", "a2dp_sink"], + }, + "speaker": { + "name": "ESP32-Speaker", + "io_cap": "no_io", + "transport": "classic", + "services": ["a2dp_sink"], + }, + "keyboard": { + "name": "ESP32-Keyboard", + "io_cap": "keyboard_display", + "transport": "ble", + "services": ["hid"], + }, + "sensor": { + "name": "ESP32-Sensor", + "io_cap": "no_io", + "transport": "ble", + "services": ["battery", "environmental_sensing"], + }, + "phone": { + "name": "ESP32-Phone", + "io_cap": "display_yesno", + "transport": "both", + "services": ["gap", "gatt"], + }, + "bare": { + "name": "ESP32-BT", + "io_cap": "no_io", + "transport": "both", + "services": [], + }, +} + + +def _event_to_dict(e) -> dict: + return {"event": e.event, "data": e.data, "ts": e.ts} + + +def register_resources(mcp: FastMCP) -> None: + @mcp.resource("esp32://status") + async def device_status() -> str: + """Device connection status and Bluetooth state.""" + try: + client = get_client() + resp = await client.send_command("get_status") + data = {"connected": True, **resp.data} + except NotConnected: + data = {"connected": False} + return json.dumps(data) + + @mcp.resource("esp32://events") + async def recent_events() -> str: + """Recent event history (last 50 events).""" + try: + client = get_client() + events = client.event_queue.get_events(limit=50) + data = [_event_to_dict(e) for e in events] + except NotConnected: + data = [] + return json.dumps(data) + + @mcp.resource("esp32://personas") + async def available_personas() -> str: + """Available device personas and their configurations.""" + return json.dumps(_PERSONAS) diff --git a/src/mcbluetooth_esp32/serial_client.py b/src/mcbluetooth_esp32/serial_client.py new file mode 100644 index 0000000..343170f --- /dev/null +++ b/src/mcbluetooth_esp32/serial_client.py @@ -0,0 +1,297 @@ +"""Async serial I/O client for ESP32 UART communication. + +Handles sending commands, correlating responses by ID, and routing +unsolicited events to the shared EventQueue. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from dataclasses import dataclass, field + +import serial_asyncio + +from .event_queue import EventQueue +from .protocol import ( + BAUD_RATE, + MAX_LINE_LENGTH, + Event, + Response, + build_command, + parse_message, +) + +log = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Exceptions +# --------------------------------------------------------------------------- + + +class SerialClientError(Exception): + """Base exception for serial client errors.""" + + +class CommandTimeout(SerialClientError): + """Raised when a command does not receive a response within the timeout.""" + + +class NotConnected(SerialClientError): + """Raised when an operation requires a connected client but none exists.""" + + +# --------------------------------------------------------------------------- +# SerialClient +# --------------------------------------------------------------------------- + + +@dataclass +class SerialClient: + """Async serial transport that speaks NDJSON to the ESP32 firmware. + + Use :func:`init_client` to create the singleton, then call + :meth:`connect` to open the serial port and start the background reader. + """ + + port: str + baudrate: int = BAUD_RATE + timeout: float = 5.0 + event_queue: EventQueue = field(default_factory=EventQueue) + + # Internal state (not constructor args) + _reader: asyncio.StreamReader | None = field(default=None, init=False, repr=False) + _writer: asyncio.StreamWriter | None = field(default=None, init=False, repr=False) + _pending: dict[str, asyncio.Future[Response]] = field( + default_factory=dict, init=False, repr=False + ) + _read_task: asyncio.Task[None] | None = field(default=None, init=False, repr=False) + _connected: bool = field(default=False, init=False, repr=False) + _lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False) + + # ------------------------------------------------------------------ + # Connection lifecycle + # ------------------------------------------------------------------ + + async def connect(self) -> None: + """Open the serial port and start the background reader loop.""" + if self._connected: + log.warning("Already connected to %s", self.port) + return + + log.info("Opening serial connection: %s @ %d baud", self.port, self.baudrate) + self._reader, self._writer = await serial_asyncio.open_serial_connection( + url=self.port, baudrate=self.baudrate + ) + self._connected = True + self._read_task = asyncio.create_task(self._read_loop(), name="esp32-serial-reader") + log.info("Connected to %s", self.port) + + async def disconnect(self) -> None: + """Close the serial connection and cancel the background reader.""" + if not self._connected: + return + + log.info("Disconnecting from %s", self.port) + self._connected = False + + # Cancel the reader task + if self._read_task is not None: + self._read_task.cancel() + try: + await self._read_task + except asyncio.CancelledError: + pass + self._read_task = None + + # Close the writer (which closes the underlying transport) + if self._writer is not None: + try: + self._writer.close() + await self._writer.wait_closed() + except Exception: + pass # transport may already be gone + self._writer = None + + self._reader = None + + # Cancel any pending command futures + for cmd_id, future in self._pending.items(): + if not future.done(): + future.cancel() + log.debug("Cancelled pending command %s", cmd_id) + self._pending.clear() + + log.info("Disconnected") + + @property + def connected(self) -> bool: + """Whether the serial port is currently open and the reader is running.""" + return self._connected + + # ------------------------------------------------------------------ + # Command / response + # ------------------------------------------------------------------ + + async def send_command( + self, cmd: str, params: dict | None = None, timeout: float | None = None + ) -> Response: + """Send a command to the ESP32 and wait for the correlated response. + + Args: + cmd: Command name (e.g. ``"ping"``, ``"configure"``). + params: Optional parameter dict sent with the command. + timeout: Per-command timeout override; falls back to ``self.timeout``. + + Returns: + The :class:`Response` from the ESP32 firmware. + + Raises: + NotConnected: If the serial connection is not open. + CommandTimeout: If no response arrives within the timeout window. + """ + if not self._connected: + raise NotConnected("Serial client not connected") + + command = build_command(cmd, params) + loop = asyncio.get_running_loop() + future: asyncio.Future[Response] = loop.create_future() + self._pending[command.id] = future + + log.debug("TX cmd=%s id=%s params=%s", cmd, command.id, params) + await self._send_line(command.to_json()) + + effective_timeout = timeout if timeout is not None else self.timeout + try: + response = await asyncio.wait_for(future, timeout=effective_timeout) + except TimeoutError: + self._pending.pop(command.id, None) + raise CommandTimeout( + f"Command {cmd!r} (id={command.id}) timed out after {effective_timeout}s" + ) from None + else: + self._pending.pop(command.id, None) + log.debug( + "RX resp id=%s status=%s data=%s", response.id, response.status, response.data + ) + return response + + # ------------------------------------------------------------------ + # Background reader + # ------------------------------------------------------------------ + + async def _read_loop(self) -> None: + """Read NDJSON lines from serial and dispatch responses / events.""" + assert self._reader is not None + log.debug("Reader loop started") + + try: + while self._connected: + try: + raw = await self._reader.readline() + except asyncio.CancelledError: + raise + except Exception as exc: + if self._connected: + log.error("Serial read error: %s", exc) + break + + if not raw: + # EOF — connection closed + if self._connected: + log.warning("Serial connection closed (EOF)") + break + + line = raw.decode("utf-8", errors="replace").strip() + if not line: + continue + + if len(line) > MAX_LINE_LENGTH: + log.warning("Dropping oversized line (%d bytes)", len(line)) + continue + + try: + msg = parse_message(line) + except (ValueError, KeyError, json.JSONDecodeError) as exc: + log.warning("Unparseable line: %s — %s", line[:120], exc) + continue + + if isinstance(msg, Response): + future = self._pending.get(msg.id) + if future is not None and not future.done(): + future.set_result(msg) + else: + log.debug("Orphan response id=%s (no pending future)", msg.id) + + elif isinstance(msg, Event): + log.debug("Event: %s data=%s", msg.event, msg.data) + self.event_queue.push(msg) + + else: + # Commands from the ESP32 are unexpected but not fatal + log.debug("Received unexpected message type: %s", msg.type) + + except asyncio.CancelledError: + log.debug("Reader loop cancelled") + finally: + # Mark disconnected if the loop exits for any reason other than + # an explicit disconnect() call (which sets _connected=False first). + if self._connected: + self._connected = False + log.warning("Reader loop exited unexpectedly — marking disconnected") + + # ------------------------------------------------------------------ + # Low-level write + # ------------------------------------------------------------------ + + async def _send_line(self, line: str) -> None: + """Write a single NDJSON line to the serial port.""" + if self._writer is None: + raise NotConnected("No serial writer available") + + async with self._lock: + self._writer.write(line.encode("utf-8") + b"\n") + await self._writer.drain() + + +# --------------------------------------------------------------------------- +# Singleton access +# --------------------------------------------------------------------------- + +_client: SerialClient | None = None + + +async def get_client() -> SerialClient: + """Get the singleton serial client. + + Raises: + NotConnected: If the client has not been initialised or is not connected. + """ + if _client is None: + raise NotConnected("Serial client not initialized. Call init_client() first.") + if not _client.connected: + raise NotConnected("Serial client not connected. Call esp32_connect tool first.") + return _client + + +def get_client_or_none() -> SerialClient | None: + """Return the singleton client instance, or ``None`` if not yet initialised. + + This does **not** check the connection state -- useful for tools that need + to inspect the client before it is connected (e.g. ``esp32_connect``). + """ + return _client + + +def init_client(port: str, baudrate: int = BAUD_RATE, timeout: float = 5.0) -> SerialClient: + """Create (or replace) the singleton serial client. + + Does **not** open the connection — call :meth:`SerialClient.connect` + separately (typically via the ``esp32_connect`` MCP tool). + """ + global _client + _client = SerialClient(port=port, baudrate=baudrate, timeout=timeout) + log.info("Initialized serial client for %s @ %d baud", port, baudrate) + return _client diff --git a/src/mcbluetooth_esp32/server.py b/src/mcbluetooth_esp32/server.py new file mode 100644 index 0000000..b594abd --- /dev/null +++ b/src/mcbluetooth_esp32/server.py @@ -0,0 +1,74 @@ +"""FastMCP server for ESP32 Bluetooth test harness.""" + +from __future__ import annotations + +import os + +from fastmcp import FastMCP + +from .resources import register_resources +from .serial_client import init_client +from .tools import ble, classic, configure, connection, events, persona + +mcp = FastMCP( + name="mcbluetooth-esp32", + instructions="""ESP32 Bluetooth test harness MCP server. + +Controls an ESP32 dev board via UART/serial, exposing it as a configurable +Bluetooth peripheral for E2E testing with mcbluetooth (Linux BlueZ). + +## Resources (live state) +- esp32://status - Device status (connected, BT state) +- esp32://events - Recent event history +- esp32://personas - Available device personas + +## Tools +Connection: esp32_connect, esp32_disconnect, esp32_status, esp32_ping, esp32_reset +Configure: esp32_configure, esp32_set_ssp_mode +Classic BT: esp32_classic_enable, esp32_classic_set_discoverable, esp32_classic_pair_respond +BLE: esp32_ble_advertise, esp32_gatt_add_service, esp32_gatt_add_characteristic, esp32_gatt_set_value, esp32_gatt_notify +Personas: esp32_load_persona, esp32_list_personas +Events: esp32_get_events, esp32_wait_event, esp32_clear_events + +## Usage +The ESP32 must be connected via USB/serial. Default port: /dev/ttyUSB0 +Set ESP32_SERIAL_PORT environment variable to override. +""", +) + +# Register resources (live state) +register_resources(mcp) + +# Register tool modules +connection.register_tools(mcp) +configure.register_tools(mcp) +classic.register_tools(mcp) +ble.register_tools(mcp) +persona.register_tools(mcp) +events.register_tools(mcp) + + +def main(): + """Entry point for the mcbluetooth-esp32 MCP server.""" + try: + from importlib.metadata import version + + package_version = version("mcbluetooth-esp32") + except Exception: + package_version = "dev" + + port = os.environ.get("ESP32_SERIAL_PORT", "/dev/ttyUSB0") + baudrate = int(os.environ.get("ESP32_SERIAL_BAUD", "115200")) + + print(f"📡 mcbluetooth-esp32 v{package_version}") + print(" ESP32 Bluetooth Test Harness") + print(f" Serial: {port} @ {baudrate} baud") + + # Pre-initialize client (actual connection happens via esp32_connect tool) + init_client(port=port, baudrate=baudrate) + + mcp.run() + + +if __name__ == "__main__": + main() diff --git a/src/mcbluetooth_esp32/tools/__init__.py b/src/mcbluetooth_esp32/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mcbluetooth_esp32/tools/ble.py b/src/mcbluetooth_esp32/tools/ble.py new file mode 100644 index 0000000..a548eec --- /dev/null +++ b/src/mcbluetooth_esp32/tools/ble.py @@ -0,0 +1,257 @@ +"""BLE advertising and GATT server tools for the ESP32 test harness.""" + +from __future__ import annotations + +from typing import Any + +from fastmcp import FastMCP + +from ..protocol import ( + CMD_BLE_ADVERTISE, + CMD_BLE_DISABLE, + CMD_BLE_ENABLE, + CMD_BLE_SET_ADV_DATA, + CMD_GATT_ADD_CHARACTERISTIC, + CMD_GATT_ADD_SERVICE, + CMD_GATT_CLEAR, + CMD_GATT_NOTIFY, + CMD_GATT_SET_VALUE, + Status, # noqa: F401 +) +from ..serial_client import get_client + + +def register_tools(mcp: FastMCP) -> None: + """Register BLE and GATT tools with the MCP server.""" + + @mcp.tool() + async def esp32_ble_enable() -> dict[str, Any]: + """Enable BLE on the ESP32. + + Initializes the BLE stack so the device can advertise + and host GATT services. + + Returns: + Status dict from the ESP32. + """ + try: + client = await get_client() + response = await client.send_command(CMD_BLE_ENABLE) + return response.data + except Exception as exc: + return {"error": str(exc)} + + @mcp.tool() + async def esp32_ble_disable() -> dict[str, Any]: + """Disable BLE on the ESP32. + + Stops advertising, tears down GATT services, and shuts + down the BLE stack. + + Returns: + Status dict from the ESP32. + """ + try: + client = await get_client() + response = await client.send_command(CMD_BLE_DISABLE) + return response.data + except Exception as exc: + return {"error": str(exc)} + + @mcp.tool() + async def esp32_ble_advertise( + enable: bool, + interval_ms: int = 100, + ) -> dict[str, Any]: + """Start or stop BLE advertising. + + When enabled, the ESP32 broadcasts advertisement packets so + nearby scanners can discover it. + + Args: + enable: True to start advertising, False to stop. + interval_ms: Advertising interval in milliseconds. + + Returns: + Status dict from the ESP32. + """ + try: + client = await get_client() + params: dict[str, Any] = { + "enable": enable, + "interval_ms": interval_ms, + } + response = await client.send_command(CMD_BLE_ADVERTISE, params) + return response.data + except Exception as exc: + return {"error": str(exc)} + + @mcp.tool() + async def esp32_ble_set_adv_data( + name: str | None = None, + service_uuids: list[str] | None = None, + manufacturer_data: str | None = None, + ) -> dict[str, Any]: + """Set BLE advertising data. + + Configures what the ESP32 includes in its advertisement + packets. Call this before starting advertising. + + Args: + name: Device name to advertise. + service_uuids: List of service UUIDs to include in the + advertisement payload. + manufacturer_data: Hex string of manufacturer-specific data + (e.g., "ff0002" for company ID 0x02FF). + + Returns: + Status dict from the ESP32. + """ + try: + client = await get_client() + params: dict[str, Any] = {} + if name is not None: + params["name"] = name + if service_uuids is not None: + params["service_uuids"] = service_uuids + if manufacturer_data is not None: + params["manufacturer_data"] = manufacturer_data + response = await client.send_command(CMD_BLE_SET_ADV_DATA, params) + return response.data + except Exception as exc: + return {"error": str(exc)} + + @mcp.tool() + async def esp32_gatt_add_service( + uuid: str, + primary: bool = True, + ) -> dict[str, Any]: + """Add a GATT service to the ESP32. + + Creates a new GATT service that can hold characteristics. + The ESP32 assigns a handle to the service which is needed + when adding characteristics. + + Args: + uuid: Service UUID (e.g., "180F" for Battery Service or + full 128-bit form). + primary: Whether this is a primary service. + + Returns: + Dict containing the assigned service handle. + """ + try: + client = await get_client() + params: dict[str, Any] = { + "uuid": uuid, + "primary": primary, + } + response = await client.send_command(CMD_GATT_ADD_SERVICE, params) + return response.data + except Exception as exc: + return {"error": str(exc)} + + @mcp.tool() + async def esp32_gatt_add_characteristic( + service_handle: int, + uuid: str, + properties: list[str], + value: str | None = None, + ) -> dict[str, Any]: + """Add a characteristic to a GATT service. + + The characteristic is attached to the service identified by + *service_handle* (returned by esp32_gatt_add_service). + + Args: + service_handle: Handle of the parent service. + uuid: Characteristic UUID. + properties: List of property strings, e.g. + ["read", "write", "notify", "indicate"]. + value: Optional initial value as a hex string + (e.g., "e803" for 1000 little-endian). + + Returns: + Dict containing the assigned characteristic handle. + """ + try: + client = await get_client() + params: dict[str, Any] = { + "service_handle": service_handle, + "uuid": uuid, + "properties": properties, + } + if value is not None: + params["value"] = value + response = await client.send_command(CMD_GATT_ADD_CHARACTERISTIC, params) + return response.data + except Exception as exc: + return {"error": str(exc)} + + @mcp.tool() + async def esp32_gatt_set_value( + char_handle: int, + value: str, + ) -> dict[str, Any]: + """Set the value of a GATT characteristic. + + Updates the stored value that BLE clients will read. Also + prepares the value for any subsequent notification. + + Args: + char_handle: Handle of the characteristic to update. + value: New value as a hex string (e.g., "e803" for + temperature 25.0C encoded as little-endian uint16). + + Returns: + Status dict from the ESP32. + """ + try: + client = await get_client() + params: dict[str, Any] = { + "char_handle": char_handle, + "value": value, + } + response = await client.send_command(CMD_GATT_SET_VALUE, params) + return response.data + except Exception as exc: + return {"error": str(exc)} + + @mcp.tool() + async def esp32_gatt_notify(char_handle: int) -> dict[str, Any]: + """Send a notification for a characteristic. + + Pushes the current characteristic value to all BLE clients + that have subscribed to notifications on this characteristic. + + Args: + char_handle: Handle of the characteristic to notify on. + + Returns: + Status dict from the ESP32. + """ + try: + client = await get_client() + params: dict[str, Any] = {"char_handle": char_handle} + response = await client.send_command(CMD_GATT_NOTIFY, params) + return response.data + except Exception as exc: + return {"error": str(exc)} + + @mcp.tool() + async def esp32_gatt_clear() -> dict[str, Any]: + """Clear all GATT services and characteristics. + + Removes every service and characteristic from the GATT + server, resetting it to a blank state. Useful before + loading a new device persona or test configuration. + + Returns: + Status dict from the ESP32. + """ + try: + client = await get_client() + response = await client.send_command(CMD_GATT_CLEAR) + return response.data + except Exception as exc: + return {"error": str(exc)} diff --git a/src/mcbluetooth_esp32/tools/classic.py b/src/mcbluetooth_esp32/tools/classic.py new file mode 100644 index 0000000..11bd624 --- /dev/null +++ b/src/mcbluetooth_esp32/tools/classic.py @@ -0,0 +1,124 @@ +"""Classic Bluetooth tools for the ESP32 test harness.""" + +from __future__ import annotations + +from typing import Any + +from fastmcp import FastMCP + +from ..protocol import ( + CMD_CLASSIC_DISABLE, + CMD_CLASSIC_ENABLE, + CMD_CLASSIC_PAIR_RESPOND, + CMD_CLASSIC_SET_DISCOVERABLE, + Status, +) +from ..serial_client import get_client + + +def register_tools(mcp: FastMCP) -> None: + """Register Classic Bluetooth tools with the MCP server.""" + + @mcp.tool() + async def esp32_classic_enable() -> dict[str, Any]: + """Enable Classic Bluetooth on the ESP32. + + Activates the BR/EDR (Classic) Bluetooth stack on the ESP32 so it + can be discovered and paired by other devices. + + Returns: + Response data from the ESP32 including current BT state. + """ + try: + client = await get_client() + response = await client.send_command(CMD_CLASSIC_ENABLE) + if response.status == Status.OK: + return {"status": "ok", **response.data} + return {"status": "error", "error": response.data.get("error", "unknown error")} + except Exception as exc: + return {"error": str(exc)} + + @mcp.tool() + async def esp32_classic_disable() -> dict[str, Any]: + """Disable Classic Bluetooth on the ESP32. + + Shuts down the BR/EDR stack. The device will no longer be + discoverable or accept Classic BT connections. + + Returns: + Response data from the ESP32 confirming BT is disabled. + """ + try: + client = await get_client() + response = await client.send_command(CMD_CLASSIC_DISABLE) + if response.status == Status.OK: + return {"status": "ok", **response.data} + return {"status": "error", "error": response.data.get("error", "unknown error")} + except Exception as exc: + return {"error": str(exc)} + + @mcp.tool() + async def esp32_classic_set_discoverable( + discoverable: bool, + timeout: int = 0, + ) -> dict[str, Any]: + """Set Classic BT discoverability on the ESP32. + + Controls whether the ESP32 is visible to inquiry scans from + other Bluetooth devices. + + Args: + discoverable: True to make the ESP32 discoverable, False to hide it. + timeout: How long to remain discoverable in seconds. 0 means forever. + + Returns: + Response data confirming the new discoverable state. + """ + try: + client = await get_client() + params = {"discoverable": discoverable, "timeout": timeout} + response = await client.send_command(CMD_CLASSIC_SET_DISCOVERABLE, params) + if response.status == Status.OK: + return {"status": "ok", **response.data} + return {"status": "error", "error": response.data.get("error", "unknown error")} + except Exception as exc: + return {"error": str(exc)} + + @mcp.tool() + async def esp32_classic_pair_respond( + address: str, + accept: bool, + passkey: int | None = None, + pin: str | None = None, + ) -> dict[str, Any]: + """Respond to an incoming Classic BT pairing request on the ESP32. + + This is the ESP32-side counterpart to mcbluetooth's bt_pair_confirm. + When the Linux host initiates pairing, the ESP32 firmware raises a + pair_request event. Call this tool to accept or reject that request, + optionally providing a passkey or legacy PIN. + + Args: + address: Bluetooth address of the device requesting pairing + (e.g. "AA:BB:CC:DD:EE:FF"). + accept: Whether to accept the pairing request. + passkey: Numeric passkey for Numeric Comparison or Passkey Entry + SSP modes (0-999999). Optional. + pin: Legacy PIN code string for pre-SSP pairing. Optional. + + Returns: + Response data with the pairing result from the ESP32. + """ + try: + client = await get_client() + params: dict[str, Any] = {"address": address, "accept": accept} + if passkey is not None: + params["passkey"] = passkey + if pin is not None: + params["pin"] = pin + response = await client.send_command(CMD_CLASSIC_PAIR_RESPOND, params) + if response.status == Status.OK: + return {"status": "ok", **response.data} + return {"status": "error", "error": response.data.get("error", "unknown error")} + except Exception as exc: + return {"error": str(exc)} diff --git a/src/mcbluetooth_esp32/tools/configure.py b/src/mcbluetooth_esp32/tools/configure.py new file mode 100644 index 0000000..4e9bd30 --- /dev/null +++ b/src/mcbluetooth_esp32/tools/configure.py @@ -0,0 +1,111 @@ +"""Configuration tools for the ESP32 test harness.""" + +from __future__ import annotations + +from typing import Any + +from fastmcp import FastMCP + +from ..protocol import CMD_CLASSIC_SET_SSP_MODE, CMD_CONFIGURE, IOCapability, Status +from ..serial_client import NotConnected, get_client + + +def register_tools(mcp: FastMCP) -> None: + """Register configuration tools with the MCP server.""" + + @mcp.tool() + async def esp32_configure( + name: str | None = None, + io_cap: str | None = None, + device_class: int | None = None, + pin_code: str | None = None, + ) -> dict[str, Any]: + """Configure the ESP32 Bluetooth device settings. + + Any parameter left as None is not sent, so the ESP32 keeps its + current value for that setting. Combine multiple parameters in a + single call to apply them atomically. + + Args: + name: Bluetooth friendly name broadcast during discovery. + io_cap: I/O capability for pairing negotiation. One of: + "display_only", "display_yesno", "keyboard_only", + "no_io", "keyboard_display". + device_class: Bluetooth Class of Device (CoD) value. Controls + how remote scanners categorise this device (e.g. 0x200408 + for an audio headset). + pin_code: Legacy PIN code for PIN-based pairing (typically a + 4-digit string like "1234"). + + Returns: + Response data confirming the applied configuration, or an + error dict on failure. + """ + params: dict[str, Any] = {} + + if name is not None: + params["name"] = name + + if io_cap is not None: + # Validate against known capability values + valid_caps = {cap.value for cap in IOCapability} + if io_cap not in valid_caps: + return {"error": f"Invalid io_cap '{io_cap}'. Must be one of: {sorted(valid_caps)}"} + params["io_cap"] = io_cap + + if device_class is not None: + params["device_class"] = device_class + + if pin_code is not None: + params["pin_code"] = pin_code + + if not params: + return {"error": "No configuration parameters provided"} + + try: + client = get_client() + resp = await client.send_command(CMD_CONFIGURE, params) + if resp.status == Status.OK: + return resp.data + return {"error": resp.data.get("error", "Configuration failed")} + except NotConnected: + return {"error": "Not connected to ESP32"} + except Exception as e: + return {"error": str(e)} + + @mcp.tool() + async def esp32_set_ssp_mode(mode: str) -> dict[str, Any]: + """Set the Secure Simple Pairing (SSP) mode on the ESP32. + + SSP mode determines which pairing association model the ESP32 + will use when a remote device initiates Classic Bluetooth pairing. + + Args: + mode: SSP association model. One of: + "just_works" — no user interaction, lowest security. + "numeric_comparison" — both sides display a 6-digit code + for the user to confirm they match. + "passkey_entry" — remote device must type a passkey shown + on the ESP32 (requires display_only or keyboard_display + io_cap). + "passkey_display" — ESP32 displays a passkey for the + remote device to enter. + + Returns: + Response data confirming the new SSP mode, or an error dict + on failure. + """ + valid_modes = {"just_works", "numeric_comparison", "passkey_entry", "passkey_display"} + if mode not in valid_modes: + return {"error": f"Invalid SSP mode '{mode}'. Must be one of: {sorted(valid_modes)}"} + + try: + client = get_client() + resp = await client.send_command(CMD_CLASSIC_SET_SSP_MODE, {"mode": mode}) + if resp.status == Status.OK: + return resp.data + return {"error": resp.data.get("error", "Failed to set SSP mode")} + except NotConnected: + return {"error": "Not connected to ESP32"} + except Exception as e: + return {"error": str(e)} diff --git a/src/mcbluetooth_esp32/tools/connection.py b/src/mcbluetooth_esp32/tools/connection.py new file mode 100644 index 0000000..edf9a93 --- /dev/null +++ b/src/mcbluetooth_esp32/tools/connection.py @@ -0,0 +1,178 @@ +"""Connection management tools for the ESP32 test harness.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from fastmcp import FastMCP + +from ..protocol import CMD_GET_INFO, CMD_GET_STATUS, CMD_PING, CMD_RESET, Status +from ..serial_client import NotConnected, get_client, init_client + + +def register_tools(mcp: FastMCP) -> None: + """Register connection management tools with the MCP server.""" + + @mcp.tool() + async def esp32_connect( + port: str = "/dev/ttyUSB0", + baudrate: int = 115200, + ) -> dict[str, Any]: + """Connect to an ESP32 over serial UART. + + Initialises the serial client (if needed) and opens the connection. + Waits briefly for the ESP32 boot event before returning status. + + Args: + port: Serial device path (e.g. "/dev/ttyUSB0", "/dev/ttyACM0") + baudrate: UART baud rate (default 115200) + + Returns: + Connection status including port, baudrate, and whether the + device responded with a boot event. + """ + try: + client = get_client() + except NotConnected: + client = init_client(port=port, baudrate=baudrate) + + try: + await client.connect() + except Exception as e: + return {"connected": False, "error": str(e), "port": port} + + # Give the ESP32 a moment to send its boot event + boot_received = False + try: + event = await asyncio.wait_for(client.wait_event("boot"), timeout=2.0) + boot_received = event is not None + except TimeoutError: + pass + + return { + "connected": True, + "port": port, + "baudrate": baudrate, + "boot_event": boot_received, + } + + @mcp.tool() + async def esp32_disconnect() -> dict[str, Any]: + """Disconnect from the ESP32 serial device. + + Closes the UART connection. The client instance is retained so + a subsequent esp32_connect can reuse it. + + Returns: + Disconnection status. + """ + try: + client = get_client() + except NotConnected: + return {"connected": False, "message": "Already disconnected"} + + try: + await client.disconnect() + return {"connected": False, "message": "Disconnected"} + except Exception as e: + return {"connected": False, "error": str(e)} + + @mcp.tool() + async def esp32_status() -> dict[str, Any]: + """Query the ESP32 for its current status. + + Sends a get_status command and returns the device's reported + state (Bluetooth mode, discoverable, connected peers, etc.). + + Returns: + Status dict from the ESP32, or {"connected": false} if the + serial link is down. + """ + try: + client = get_client() + except NotConnected: + return {"connected": False} + + try: + resp = await client.send_command(CMD_GET_STATUS) + if resp.status == Status.OK: + return {"connected": True, **resp.data} + return {"connected": True, "status": resp.status, "error": resp.data.get("error", "")} + except NotConnected: + return {"connected": False} + except Exception as e: + return {"connected": True, "error": str(e)} + + @mcp.tool() + async def esp32_ping() -> dict[str, Any]: + """Ping the ESP32 to verify the serial link is alive. + + Sends a lightweight ping command and expects a pong response. + + Returns: + Response dict (typically {"pong": true}) or an error dict + if the device is unreachable. + """ + try: + client = get_client() + resp = await client.send_command(CMD_PING) + if resp.status == Status.OK: + return {"pong": True, **resp.data} + return {"pong": False, "error": resp.data.get("error", "")} + except NotConnected: + return {"pong": False, "error": "Not connected to ESP32"} + except Exception as e: + return {"pong": False, "error": str(e)} + + @mcp.tool() + async def esp32_reset() -> dict[str, Any]: + """Reset (reboot) the ESP32. + + Sends a reset command over UART. The ESP32 will reboot, which + drops the serial link. The client is disconnected automatically; + call esp32_connect again after the device restarts. + + Returns: + Reset status. The serial connection will be closed. + """ + try: + client = get_client() + except NotConnected: + return {"reset": False, "error": "Not connected to ESP32"} + + try: + resp = await client.send_command(CMD_RESET) + status = resp.status == Status.OK + except Exception: + # The reset itself may kill the link before the response arrives + status = True + + # Connection will be lost after reboot, so disconnect cleanly + try: + await client.disconnect() + except Exception: + pass + + return {"reset": status, "message": "ESP32 is rebooting — reconnect after restart"} + + @mcp.tool() + async def esp32_get_info() -> dict[str, Any]: + """Retrieve device information from the ESP32. + + Returns firmware version, chip model, MAC addresses, and other + hardware details reported by the device. + + Returns: + Device information dict, or an error dict on failure. + """ + try: + client = get_client() + resp = await client.send_command(CMD_GET_INFO) + if resp.status == Status.OK: + return resp.data + return {"error": resp.data.get("error", "Failed to get device info")} + except NotConnected: + return {"error": "Not connected to ESP32"} + except Exception as e: + return {"error": str(e)} diff --git a/src/mcbluetooth_esp32/tools/events.py b/src/mcbluetooth_esp32/tools/events.py new file mode 100644 index 0000000..579dc0b --- /dev/null +++ b/src/mcbluetooth_esp32/tools/events.py @@ -0,0 +1,67 @@ +"""Event management tools -- inspect and wait on ESP32 Bluetooth events.""" + +from __future__ import annotations + +from typing import Any + +from fastmcp import FastMCP + +from ..event_queue import EventQueue +from ..protocol import Event +from ..serial_client import get_client + + +def _event_to_dict(e: Event) -> dict[str, Any]: + """Convert an Event dataclass to a plain dict.""" + return {"event": e.event, "data": e.data, "ts": e.ts} + + +def register_tools(mcp: FastMCP) -> None: + @mcp.tool() + async def esp32_get_events( + event_name: str | None = None, + limit: int = 50, + ) -> list[dict[str, Any]]: + """Get recent events from the ESP32 event history. + + Args: + event_name: If provided, only return events with this name + (e.g. "pair_request", "connect"). + limit: Maximum number of events to return (default 50). + """ + client = get_client() + eq: EventQueue = client.event_queue + events = eq.get_events(event_name=event_name, limit=limit) + return [_event_to_dict(e) for e in events] + + @mcp.tool() + async def esp32_wait_event( + event_name: str, + timeout: float = 30.0, + ) -> dict[str, Any]: + """Wait for a specific event from the ESP32. + + Blocks until a matching event arrives or the timeout expires. + + Args: + event_name: The event name to wait for (e.g. "pair_request", + "pair_complete", "connect", "disconnect"). + timeout: Seconds to wait before returning an error (default 30). + """ + client = get_client() + eq: EventQueue = client.event_queue + try: + event = await eq.wait_for(event_name=event_name, timeout=timeout) + except TimeoutError: + return { + "error": f"timed out waiting for {event_name!r} after {timeout}s", + } + return _event_to_dict(event) + + @mcp.tool() + async def esp32_clear_events() -> dict[str, Any]: + """Clear the ESP32 event history.""" + client = get_client() + eq: EventQueue = client.event_queue + eq.clear() + return {"cleared": True} diff --git a/src/mcbluetooth_esp32/tools/persona.py b/src/mcbluetooth_esp32/tools/persona.py new file mode 100644 index 0000000..4ddfd9b --- /dev/null +++ b/src/mcbluetooth_esp32/tools/persona.py @@ -0,0 +1,42 @@ +"""Persona management tools -- switch the ESP32 between device profiles.""" + +from __future__ import annotations + +from typing import Any + +from fastmcp import FastMCP + +from ..protocol import CMD_LIST_PERSONAS, CMD_LOAD_PERSONA, Status +from ..serial_client import get_client + +VALID_PERSONAS = frozenset({"headset", "speaker", "keyboard", "sensor", "phone", "bare"}) + + +def register_tools(mcp: FastMCP) -> None: + @mcp.tool() + async def esp32_load_persona(persona: str) -> dict[str, Any]: + """Load a device persona preset on the ESP32. + + Each persona configures the device name, IO capabilities, Bluetooth + transport, and default GATT services in a single operation. + + Args: + persona: One of "headset", "speaker", "keyboard", "sensor", + "phone", or "bare". + """ + if persona not in VALID_PERSONAS: + return { + "status": Status.ERROR, + "error": f"unknown persona {persona!r}, valid: {sorted(VALID_PERSONAS)}", + } + + client = get_client() + resp = await client.send_command(CMD_LOAD_PERSONA, {"persona": persona}) + return resp.data + + @mcp.tool() + async def esp32_list_personas() -> dict[str, Any]: + """List all available device personas and their configurations.""" + client = get_client() + resp = await client.send_command(CMD_LIST_PERSONAS) + return resp.data diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..54ad571 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,40 @@ +"""Integration test fixtures — detect real ESP32 hardware on the serial port.""" + +from __future__ import annotations + +import os + +import pytest +import serial + +ESP32_PORT = os.environ.get("ESP32_SERIAL_PORT", "/dev/ttyUSB4") + + +def esp32_available() -> bool: + """Check if an ESP32 is connected on the expected port.""" + try: + s = serial.Serial(ESP32_PORT, 115200, timeout=1) + s.close() + return True + except (serial.SerialException, OSError): + return False + + +requires_esp32 = pytest.mark.skipif( + not esp32_available(), + reason=f"ESP32 not found on {ESP32_PORT}", +) + + +@pytest.fixture +async def esp32_client(): + """Connected SerialClient fixture -- skips if no hardware.""" + if not esp32_available(): + pytest.skip(f"ESP32 not available on {ESP32_PORT}") + + from mcbluetooth_esp32.serial_client import SerialClient + + client = SerialClient(port=ESP32_PORT) + await client.connect() + yield client + await client.disconnect() diff --git a/tests/integration/test_ble_gatt.py b/tests/integration/test_ble_gatt.py new file mode 100644 index 0000000..bfe9a74 --- /dev/null +++ b/tests/integration/test_ble_gatt.py @@ -0,0 +1,20 @@ +"""E2E test: BLE GATT service interaction between Linux and ESP32.""" + +from __future__ import annotations + +import pytest + +from .conftest import requires_esp32 + + +@requires_esp32 +class TestBLEGATT: + """Create GATT services on ESP32, interact from Linux via mcbluetooth.""" + + async def test_gatt_read_write(self, esp32_client): + """Create GATT service on ESP32, read/write from Linux.""" + pytest.skip("Not yet implemented") + + async def test_gatt_notify(self, esp32_client): + """Subscribe to notifications on Linux, verify delivery from ESP32.""" + pytest.skip("Not yet implemented") diff --git a/tests/integration/test_ssp_just_works.py b/tests/integration/test_ssp_just_works.py new file mode 100644 index 0000000..5ca7675 --- /dev/null +++ b/tests/integration/test_ssp_just_works.py @@ -0,0 +1,29 @@ +"""E2E test: SSP Just Works pairing between Linux and ESP32. + +Requires both mcbluetooth and mcbluetooth-esp32 MCP servers, plus +real hardware with the test firmware flashed. +""" + +from __future__ import annotations + +import pytest + +from .conftest import requires_esp32 + + +@requires_esp32 +class TestSSPJustWorks: + """SSP Just Works pairing -- both sides declare no_io capability.""" + + async def test_just_works_pairing(self, esp32_client): + """Test Just Works pairing mode (no_io on both sides). + + Steps (once firmware is ready): + 1. Load 'headset' persona (no_io -> Just Works) + 2. Enable Classic BT, set discoverable + 3. Scan from Linux side via mcbluetooth + 4. Initiate pairing from Linux + 5. Verify pair_complete event on ESP32 + 6. Verify device paired on Linux + """ + pytest.skip("Not yet implemented -- waiting for firmware flash") diff --git a/tests/integration/test_ssp_numeric.py b/tests/integration/test_ssp_numeric.py new file mode 100644 index 0000000..0b6f3ee --- /dev/null +++ b/tests/integration/test_ssp_numeric.py @@ -0,0 +1,16 @@ +"""E2E test: SSP Numeric Comparison pairing between Linux and ESP32.""" + +from __future__ import annotations + +import pytest + +from .conftest import requires_esp32 + + +@requires_esp32 +class TestSSPNumericComparison: + """Both sides display a 6-digit passkey; both must confirm to pair.""" + + async def test_numeric_comparison_pairing(self, esp32_client): + """Both sides display passkey, both must confirm.""" + pytest.skip("Not yet implemented") diff --git a/tests/integration/test_ssp_passkey.py b/tests/integration/test_ssp_passkey.py new file mode 100644 index 0000000..d24fa46 --- /dev/null +++ b/tests/integration/test_ssp_passkey.py @@ -0,0 +1,16 @@ +"""E2E test: SSP Passkey Entry pairing between Linux and ESP32.""" + +from __future__ import annotations + +import pytest + +from .conftest import requires_esp32 + + +@requires_esp32 +class TestSSPPasskeyEntry: + """One side displays a passkey, the other side enters it.""" + + async def test_passkey_entry_pairing(self, esp32_client): + """One side displays passkey, other enters it.""" + pytest.skip("Not yet implemented") diff --git a/tests/test_event_queue.py b/tests/test_event_queue.py new file mode 100644 index 0000000..1cd9dfd --- /dev/null +++ b/tests/test_event_queue.py @@ -0,0 +1,285 @@ +"""Unit tests for mcbluetooth_esp32.event_queue.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from mcbluetooth_esp32.event_queue import EventQueue +from mcbluetooth_esp32.protocol import Event, MsgType + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_event(name: str, data: dict | None = None, ts: int | None = None) -> Event: + return Event(type=MsgType.EVENT, event=name, data=data or {}, ts=ts) + + +# --------------------------------------------------------------------------- +# Tests: push / get_events basics +# --------------------------------------------------------------------------- + + +class TestPushAndGetEvents: + """push() and get_events() without any async waiting.""" + + async def test_push_and_retrieve(self): + q = EventQueue() + q.push(_make_event("boot")) + q.push(_make_event("connect")) + + events = q.get_events() + assert len(events) == 2 + assert events[0].event == "boot" + assert events[1].event == "connect" + + async def test_get_events_filtered_by_name(self): + q = EventQueue() + q.push(_make_event("boot")) + q.push(_make_event("connect")) + q.push(_make_event("boot")) + + events = q.get_events(event_name="boot") + assert len(events) == 2 + assert all(e.event == "boot" for e in events) + + async def test_get_events_with_limit(self): + q = EventQueue() + for i in range(10): + q.push(_make_event("tick", data={"i": i})) + + events = q.get_events(limit=3) + assert len(events) == 3 + # Should be the *most recent* three + assert events[0].data["i"] == 7 + assert events[1].data["i"] == 8 + assert events[2].data["i"] == 9 + + async def test_get_events_with_since_ts(self): + q = EventQueue() + q.push(_make_event("a", ts=100)) + q.push(_make_event("b", ts=200)) + q.push(_make_event("c", ts=300)) + + events = q.get_events(since_ts=200) + assert len(events) == 2 + assert events[0].event == "b" + assert events[1].event == "c" + + async def test_get_events_with_none_ts_excluded_by_since_ts(self): + q = EventQueue() + q.push(_make_event("a", ts=None)) + q.push(_make_event("b", ts=500)) + + events = q.get_events(since_ts=100) + assert len(events) == 1 + assert events[0].event == "b" + + async def test_get_events_empty_queue(self): + q = EventQueue() + assert q.get_events() == [] + + async def test_len(self): + q = EventQueue() + assert len(q) == 0 + q.push(_make_event("a")) + assert len(q) == 1 + q.push(_make_event("b")) + assert len(q) == 2 + + +# --------------------------------------------------------------------------- +# Tests: wait_for +# --------------------------------------------------------------------------- + + +class TestWaitFor: + """Async waiting for specific events.""" + + async def test_wait_for_resolves_on_push(self): + q = EventQueue() + + async def _push_later(): + await asyncio.sleep(0.05) + q.push(_make_event("pair_complete", data={"addr": "AA:BB"})) + + task = asyncio.create_task(_push_later()) + event = await q.wait_for(event_name="pair_complete", timeout=2.0) + await task + + assert event.event == "pair_complete" + assert event.data["addr"] == "AA:BB" + + async def test_wait_for_timeout(self): + q = EventQueue() + with pytest.raises(asyncio.TimeoutError): + await q.wait_for(event_name="never_arrives", timeout=0.1) + + async def test_wait_for_finds_existing_event(self): + """If a matching event is already in the history, return immediately.""" + q = EventQueue() + q.push(_make_event("boot", data={"v": "1.0"})) + + # Should return instantly without waiting + event = await q.wait_for(event_name="boot", timeout=0.5) + assert event.event == "boot" + assert event.data["v"] == "1.0" + + async def test_wait_for_returns_most_recent_existing(self): + """When scanning history, the most recent match is returned.""" + q = EventQueue() + q.push(_make_event("boot", data={"v": "old"})) + q.push(_make_event("boot", data={"v": "new"})) + + event = await q.wait_for(event_name="boot", timeout=0.5) + assert event.data["v"] == "new" + + async def test_wait_for_with_custom_match(self): + q = EventQueue() + + async def _push_later(): + await asyncio.sleep(0.05) + q.push(_make_event("pair_complete", data={"success": False})) + q.push(_make_event("pair_complete", data={"success": True})) + + task = asyncio.create_task(_push_later()) + event = await q.wait_for( + event_name="pair_complete", + match=lambda e: e.data.get("success") is True, + timeout=2.0, + ) + await task + + assert event.data["success"] is True + + async def test_wait_for_requires_at_least_one_filter(self): + q = EventQueue() + with pytest.raises(ValueError, match="at least one"): + await q.wait_for(timeout=1.0) + + async def test_wait_for_with_match_only(self): + """Using only a custom match function (no event_name).""" + q = EventQueue() + + async def _push(): + await asyncio.sleep(0.05) + q.push(_make_event("x", data={"val": 42})) + + task = asyncio.create_task(_push()) + event = await q.wait_for( + match=lambda e: e.data.get("val") == 42, + timeout=2.0, + ) + await task + assert event.data["val"] == 42 + + +# --------------------------------------------------------------------------- +# Tests: clear +# --------------------------------------------------------------------------- + + +class TestClear: + async def test_clear_removes_all_events(self): + q = EventQueue() + q.push(_make_event("a")) + q.push(_make_event("b")) + assert len(q) == 2 + + q.clear() + assert len(q) == 0 + assert q.get_events() == [] + + async def test_clear_does_not_cancel_waiters(self): + """Waiters registered before clear() should still fire on new events.""" + q = EventQueue() + + async def _push_after_clear(): + await asyncio.sleep(0.05) + q.clear() + await asyncio.sleep(0.05) + q.push(_make_event("target")) + + task = asyncio.create_task(_push_after_clear()) + event = await q.wait_for(event_name="target", timeout=2.0) + await task + assert event.event == "target" + + +# --------------------------------------------------------------------------- +# Tests: max_events boundary +# --------------------------------------------------------------------------- + + +class TestMaxEvents: + async def test_max_events_trims_oldest(self): + q = EventQueue(max_events=10) + for i in range(15): + q.push(_make_event("tick", data={"i": i})) + + assert len(q) == 10 + events = q.get_events() + # Oldest five (0..4) should have been trimmed + assert events[0].data["i"] == 5 + assert events[-1].data["i"] == 14 + + async def test_max_events_exact_boundary(self): + q = EventQueue(max_events=1000) + for i in range(1001): + q.push(_make_event("x", data={"i": i})) + + assert len(q) == 1000 + events = q.get_events(limit=1000) + assert events[0].data["i"] == 1 + assert events[-1].data["i"] == 1000 + + +# --------------------------------------------------------------------------- +# Tests: multiple waiters +# --------------------------------------------------------------------------- + + +class TestMultipleWaiters: + async def test_different_event_waiters(self): + """Two waiters for different events should each get their own match.""" + q = EventQueue() + + async def _push_events(): + await asyncio.sleep(0.05) + q.push(_make_event("alpha", data={"n": 1})) + q.push(_make_event("beta", data={"n": 2})) + + task = asyncio.create_task(_push_events()) + + evt_a, evt_b = await asyncio.gather( + q.wait_for(event_name="alpha", timeout=2.0), + q.wait_for(event_name="beta", timeout=2.0), + ) + await task + + assert evt_a.event == "alpha" + assert evt_a.data["n"] == 1 + assert evt_b.event == "beta" + assert evt_b.data["n"] == 2 + + async def test_same_event_wakes_all_matching_waiters(self): + """If multiple waiters match the same event, they all get resolved.""" + q = EventQueue() + + async def _push(): + await asyncio.sleep(0.05) + q.push(_make_event("boom")) + + task = asyncio.create_task(_push()) + + e1, e2 = await asyncio.gather( + q.wait_for(event_name="boom", timeout=2.0), + q.wait_for(event_name="boom", timeout=2.0), + ) + await task + + assert e1.event == "boom" + assert e2.event == "boom" diff --git a/tests/test_protocol.py b/tests/test_protocol.py new file mode 100644 index 0000000..9385acc --- /dev/null +++ b/tests/test_protocol.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import json + +import pytest + +from mcbluetooth_esp32.protocol import ( + BAUD_RATE, + CMD_BLE_ADVERTISE, + CMD_CLASSIC_ENABLE, + CMD_GATT_ADD_SERVICE, + CMD_GET_INFO, + CMD_PING, + CMD_RESET, + EVT_BOOT, + EVT_CONNECT, + EVT_PAIR_REQUEST, + MAX_LINE_LENGTH, + Command, + Event, + IOCapability, + MsgType, + Response, + Status, + Transport, + build_command, + parse_message, +) + + +class TestEnums: + def test_msg_type_values(self): + assert MsgType.CMD == "cmd" + assert MsgType.RESP == "resp" + assert MsgType.EVENT == "event" + + def test_msg_type_from_string(self): + assert MsgType("cmd") is MsgType.CMD + assert MsgType("resp") is MsgType.RESP + assert MsgType("event") is MsgType.EVENT + + def test_status_values(self): + assert Status.OK == "ok" + assert Status.ERROR == "error" + + def test_io_capability_values(self): + assert IOCapability.DISPLAY_ONLY == "display_only" + assert IOCapability.DISPLAY_YESNO == "display_yesno" + assert IOCapability.KEYBOARD_ONLY == "keyboard_only" + assert IOCapability.NO_IO == "no_io" + assert IOCapability.KEYBOARD_DISPLAY == "keyboard_display" + + def test_transport_values(self): + assert Transport.CLASSIC == "classic" + assert Transport.BLE == "ble" + assert Transport.BOTH == "both" + + +class TestConstants: + def test_baud_rate(self): + assert BAUD_RATE == 115200 + + def test_max_line_length(self): + assert MAX_LINE_LENGTH == 2048 + + def test_command_constants(self): + assert CMD_PING == "ping" + assert CMD_RESET == "reset" + assert CMD_GET_INFO == "get_info" + assert CMD_CLASSIC_ENABLE == "classic_enable" + assert CMD_BLE_ADVERTISE == "ble_advertise" + assert CMD_GATT_ADD_SERVICE == "gatt_add_service" + + def test_event_constants(self): + assert EVT_BOOT == "boot" + assert EVT_PAIR_REQUEST == "pair_request" + assert EVT_CONNECT == "connect" + + +class TestCommand: + def test_build_command_simple(self): + cmd = build_command("ping") + assert cmd.type == MsgType.CMD + assert cmd.cmd == "ping" + assert cmd.params == {} + + def test_build_command_with_params(self): + cmd = build_command("configure", {"name": "test"}) + assert cmd.type == MsgType.CMD + assert cmd.cmd == "configure" + assert cmd.params == {"name": "test"} + + def test_build_command_with_explicit_id(self): + cmd = build_command("ping", cmd_id="42") + assert cmd.id == "42" + + def test_auto_incrementing_ids(self): + cmd1 = build_command("ping") + cmd2 = build_command("ping") + assert cmd1.id != cmd2.id + assert int(cmd2.id) > int(cmd1.id) + + def test_to_json_produces_valid_json(self): + cmd = build_command("ping", cmd_id="1") + raw = cmd.to_json() + parsed = json.loads(raw) + assert parsed["type"] == "cmd" + assert parsed["id"] == "1" + assert parsed["cmd"] == "ping" + + def test_to_json_single_line(self): + cmd = build_command("configure", {"name": "test", "io": "no_io"}, cmd_id="5") + raw = cmd.to_json() + assert "\n" not in raw + + def test_to_json_omits_empty_params(self): + cmd = build_command("ping", cmd_id="1") + parsed = json.loads(cmd.to_json()) + assert "params" not in parsed + + def test_to_json_includes_params_when_present(self): + cmd = build_command("configure", {"name": "test"}, cmd_id="1") + parsed = json.loads(cmd.to_json()) + assert parsed["params"] == {"name": "test"} + + def test_to_json_round_trip(self): + cmd = build_command("gatt_add_service", {"uuid": "180F", "primary": True}, cmd_id="99") + raw = cmd.to_json() + parsed = json.loads(raw) + assert parsed == { + "type": "cmd", + "id": "99", + "cmd": "gatt_add_service", + "params": {"uuid": "180F", "primary": True}, + } + + +class TestResponse: + def test_from_json_ok(self): + line = json.dumps({"type": "resp", "id": "1", "status": "ok", "data": {"version": "1.0"}}) + resp = Response.from_json(line) + assert resp.type == MsgType.RESP + assert resp.id == "1" + assert resp.status == Status.OK + assert resp.data == {"version": "1.0"} + + def test_from_json_error(self): + line = json.dumps( + {"type": "resp", "id": "2", "status": "error", "data": {"msg": "timeout"}} + ) + resp = Response.from_json(line) + assert resp.status == Status.ERROR + assert resp.data["msg"] == "timeout" + + def test_from_json_complex_data(self): + data = {"adapters": [{"name": "hci0", "addr": "AA:BB:CC:DD:EE:FF"}], "count": 1} + line = json.dumps({"type": "resp", "id": "3", "status": "ok", "data": data}) + resp = Response.from_json(line) + assert resp.data == data + + def test_from_json_no_data_field(self): + line = json.dumps({"type": "resp", "id": "4", "status": "ok"}) + resp = Response.from_json(line) + assert resp.data == {} + + def test_from_json_missing_required_field(self): + line = json.dumps({"type": "resp", "id": "5"}) + with pytest.raises(KeyError): + Response.from_json(line) + + def test_from_json_invalid_json(self): + with pytest.raises((ValueError, json.JSONDecodeError)): + Response.from_json("not json at all") + + +class TestEvent: + def test_from_json_boot(self): + line = json.dumps( + {"type": "event", "event": "boot", "data": {"firmware": "1.0.0"}, "ts": 1000} + ) + evt = Event.from_json(line) + assert evt.type == MsgType.EVENT + assert evt.event == "boot" + assert evt.data == {"firmware": "1.0.0"} + assert evt.ts == 1000 + + def test_from_json_pair_request_with_passkey(self): + line = json.dumps( + { + "type": "event", + "event": "pair_request", + "data": {"address": "AA:BB:CC:DD:EE:FF", "passkey": 123456}, + "ts": 2000, + } + ) + evt = Event.from_json(line) + assert evt.event == "pair_request" + assert evt.data["passkey"] == 123456 + assert evt.data["address"] == "AA:BB:CC:DD:EE:FF" + + def test_from_json_connect_disconnect(self): + for event_name in ("connect", "disconnect"): + line = json.dumps( + {"type": "event", "event": event_name, "data": {"address": "11:22:33:44:55:66"}} + ) + evt = Event.from_json(line) + assert evt.event == event_name + + def test_from_json_preserves_timestamp(self): + line = json.dumps({"type": "event", "event": "boot", "ts": 9999999}) + evt = Event.from_json(line) + assert evt.ts == 9999999 + + def test_from_json_no_timestamp(self): + line = json.dumps({"type": "event", "event": "boot"}) + evt = Event.from_json(line) + assert evt.ts is None + + def test_from_json_no_data(self): + line = json.dumps({"type": "event", "event": "boot"}) + evt = Event.from_json(line) + assert evt.data == {} + + +class TestParseMessage: + def test_parse_command(self): + line = json.dumps({"type": "cmd", "id": "10", "cmd": "ping"}) + msg = parse_message(line) + assert isinstance(msg, Command) + assert msg.type == MsgType.CMD + assert msg.cmd == "ping" + assert msg.id == "10" + + def test_parse_response(self): + line = json.dumps({"type": "resp", "id": "10", "status": "ok", "data": {"pong": True}}) + msg = parse_message(line) + assert isinstance(msg, Response) + assert msg.status == Status.OK + assert msg.data == {"pong": True} + + def test_parse_event(self): + line = json.dumps({"type": "event", "event": "boot", "data": {}, "ts": 500}) + msg = parse_message(line) + assert isinstance(msg, Event) + assert msg.event == "boot" + assert msg.ts == 500 + + def test_parse_unknown_type(self): + line = json.dumps({"type": "unknown", "id": "1"}) + with pytest.raises(ValueError, match="unknown message type"): + parse_message(line) + + def test_parse_empty_string(self): + with pytest.raises(ValueError): + parse_message("") + + def test_parse_invalid_json(self): + with pytest.raises(ValueError, match="invalid JSON"): + parse_message("{broken json") + + def test_parse_whitespace_stripping(self): + line = ' {"type":"cmd","id":"1","cmd":"ping"} \n' + msg = parse_message(line) + assert isinstance(msg, Command) + assert msg.cmd == "ping" + + def test_parse_command_with_params(self): + line = json.dumps({"type": "cmd", "id": "7", "cmd": "configure", "params": {"name": "dev"}}) + msg = parse_message(line) + assert isinstance(msg, Command) + assert msg.params == {"name": "dev"} + + def test_parse_missing_type(self): + line = json.dumps({"id": "1", "cmd": "ping"}) + with pytest.raises(ValueError, match="unknown message type"): + parse_message(line) + + def test_parse_command_round_trip(self): + original = build_command("get_info", {"verbose": True}, cmd_id="50") + serialized = original.to_json() + restored = parse_message(serialized) + assert isinstance(restored, Command) + assert restored.cmd == original.cmd + assert restored.id == original.id + assert restored.params == original.params diff --git a/tests/test_serial_client.py b/tests/test_serial_client.py new file mode 100644 index 0000000..78763ea --- /dev/null +++ b/tests/test_serial_client.py @@ -0,0 +1,308 @@ +"""Unit tests for mcbluetooth_esp32.serial_client — mocked serial, no hardware needed.""" + +from __future__ import annotations + +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from mcbluetooth_esp32.protocol import CMD_GET_INFO, CMD_PING, Status +from mcbluetooth_esp32.serial_client import CommandTimeout, NotConnected, SerialClient + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _resp_line(cmd_id: str, status: str = "ok", data: dict | None = None) -> bytes: + """Build a raw NDJSON response line as bytes (what the reader yields).""" + obj = {"type": "resp", "id": cmd_id, "status": status} + if data: + obj["data"] = data + return json.dumps(obj, separators=(",", ":")).encode() + b"\n" + + +def _event_line(event_name: str, data: dict | None = None, ts: int | None = None) -> bytes: + """Build a raw NDJSON event line as bytes.""" + obj: dict = {"type": "event", "event": event_name} + if data: + obj["data"] = data + if ts is not None: + obj["ts"] = ts + return json.dumps(obj, separators=(",", ":")).encode() + b"\n" + + +# --------------------------------------------------------------------------- +# Tests: offline (no connection at all) +# --------------------------------------------------------------------------- + + +class TestSerialClientOffline: + """Verify behaviour when the client is not connected.""" + + async def test_send_command_raises_not_connected(self): + client = SerialClient(port="/dev/ttyUSB0") + with pytest.raises(NotConnected): + await client.send_command(CMD_PING) + + async def test_connected_is_false_initially(self): + client = SerialClient(port="/dev/ttyUSB0") + assert client.connected is False + + async def test_disconnect_when_not_connected_is_noop(self): + client = SerialClient(port="/dev/ttyUSB0") + # Should not raise + await client.disconnect() + assert client.connected is False + + +# --------------------------------------------------------------------------- +# Tests: mocked serial connection +# --------------------------------------------------------------------------- + + +class TestSerialClientMocked: + """Tests against a mocked serial_asyncio transport.""" + + @pytest.fixture + async def mock_client(self): + """Create a SerialClient with mocked serial connection. + + The reader is set up with a response queue. Test code pushes raw + bytes into ``reader_lines`` and the mock readline() pops them off. + The ``drain_called`` event can be awaited to know when a write has + been flushed. + """ + client = SerialClient(port="/dev/ttyUSB0", timeout=2.0) + + reader = AsyncMock(spec=asyncio.StreamReader) + writer = MagicMock() + writer.write = MagicMock() + writer.drain = AsyncMock() + writer.close = MagicMock() + writer.wait_closed = AsyncMock() + + # Queue of lines the reader will yield; tests push into this. + reader_lines: asyncio.Queue[bytes] = asyncio.Queue() + + async def _readline(): + return await reader_lines.get() + + reader.readline = _readline + + with patch("serial_asyncio.open_serial_connection", return_value=(reader, writer)): + await client.connect() + # Sanity: the connect above started _read_loop with the real reader + # which is our mock. + yield client, reader_lines, writer + + if client.connected: + # Push empty bytes to unblock reader_lines.get() so disconnect can cancel cleanly + reader_lines.put_nowait(b"") + await client.disconnect() + + # ------------------------------------------------------------------ + # send_command basics + # ------------------------------------------------------------------ + + async def test_send_command_writes_json_line(self, mock_client): + """Verify the client writes properly formatted NDJSON to the writer.""" + client, reader_lines, writer = mock_client + + async def _respond_soon(): + """Wait for the write, parse the command ID, then inject a response.""" + await asyncio.sleep(0.05) + # Extract what was written + call_args = writer.write.call_args + assert call_args is not None, "writer.write was not called" + raw = call_args[0][0] + obj = json.loads(raw) + assert obj["type"] == "cmd" + assert obj["cmd"] == CMD_PING + # Push a matching response + reader_lines.put_nowait(_resp_line(obj["id"])) + + task = asyncio.create_task(_respond_soon()) + resp = await client.send_command(CMD_PING) + await task + + assert resp.status == Status.OK + assert resp.id is not None + + async def test_response_correlation(self, mock_client): + """Verify the correct response is returned for a given command ID.""" + client, reader_lines, writer = mock_client + + expected_data = {"firmware": "1.0.0", "mac": "AA:BB:CC:DD:EE:FF"} + + async def _respond(): + await asyncio.sleep(0.05) + raw = writer.write.call_args[0][0] + cmd_id = json.loads(raw)["id"] + reader_lines.put_nowait(_resp_line(cmd_id, data=expected_data)) + + task = asyncio.create_task(_respond()) + resp = await client.send_command(CMD_GET_INFO) + await task + + assert resp.status == Status.OK + assert resp.data["firmware"] == "1.0.0" + assert resp.data["mac"] == "AA:BB:CC:DD:EE:FF" + + # ------------------------------------------------------------------ + # Event routing + # ------------------------------------------------------------------ + + async def test_event_routing_to_event_queue(self, mock_client): + """Events from the reader should land in the EventQueue.""" + client, reader_lines, writer = mock_client + + # Push an event line + reader_lines.put_nowait(_event_line("boot", data={"version": "1.0"}, ts=12345)) + + # Give the read loop time to consume it + await asyncio.sleep(0.1) + + events = client.event_queue.get_events(event_name="boot") + assert len(events) == 1 + assert events[0].event == "boot" + assert events[0].data["version"] == "1.0" + assert events[0].ts == 12345 + + async def test_multiple_events_accumulated(self, mock_client): + """Multiple events should all appear in the queue.""" + client, reader_lines, writer = mock_client + + for i in range(5): + reader_lines.put_nowait(_event_line("connect", data={"index": i})) + + await asyncio.sleep(0.15) + + events = client.event_queue.get_events(event_name="connect") + assert len(events) == 5 + + # ------------------------------------------------------------------ + # Timeout + # ------------------------------------------------------------------ + + async def test_command_timeout(self, mock_client): + """A command with no response should raise CommandTimeout.""" + client, reader_lines, writer = mock_client + + with pytest.raises(CommandTimeout): + await client.send_command(CMD_PING, timeout=0.1) + + # ------------------------------------------------------------------ + # Concurrent commands + # ------------------------------------------------------------------ + + async def test_multiple_concurrent_commands(self, mock_client): + """Two commands in flight at once should each get the right response.""" + client, reader_lines, writer = mock_client + + captured_ids: list[str] = [] + + async def _respond_after_both(): + # Wait for two writes + while len(writer.write.call_args_list) < 2: + await asyncio.sleep(0.02) + + for call in writer.write.call_args_list: + raw = call[0][0] + obj = json.loads(raw) + captured_ids.append(obj["id"]) + + # Respond in reverse order to test correlation + reader_lines.put_nowait(_resp_line(captured_ids[1], data={"which": "second"})) + reader_lines.put_nowait(_resp_line(captured_ids[0], data={"which": "first"})) + + responder = asyncio.create_task(_respond_after_both()) + + r1, r2 = await asyncio.gather( + client.send_command(CMD_PING), + client.send_command(CMD_GET_INFO), + ) + await responder + + # Each response should match its own command regardless of delivery order + assert r1.data["which"] == "first" + assert r2.data["which"] == "second" + + # ------------------------------------------------------------------ + # Disconnect + # ------------------------------------------------------------------ + + async def test_disconnect_cancels_pending_futures(self, mock_client): + """Pending commands should be cancelled when we disconnect.""" + client, reader_lines, writer = mock_client + + async def _fire_and_forget(): + with pytest.raises((CommandTimeout, asyncio.CancelledError, NotConnected)): + await client.send_command(CMD_PING, timeout=5.0) + + cmd_task = asyncio.create_task(_fire_and_forget()) + + await asyncio.sleep(0.05) + # Push empty bytes to unblock the readline so disconnect can cancel the read loop + reader_lines.put_nowait(b"") + await client.disconnect() + + assert client.connected is False + # The command task should finish (cancelled or timed-out — either is fine) + await asyncio.wait_for(cmd_task, timeout=2.0) + + async def test_disconnect_is_idempotent(self, mock_client): + """Calling disconnect twice should not raise.""" + client, reader_lines, writer = mock_client + reader_lines.put_nowait(b"") + await client.disconnect() + # Second call is a no-op + await client.disconnect() + assert client.connected is False + + # ------------------------------------------------------------------ + # Edge cases + # ------------------------------------------------------------------ + + async def test_unparseable_line_is_dropped(self, mock_client): + """Garbage on the wire should be silently dropped.""" + client, reader_lines, writer = mock_client + + reader_lines.put_nowait(b"NOT-JSON-AT-ALL\n") + # Valid event right after to prove the loop keeps running + reader_lines.put_nowait(_event_line("boot")) + await asyncio.sleep(0.15) + + events = client.event_queue.get_events(event_name="boot") + assert len(events) == 1 + + async def test_oversized_line_is_dropped(self, mock_client): + """Lines exceeding MAX_LINE_LENGTH should be dropped.""" + client, reader_lines, writer = mock_client + + # 3000 bytes of 'a' is above the 2048 limit + reader_lines.put_nowait(b"a" * 3000 + b"\n") + reader_lines.put_nowait(_event_line("boot")) + await asyncio.sleep(0.15) + + events = client.event_queue.get_events(event_name="boot") + assert len(events) == 1 + + async def test_error_response_propagates(self, mock_client): + """An error response should be returned (not raised) with status=error.""" + client, reader_lines, writer = mock_client + + async def _respond_error(): + await asyncio.sleep(0.05) + raw = writer.write.call_args[0][0] + cmd_id = json.loads(raw)["id"] + reader_lines.put_nowait(_resp_line(cmd_id, status="error", data={"msg": "bad"})) + + task = asyncio.create_task(_respond_error()) + resp = await client.send_command(CMD_PING) + await task + + assert resp.status == Status.ERROR + assert resp.data["msg"] == "bad" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..782dc94 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1754 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "authlib" +version = "1.6.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/9b/b1661026ff24bc641b76b78c5222d614776b0c085bcfdac9bd15a1cb4b35/authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e", size = 164894, upload-time = "2025-12-12T08:01:41.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" }, +] + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, +] + +[[package]] +name = "cachetools" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/af/df70e9b65bc77a1cbe0768c0aa4617147f30f8306ded98c1744bcdc0ae1e/cachetools-7.0.0.tar.gz", hash = "sha256:a9abf18ff3b86c7d05b27ead412e235e16ae045925e531fae38d5fada5ed5b08", size = 35796, upload-time = "2026-02-01T18:59:47.411Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/df/2dd32cce20cbcf6f2ec456b58d44368161ad28320729f64e5e1d5d7bd0ae/cachetools-7.0.0-py3-none-any.whl", hash = "sha256:d52fef60e6e964a1969cfb61ccf6242a801b432790fe520d78720d757c81cbd2", size = 13487, upload-time = "2026-02-01T18:59:45.981Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "cloudpickle" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/19/f748958276519adf6a0c1e79e7b8860b4830dda55ccdf29f2719b5fc499c/cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59", size = 749301, upload-time = "2026-01-28T00:24:37.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485", size = 7173686, upload-time = "2026-01-28T00:23:07.515Z" }, + { url = "https://files.pythonhosted.org/packages/87/91/874b8910903159043b5c6a123b7e79c4559ddd1896e38967567942635778/cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc", size = 4275871, upload-time = "2026-01-28T00:23:09.439Z" }, + { url = "https://files.pythonhosted.org/packages/c0/35/690e809be77896111f5b195ede56e4b4ed0435b428c2f2b6d35046fbb5e8/cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0", size = 4423124, upload-time = "2026-01-28T00:23:11.529Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5b/a26407d4f79d61ca4bebaa9213feafdd8806dc69d3d290ce24996d3cfe43/cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa", size = 4277090, upload-time = "2026-01-28T00:23:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d8/4bb7aec442a9049827aa34cee1aa83803e528fa55da9a9d45d01d1bb933e/cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81", size = 4947652, upload-time = "2026-01-28T00:23:14.554Z" }, + { url = "https://files.pythonhosted.org/packages/2b/08/f83e2e0814248b844265802d081f2fac2f1cbe6cd258e72ba14ff006823a/cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255", size = 4455157, upload-time = "2026-01-28T00:23:16.443Z" }, + { url = "https://files.pythonhosted.org/packages/0a/05/19d849cf4096448779d2dcc9bb27d097457dac36f7273ffa875a93b5884c/cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e", size = 3981078, upload-time = "2026-01-28T00:23:17.838Z" }, + { url = "https://files.pythonhosted.org/packages/e6/89/f7bac81d66ba7cde867a743ea5b37537b32b5c633c473002b26a226f703f/cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c", size = 4276213, upload-time = "2026-01-28T00:23:19.257Z" }, + { url = "https://files.pythonhosted.org/packages/da/9f/7133e41f24edd827020ad21b068736e792bc68eecf66d93c924ad4719fb3/cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32", size = 4912190, upload-time = "2026-01-28T00:23:21.244Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f7/6d43cbaddf6f65b24816e4af187d211f0bc536a29961f69faedc48501d8e/cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616", size = 4454641, upload-time = "2026-01-28T00:23:22.866Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4f/ebd0473ad656a0ac912a16bd07db0f5d85184924e14fc88feecae2492834/cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0", size = 4405159, upload-time = "2026-01-28T00:23:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f7/7923886f32dc47e27adeff8246e976d77258fd2aa3efdd1754e4e323bf49/cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0", size = 4666059, upload-time = "2026-01-28T00:23:26.766Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a7/0fca0fd3591dffc297278a61813d7f661a14243dd60f499a7a5b48acb52a/cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5", size = 3026378, upload-time = "2026-01-28T00:23:28.317Z" }, + { url = "https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b", size = 3478614, upload-time = "2026-01-28T00:23:30.275Z" }, + { url = "https://files.pythonhosted.org/packages/b9/27/542b029f293a5cce59349d799d4d8484b3b1654a7b9a0585c266e974a488/cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908", size = 7116417, upload-time = "2026-01-28T00:23:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f5/559c25b77f40b6bf828eabaf988efb8b0e17b573545edb503368ca0a2a03/cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da", size = 4264508, upload-time = "2026-01-28T00:23:34.264Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/551fa162d33074b660dc35c9bc3616fefa21a0e8c1edd27b92559902e408/cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829", size = 4409080, upload-time = "2026-01-28T00:23:35.793Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/4d8d129a755f5d6df1bbee69ea2f35ebfa954fa1847690d1db2e8bca46a5/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2", size = 4270039, upload-time = "2026-01-28T00:23:37.263Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f5/ed3fcddd0a5e39321e595e144615399e47e7c153a1fb8c4862aec3151ff9/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085", size = 4926748, upload-time = "2026-01-28T00:23:38.884Z" }, + { url = "https://files.pythonhosted.org/packages/43/ae/9f03d5f0c0c00e85ecb34f06d3b79599f20630e4db91b8a6e56e8f83d410/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b", size = 4442307, upload-time = "2026-01-28T00:23:40.56Z" }, + { url = "https://files.pythonhosted.org/packages/8b/22/e0f9f2dae8040695103369cf2283ef9ac8abe4d51f68710bec2afd232609/cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd", size = 3959253, upload-time = "2026-01-28T00:23:42.827Z" }, + { url = "https://files.pythonhosted.org/packages/01/5b/6a43fcccc51dae4d101ac7d378a8724d1ba3de628a24e11bf2f4f43cba4d/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2", size = 4269372, upload-time = "2026-01-28T00:23:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/17/b7/0f6b8c1dd0779df2b526e78978ff00462355e31c0a6f6cff8a3e99889c90/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e", size = 4891908, upload-time = "2026-01-28T00:23:46.48Z" }, + { url = "https://files.pythonhosted.org/packages/83/17/259409b8349aa10535358807a472c6a695cf84f106022268d31cea2b6c97/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f", size = 4441254, upload-time = "2026-01-28T00:23:48.403Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fe/e4a1b0c989b00cee5ffa0764401767e2d1cf59f45530963b894129fd5dce/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82", size = 4396520, upload-time = "2026-01-28T00:23:50.26Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/ba8fd9657d27076eb40d6a2f941b23429a3c3d2f56f5a921d6b936a27bc9/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c", size = 4651479, upload-time = "2026-01-28T00:23:51.674Z" }, + { url = "https://files.pythonhosted.org/packages/00/03/0de4ed43c71c31e4fe954edd50b9d28d658fef56555eba7641696370a8e2/cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061", size = 3001986, upload-time = "2026-01-28T00:23:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/5c/70/81830b59df7682917d7a10f833c4dab2a5574cd664e86d18139f2b421329/cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7", size = 3468288, upload-time = "2026-01-28T00:23:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/56/f7/f648fdbb61d0d45902d3f374217451385edc7e7768d1b03ff1d0e5ffc17b/cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab", size = 7169583, upload-time = "2026-01-28T00:23:56.558Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cc/8f3224cbb2a928de7298d6ed4790f5ebc48114e02bdc9559196bfb12435d/cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef", size = 4275419, upload-time = "2026-01-28T00:23:58.364Z" }, + { url = "https://files.pythonhosted.org/packages/17/43/4a18faa7a872d00e4264855134ba82d23546c850a70ff209e04ee200e76f/cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d", size = 4419058, upload-time = "2026-01-28T00:23:59.867Z" }, + { url = "https://files.pythonhosted.org/packages/ee/64/6651969409821d791ba12346a124f55e1b76f66a819254ae840a965d4b9c/cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973", size = 4278151, upload-time = "2026-01-28T00:24:01.731Z" }, + { url = "https://files.pythonhosted.org/packages/20/0b/a7fce65ee08c3c02f7a8310cc090a732344066b990ac63a9dfd0a655d321/cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4", size = 4939441, upload-time = "2026-01-28T00:24:03.175Z" }, + { url = "https://files.pythonhosted.org/packages/db/a7/20c5701e2cd3e1dfd7a19d2290c522a5f435dd30957d431dcb531d0f1413/cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af", size = 4451617, upload-time = "2026-01-28T00:24:05.403Z" }, + { url = "https://files.pythonhosted.org/packages/00/dc/3e16030ea9aa47b63af6524c354933b4fb0e352257c792c4deeb0edae367/cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263", size = 3977774, upload-time = "2026-01-28T00:24:06.851Z" }, + { url = "https://files.pythonhosted.org/packages/42/c8/ad93f14118252717b465880368721c963975ac4b941b7ef88f3c56bf2897/cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095", size = 4277008, upload-time = "2026-01-28T00:24:08.926Z" }, + { url = "https://files.pythonhosted.org/packages/00/cf/89c99698151c00a4631fbfcfcf459d308213ac29e321b0ff44ceeeac82f1/cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b", size = 4903339, upload-time = "2026-01-28T00:24:12.009Z" }, + { url = "https://files.pythonhosted.org/packages/03/c3/c90a2cb358de4ac9309b26acf49b2a100957e1ff5cc1e98e6c4996576710/cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019", size = 4451216, upload-time = "2026-01-28T00:24:13.975Z" }, + { url = "https://files.pythonhosted.org/packages/96/2c/8d7f4171388a10208671e181ca43cdc0e596d8259ebacbbcfbd16de593da/cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4", size = 4404299, upload-time = "2026-01-28T00:24:16.169Z" }, + { url = "https://files.pythonhosted.org/packages/e9/23/cbb2036e450980f65c6e0a173b73a56ff3bccd8998965dea5cc9ddd424a5/cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", size = 4664837, upload-time = "2026-01-28T00:24:17.629Z" }, + { url = "https://files.pythonhosted.org/packages/0a/21/f7433d18fe6d5845329cbdc597e30caf983229c7a245bcf54afecc555938/cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", size = 3009779, upload-time = "2026-01-28T00:24:20.198Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" }, + { url = "https://files.pythonhosted.org/packages/59/e0/f9c6c53e1f2a1c2507f00f2faba00f01d2f334b35b0fbfe5286715da2184/cryptography-46.0.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b", size = 3476316, upload-time = "2026-01-28T00:24:24.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/7a/f8d2d13227a9a1a9fe9c7442b057efecffa41f1e3c51d8622f26b9edbe8f/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da", size = 4216693, upload-time = "2026-01-28T00:24:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/c5/de/3787054e8f7972658370198753835d9d680f6cd4a39df9f877b57f0dd69c/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80", size = 4382765, upload-time = "2026-01-28T00:24:27.577Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/60e0afb019973ba6a0b322e86b3d61edf487a4f5597618a430a2a15f2d22/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822", size = 4216066, upload-time = "2026-01-28T00:24:29.056Z" }, + { url = "https://files.pythonhosted.org/packages/81/8e/bf4a0de294f147fee66f879d9bae6f8e8d61515558e3d12785dd90eca0be/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947", size = 4382025, upload-time = "2026-01-28T00:24:30.681Z" }, + { url = "https://files.pythonhosted.org/packages/79/f4/9ceb90cfd6a3847069b0b0b353fd3075dc69b49defc70182d8af0c4ca390/cryptography-46.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3", size = 3406043, upload-time = "2026-01-28T00:24:32.236Z" }, +] + +[[package]] +name = "cyclopts" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/93/6085aa89c3fff78a5180987354538d72e43b0db27e66a959302d0c07821a/cyclopts-4.5.1.tar.gz", hash = "sha256:fadc45304763fd9f5d6033727f176898d17a1778e194436964661a005078a3dd", size = 162075, upload-time = "2026-01-25T15:23:54.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/7c/996760c30f1302704af57c66ff2d723f7d656d0d0b93563b5528a51484bb/cyclopts-4.5.1-py3-none-any.whl", hash = "sha256:0642c93601e554ca6b7b9abd81093847ea4448b2616280f2a0952416574e8c7a", size = 199807, upload-time = "2026-01-25T15:23:55.219Z" }, +] + +[[package]] +name = "diskcache" +version = "5.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fakeredis" +version = "2.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "redis" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/f9/57464119936414d60697fcbd32f38909bb5688b616ae13de6e98384433e0/fakeredis-2.33.0.tar.gz", hash = "sha256:d7bc9a69d21df108a6451bbffee23b3eba432c21a654afc7ff2d295428ec5770", size = 175187, upload-time = "2025-12-16T19:45:52.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/78/a850fed8aeef96d4a99043c90b818b2ed5419cd5b24a4049fd7cfb9f1471/fakeredis-2.33.0-py3-none-any.whl", hash = "sha256:de535f3f9ccde1c56672ab2fdd6a8efbc4f2619fc2f1acc87b8737177d71c965", size = 119605, upload-time = "2025-12-16T19:45:51.08Z" }, +] + +[package.optional-dependencies] +lua = [ + { name = "lupa" }, +] + +[[package]] +name = "fastmcp" +version = "2.14.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "authlib" }, + { name = "cyclopts" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "jsonref" }, + { name = "jsonschema-path" }, + { name = "mcp" }, + { name = "openapi-pydantic" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "py-key-value-aio", extra = ["disk", "keyring", "memory"] }, + { name = "pydantic", extra = ["email"] }, + { name = "pydocket" }, + { name = "pyperclip" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "uvicorn" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/a9/a57d5e5629ebd4ef82b495a7f8e346ce29ef80cc86b15c8c40570701b94d/fastmcp-2.14.4.tar.gz", hash = "sha256:c01f19845c2adda0a70d59525c9193be64a6383014c8d40ce63345ac664053ff", size = 8302239, upload-time = "2026-01-22T17:29:37.024Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/41/c4d407e2218fd60d84acb6cc5131d28ff876afecf325e3fd9d27b8318581/fastmcp-2.14.4-py3-none-any.whl", hash = "sha256:5858cff5e4c8ea8107f9bca2609d71d6256e0fce74495912f6e51625e466c49a", size = 417788, upload-time = "2026-01-22T17:29:35.159Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +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 = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/9c/a788f5bb29c61e456b8ee52ce76dbdd32fd72cd73dd67bc95f42c7a8d13c/jaraco_context-6.1.0.tar.gz", hash = "sha256:129a341b0a85a7db7879e22acd66902fda67882db771754574338898b2d5d86f", size = 15850, upload-time = "2026-01-13T02:53:53.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/48/aa685dbf1024c7bd82bede569e3a85f82c32fd3d79ba5fea578f0159571a/jaraco_context-6.1.0-py3-none-any.whl", hash = "sha256:a43b5ed85815223d0d3cfdb6d7ca0d2bc8946f28f30b6f3216bda070f68badda", size = 7065, upload-time = "2026-01-13T02:53:53.031Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-path" +version = "0.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/45/41ebc679c2a4fced6a722f624c18d658dee42612b83ea24c1caf7c0eb3a8/jsonschema_path-0.3.4.tar.gz", hash = "sha256:8365356039f16cc65fddffafda5f58766e34bebab7d6d105616ab52bc4297001", size = 11159, upload-time = "2025-01-24T14:33:16.547Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/58/3485da8cb93d2f393bce453adeef16896751f14ba3e2024bc21dc9597646/jsonschema_path-0.3.4-py3-none-any.whl", hash = "sha256:f502191fdc2b22050f9a81c9237be9d27145b9001c55842bece5e94e382e52f8", size = 14810, upload-time = "2025-01-24T14:33:14.652Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + +[[package]] +name = "lupa" +version = "2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/1c/191c3e6ec6502e3dbe25a53e27f69a5daeac3e56de1f73c0138224171ead/lupa-2.6.tar.gz", hash = "sha256:9a770a6e89576be3447668d7ced312cd6fd41d3c13c2462c9dc2c2ab570e45d9", size = 7240282, upload-time = "2025-10-24T07:20:29.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/29/1f66907c1ebf1881735afa695e646762c674f00738ebf66d795d59fc0665/lupa-2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6d988c0f9331b9f2a5a55186701a25444ab10a1432a1021ee58011499ecbbdd5", size = 962875, upload-time = "2025-10-24T07:17:39.107Z" }, + { url = "https://files.pythonhosted.org/packages/e6/67/4a748604be360eb9c1c215f6a0da921cd1a2b44b2c5951aae6fb83019d3a/lupa-2.6-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:ebe1bbf48259382c72a6fe363dea61a0fd6fe19eab95e2ae881e20f3654587bf", size = 1935390, upload-time = "2025-10-24T07:17:41.427Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/8ef9ee933a350428b7bdb8335a37ef170ab0bb008bbf9ca8f4f4310116b6/lupa-2.6-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:a8fcee258487cf77cdd41560046843bb38c2e18989cd19671dd1e2596f798306", size = 992193, upload-time = "2025-10-24T07:17:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/65/46/e6c7facebdb438db8a65ed247e56908818389c1a5abbf6a36aab14f1057d/lupa-2.6-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:561a8e3be800827884e767a694727ed8482d066e0d6edfcbf423b05e63b05535", size = 1165844, upload-time = "2025-10-24T07:17:45.437Z" }, + { url = "https://files.pythonhosted.org/packages/1c/26/9f1154c6c95f175ccbf96aa96c8f569c87f64f463b32473e839137601a8b/lupa-2.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af880a62d47991cae78b8e9905c008cbfdc4a3a9723a66310c2634fc7644578c", size = 1048069, upload-time = "2025-10-24T07:17:47.181Z" }, + { url = "https://files.pythonhosted.org/packages/68/67/2cc52ab73d6af81612b2ea24c870d3fa398443af8e2875e5befe142398b1/lupa-2.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80b22923aa4023c86c0097b235615f89d469a0c4eee0489699c494d3367c4c85", size = 2079079, upload-time = "2025-10-24T07:17:49.755Z" }, + { url = "https://files.pythonhosted.org/packages/2e/dc/f843f09bbf325f6e5ee61730cf6c3409fc78c010d968c7c78acba3019ca7/lupa-2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:153d2cc6b643f7efb9cfc0c6bb55ec784d5bac1a3660cfc5b958a7b8f38f4a75", size = 1071428, upload-time = "2025-10-24T07:17:51.991Z" }, + { url = "https://files.pythonhosted.org/packages/2e/60/37533a8d85bf004697449acb97ecdacea851acad28f2ad3803662487dd2a/lupa-2.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3fa8777e16f3ded50b72967dc17e23f5a08e4f1e2c9456aff2ebdb57f5b2869f", size = 1181756, upload-time = "2025-10-24T07:17:53.752Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f2/cf29b20dbb4927b6a3d27c339ac5d73e74306ecc28c8e2c900b2794142ba/lupa-2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8dbdcbe818c02a2f56f5ab5ce2de374dab03e84b25266cfbaef237829bc09b3f", size = 2175687, upload-time = "2025-10-24T07:17:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/050e02f80c7131b63db1474bff511e63c545b5a8636a24cbef3fc4da20b6/lupa-2.6-cp311-cp311-win32.whl", hash = "sha256:defaf188fde8f7a1e5ce3a5e6d945e533b8b8d547c11e43b96c9b7fe527f56dc", size = 1412592, upload-time = "2025-10-24T07:17:59.062Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/6f2af98aa5d771cea661f66c8eb8f53772ec1ab1dfbce24126cfcd189436/lupa-2.6-cp311-cp311-win_amd64.whl", hash = "sha256:9505ae600b5c14f3e17e70f87f88d333717f60411faca1ddc6f3e61dce85fa9e", size = 1669194, upload-time = "2025-10-24T07:18:01.647Z" }, + { url = "https://files.pythonhosted.org/packages/94/86/ce243390535c39d53ea17ccf0240815e6e457e413e40428a658ea4ee4b8d/lupa-2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47ce718817ef1cc0c40d87c3d5ae56a800d61af00fbc0fad1ca9be12df2f3b56", size = 951707, upload-time = "2025-10-24T07:18:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/cedea5e6cbeb54396fdcc55f6b741696f3f036d23cfaf986d50d680446da/lupa-2.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7aba985b15b101495aa4b07112cdc08baa0c545390d560ad5cfde2e9e34f4d58", size = 1916703, upload-time = "2025-10-24T07:18:05.6Z" }, + { url = "https://files.pythonhosted.org/packages/24/be/3d6b5f9a8588c01a4d88129284c726017b2089f3a3fd3ba8bd977292fea0/lupa-2.6-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:b766f62f95b2739f2248977d29b0722e589dcf4f0ccfa827ccbd29f0148bd2e5", size = 985152, upload-time = "2025-10-24T07:18:08.561Z" }, + { url = "https://files.pythonhosted.org/packages/eb/23/9f9a05beee5d5dce9deca4cb07c91c40a90541fc0a8e09db4ee670da550f/lupa-2.6-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:00a934c23331f94cb51760097ebfab14b005d55a6b30a2b480e3c53dd2fa290d", size = 1159599, upload-time = "2025-10-24T07:18:10.346Z" }, + { url = "https://files.pythonhosted.org/packages/40/4e/e7c0583083db9d7f1fd023800a9767d8e4391e8330d56c2373d890ac971b/lupa-2.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21de9f38bd475303e34a042b7081aabdf50bd9bafd36ce4faea2f90fd9f15c31", size = 1038686, upload-time = "2025-10-24T07:18:12.112Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/5a4f7d959d4feba5e203ff0c31889e74d1ca3153122be4a46dca7d92bf7c/lupa-2.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf3bda96d3fc41237e964a69c23647d50d4e28421111360274d4799832c560e9", size = 2071956, upload-time = "2025-10-24T07:18:14.572Z" }, + { url = "https://files.pythonhosted.org/packages/92/34/2f4f13ca65d01169b1720176aedc4af17bc19ee834598c7292db232cb6dc/lupa-2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a76ead245da54801a81053794aa3975f213221f6542d14ec4b859ee2e7e0323", size = 1057199, upload-time = "2025-10-24T07:18:16.379Z" }, + { url = "https://files.pythonhosted.org/packages/35/2a/5f7d2eebec6993b0dcd428e0184ad71afb06a45ba13e717f6501bfed1da3/lupa-2.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8dd0861741caa20886ddbda0a121d8e52fb9b5bb153d82fa9bba796962bf30e8", size = 1173693, upload-time = "2025-10-24T07:18:18.153Z" }, + { url = "https://files.pythonhosted.org/packages/e4/29/089b4d2f8e34417349af3904bb40bec40b65c8731f45e3fd8d497ca573e5/lupa-2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:239e63948b0b23023f81d9a19a395e768ed3da6a299f84e7963b8f813f6e3f9c", size = 2164394, upload-time = "2025-10-24T07:18:20.403Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1b/79c17b23c921f81468a111cad843b076a17ef4b684c4a8dff32a7969c3f0/lupa-2.6-cp312-cp312-win32.whl", hash = "sha256:325894e1099499e7a6f9c351147661a2011887603c71086d36fe0f964d52d1ce", size = 1420647, upload-time = "2025-10-24T07:18:23.368Z" }, + { url = "https://files.pythonhosted.org/packages/b8/15/5121e68aad3584e26e1425a5c9a79cd898f8a152292059e128c206ee817c/lupa-2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c735a1ce8ee60edb0fe71d665f1e6b7c55c6021f1d340eb8c865952c602cd36f", size = 1688529, upload-time = "2025-10-24T07:18:25.523Z" }, + { url = "https://files.pythonhosted.org/packages/28/1d/21176b682ca5469001199d8b95fa1737e29957a3d185186e7a8b55345f2e/lupa-2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:663a6e58a0f60e7d212017d6678639ac8df0119bc13c2145029dcba084391310", size = 947232, upload-time = "2025-10-24T07:18:27.878Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4c/d327befb684660ca13cf79cd1f1d604331808f9f1b6fb6bf57832f8edf80/lupa-2.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:d1f5afda5c20b1f3217a80e9bc1b77037f8a6eb11612fd3ada19065303c8f380", size = 1908625, upload-time = "2025-10-24T07:18:29.944Z" }, + { url = "https://files.pythonhosted.org/packages/66/8e/ad22b0a19454dfd08662237a84c792d6d420d36b061f239e084f29d1a4f3/lupa-2.6-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:26f2b3c085fe76e9119e48c1013c1cccdc1f51585d456858290475aa38e7089e", size = 981057, upload-time = "2025-10-24T07:18:31.553Z" }, + { url = "https://files.pythonhosted.org/packages/5c/48/74859073ab276bd0566c719f9ca0108b0cfc1956ca0d68678d117d47d155/lupa-2.6-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:60d2f902c7b96fb8ab98493dcff315e7bb4d0b44dc9dd76eb37de575025d5685", size = 1156227, upload-time = "2025-10-24T07:18:33.981Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/0e9ded061916877253c2266074060eb71ed99fb21d73c8c114a76725bce2/lupa-2.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a02d25dee3a3250967c36590128d9220ae02f2eda166a24279da0b481519cbff", size = 1035752, upload-time = "2025-10-24T07:18:36.32Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ef/f8c32e454ef9f3fe909f6c7d57a39f950996c37a3deb7b391fec7903dab7/lupa-2.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eae1ee16b886b8914ff292dbefbf2f48abfbdee94b33a88d1d5475e02423203", size = 2069009, upload-time = "2025-10-24T07:18:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/15b80c226a5225815a890ee1c11f07968e0aba7a852df41e8ae6fe285063/lupa-2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0edd5073a4ee74ab36f74fe61450148e6044f3952b8d21248581f3c5d1a58be", size = 1056301, upload-time = "2025-10-24T07:18:40.165Z" }, + { url = "https://files.pythonhosted.org/packages/31/14/2086c1425c985acfb30997a67e90c39457122df41324d3c179d6ee2292c6/lupa-2.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0c53ee9f22a8a17e7d4266ad48e86f43771951797042dd51d1494aaa4f5f3f0a", size = 1170673, upload-time = "2025-10-24T07:18:42.426Z" }, + { url = "https://files.pythonhosted.org/packages/10/e5/b216c054cf86576c0191bf9a9f05de6f7e8e07164897d95eea0078dca9b2/lupa-2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:de7c0f157a9064a400d828789191a96da7f4ce889969a588b87ec80de9b14772", size = 2162227, upload-time = "2025-10-24T07:18:46.112Z" }, + { url = "https://files.pythonhosted.org/packages/59/2f/33ecb5bedf4f3bc297ceacb7f016ff951331d352f58e7e791589609ea306/lupa-2.6-cp313-cp313-win32.whl", hash = "sha256:ee9523941ae0a87b5b703417720c5d78f72d2f5bc23883a2ea80a949a3ed9e75", size = 1419558, upload-time = "2025-10-24T07:18:48.371Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/55e885834c847ea610e111d87b9ed4768f0afdaeebc00cd46810f25029f6/lupa-2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b1335a5835b0a25ebdbc75cf0bda195e54d133e4d994877ef025e218c2e59db9", size = 1683424, upload-time = "2025-10-24T07:18:50.976Z" }, + { url = "https://files.pythonhosted.org/packages/66/9d/d9427394e54d22a35d1139ef12e845fd700d4872a67a34db32516170b746/lupa-2.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcb6d0a3264873e1653bc188499f48c1fb4b41a779e315eba45256cfe7bc33c1", size = 953818, upload-time = "2025-10-24T07:18:53.378Z" }, + { url = "https://files.pythonhosted.org/packages/10/41/27bbe81953fb2f9ecfced5d9c99f85b37964cfaf6aa8453bb11283983721/lupa-2.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:a37e01f2128f8c36106726cb9d360bac087d58c54b4522b033cc5691c584db18", size = 1915850, upload-time = "2025-10-24T07:18:55.259Z" }, + { url = "https://files.pythonhosted.org/packages/a3/98/f9ff60db84a75ba8725506bbf448fb085bc77868a021998ed2a66d920568/lupa-2.6-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:458bd7e9ff3c150b245b0fcfbb9bd2593d1152ea7f0a7b91c1d185846da033fe", size = 982344, upload-time = "2025-10-24T07:18:57.05Z" }, + { url = "https://files.pythonhosted.org/packages/41/f7/f39e0f1c055c3b887d86b404aaf0ca197b5edfd235a8b81b45b25bac7fc3/lupa-2.6-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:052ee82cac5206a02df77119c325339acbc09f5ce66967f66a2e12a0f3211cad", size = 1156543, upload-time = "2025-10-24T07:18:59.251Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9c/59e6cffa0d672d662ae17bd7ac8ecd2c89c9449dee499e3eb13ca9cd10d9/lupa-2.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96594eca3c87dd07938009e95e591e43d554c1dbd0385be03c100367141db5a8", size = 1047974, upload-time = "2025-10-24T07:19:01.449Z" }, + { url = "https://files.pythonhosted.org/packages/23/c6/a04e9cef7c052717fcb28fb63b3824802488f688391895b618e39be0f684/lupa-2.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8faddd9d198688c8884091173a088a8e920ecc96cda2ffed576a23574c4b3f6", size = 2073458, upload-time = "2025-10-24T07:19:03.369Z" }, + { url = "https://files.pythonhosted.org/packages/e6/10/824173d10f38b51fc77785228f01411b6ca28826ce27404c7c912e0e442c/lupa-2.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:daebb3a6b58095c917e76ba727ab37b27477fb926957c825205fbda431552134", size = 1067683, upload-time = "2025-10-24T07:19:06.2Z" }, + { url = "https://files.pythonhosted.org/packages/b6/dc/9692fbcf3c924d9c4ece2d8d2f724451ac2e09af0bd2a782db1cef34e799/lupa-2.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f3154e68972befe0f81564e37d8142b5d5d79931a18309226a04ec92487d4ea3", size = 1171892, upload-time = "2025-10-24T07:19:08.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/e318b628d4643c278c96ab3ddea07fc36b075a57383c837f5b11e537ba9d/lupa-2.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e4dadf77b9fedc0bfa53417cc28dc2278a26d4cbd95c29f8927ad4d8fe0a7ef9", size = 2166641, upload-time = "2025-10-24T07:19:10.485Z" }, + { url = "https://files.pythonhosted.org/packages/12/f7/a6f9ec2806cf2d50826980cdb4b3cffc7691dc6f95e13cc728846d5cb793/lupa-2.6-cp314-cp314-win32.whl", hash = "sha256:cb34169c6fa3bab3e8ac58ca21b8a7102f6a94b6a5d08d3636312f3f02fafd8f", size = 1456857, upload-time = "2025-10-24T07:19:37.989Z" }, + { url = "https://files.pythonhosted.org/packages/c5/de/df71896f25bdc18360fdfa3b802cd7d57d7fede41a0e9724a4625b412c85/lupa-2.6-cp314-cp314-win_amd64.whl", hash = "sha256:b74f944fe46c421e25d0f8692aef1e842192f6f7f68034201382ac440ef9ea67", size = 1731191, upload-time = "2025-10-24T07:19:40.281Z" }, + { url = "https://files.pythonhosted.org/packages/47/3c/a1f23b01c54669465f5f4c4083107d496fbe6fb45998771420e9aadcf145/lupa-2.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0e21b716408a21ab65723f8841cf7f2f37a844b7a965eeabb785e27fca4099cf", size = 999343, upload-time = "2025-10-24T07:19:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6d/501994291cb640bfa2ccf7f554be4e6914afa21c4026bd01bff9ca8aac57/lupa-2.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:589db872a141bfff828340079bbdf3e9a31f2689f4ca0d88f97d9e8c2eae6142", size = 2000730, upload-time = "2025-10-24T07:19:14.869Z" }, + { url = "https://files.pythonhosted.org/packages/53/a5/457ffb4f3f20469956c2d4c4842a7675e884efc895b2f23d126d23e126cc/lupa-2.6-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:cd852a91a4a9d4dcbb9a58100f820a75a425703ec3e3f049055f60b8533b7953", size = 1021553, upload-time = "2025-10-24T07:19:17.123Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/36bb5a5d0960f2a5c7c700e0819abb76fd9bf9c1d8a66e5106416d6e9b14/lupa-2.6-cp314-cp314t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:0334753be028358922415ca97a64a3048e4ed155413fc4eaf87dd0a7e2752983", size = 1133275, upload-time = "2025-10-24T07:19:20.51Z" }, + { url = "https://files.pythonhosted.org/packages/19/86/202ff4429f663013f37d2229f6176ca9f83678a50257d70f61a0a97281bf/lupa-2.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:661d895cd38c87658a34780fac54a690ec036ead743e41b74c3fb81a9e65a6aa", size = 1038441, upload-time = "2025-10-24T07:19:22.509Z" }, + { url = "https://files.pythonhosted.org/packages/a7/42/d8125f8e420714e5b52e9c08d88b5329dfb02dcca731b4f21faaee6cc5b5/lupa-2.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aa58454ccc13878cc177c62529a2056be734da16369e451987ff92784994ca7", size = 2058324, upload-time = "2025-10-24T07:19:24.979Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2c/47bf8b84059876e877a339717ddb595a4a7b0e8740bacae78ba527562e1c/lupa-2.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1425017264e470c98022bba8cff5bd46d054a827f5df6b80274f9cc71dafd24f", size = 1060250, upload-time = "2025-10-24T07:19:27.262Z" }, + { url = "https://files.pythonhosted.org/packages/c2/06/d88add2b6406ca1bdec99d11a429222837ca6d03bea42ca75afa169a78cb/lupa-2.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:224af0532d216e3105f0a127410f12320f7c5f1aa0300bdf9646b8d9afb0048c", size = 1151126, upload-time = "2025-10-24T07:19:29.522Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a0/89e6a024c3b4485b89ef86881c9d55e097e7cb0bdb74efb746f2fa6a9a76/lupa-2.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9abb98d5a8fd27c8285302e82199f0e56e463066f88f619d6594a450bf269d80", size = 2153693, upload-time = "2025-10-24T07:19:31.379Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/a0f007dc58fc1bbf51fb85dcc82fcb1f21b8c4261361de7dab0e3d8521ef/lupa-2.6-cp314-cp314t-win32.whl", hash = "sha256:1849efeba7a8f6fb8aa2c13790bee988fd242ae404bd459509640eeea3d1e291", size = 1590104, upload-time = "2025-10-24T07:19:33.514Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5e/db903ce9cf82c48d6b91bf6d63ae4c8d0d17958939a4e04ba6b9f38b8643/lupa-2.6-cp314-cp314t-win_amd64.whl", hash = "sha256:fc1498d1a4fc028bc521c26d0fad4ca00ed63b952e32fb95949bda76a04bad52", size = 1913818, upload-time = "2025-10-24T07:19:36.039Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mcbluetooth-esp32" +version = "2026.2.2" +source = { editable = "." } +dependencies = [ + { name = "fastmcp" }, + { name = "pyserial-asyncio" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastmcp", specifier = ">=2.14.4" }, + { name = "pyserial-asyncio", specifier = ">=0.6" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8" }, +] +provides-extras = ["dev"] + +[[package]] +name = "mcp" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +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 = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, +] + +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, +] + +[[package]] +name = "opentelemetry-exporter-prometheus" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "prometheus-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/39/7dafa6fff210737267bed35a8855b6ac7399b9e582b8cf1f25f842517012/opentelemetry_exporter_prometheus-0.60b1.tar.gz", hash = "sha256:a4011b46906323f71724649d301b4dc188aaa068852e814f4df38cc76eac616b", size = 14976, upload-time = "2025-12-11T13:32:42.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/0d/4be6bf5477a3eb3d917d2f17d3c0b6720cd6cb97898444a61d43cc983f5c/opentelemetry_exporter_prometheus-0.60b1-py3-none-any.whl", hash = "sha256:49f59178de4f4590e3cef0b8b95cf6e071aae70e1f060566df5546fad773b8fd", size = 13019, upload-time = "2025-12-11T13:32:23.974Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706, upload-time = "2025-12-11T13:36:42.515Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096, upload-time = "2025-12-11T13:35:33.067Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pathable" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/67/93/8f2c2075b180c12c1e9f6a09d1a985bc2036906b13dff1d8917e395f2048/pathable-0.4.4.tar.gz", hash = "sha256:6905a3cd17804edfac7875b5f6c9142a218c7caef78693c2dbbbfbac186d88b2", size = 8124, upload-time = "2025-01-10T18:43:13.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" }, +] + +[[package]] +name = "pathvalidate" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prometheus-client" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" }, +] + +[[package]] +name = "py-key-value-aio" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "py-key-value-shared" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/ce/3136b771dddf5ac905cc193b461eb67967cf3979688c6696e1f2cdcde7ea/py_key_value_aio-0.3.0.tar.gz", hash = "sha256:858e852fcf6d696d231266da66042d3355a7f9871650415feef9fca7a6cd4155", size = 50801, upload-time = "2025-11-17T16:50:04.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/10/72f6f213b8f0bce36eff21fda0a13271834e9eeff7f9609b01afdc253c79/py_key_value_aio-0.3.0-py3-none-any.whl", hash = "sha256:1c781915766078bfd608daa769fefb97e65d1d73746a3dfb640460e322071b64", size = 96342, upload-time = "2025-11-17T16:50:03.801Z" }, +] + +[package.optional-dependencies] +disk = [ + { name = "diskcache" }, + { name = "pathvalidate" }, +] +keyring = [ + { name = "keyring" }, +] +memory = [ + { name = "cachetools" }, +] +redis = [ + { name = "redis" }, +] + +[[package]] +name = "py-key-value-shared" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/e4/1971dfc4620a3a15b4579fe99e024f5edd6e0967a71154771a059daff4db/py_key_value_shared-0.3.0.tar.gz", hash = "sha256:8fdd786cf96c3e900102945f92aa1473138ebe960ef49da1c833790160c28a4b", size = 11666, upload-time = "2025-11-17T16:50:06.849Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/e4/b8b0a03ece72f47dce2307d36e1c34725b7223d209fc679315ffe6a4e2c3/py_key_value_shared-0.3.0-py3-none-any.whl", hash = "sha256:5b0efba7ebca08bb158b1e93afc2f07d30b8f40c2fc12ce24a4c0d84f42f9298", size = 19560, upload-time = "2025-11-17T16:50:05.954Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + +[[package]] +name = "pydocket" +version = "0.16.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cloudpickle" }, + { name = "fakeredis", extra = ["lua"] }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-prometheus" }, + { name = "opentelemetry-instrumentation" }, + { name = "prometheus-client" }, + { name = "py-key-value-aio", extra = ["memory", "redis"] }, + { name = "python-json-logger" }, + { name = "redis" }, + { name = "rich" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/00/26befe5f58df7cd1aeda4a8d10bc7d1908ffd86b80fd995e57a2a7b3f7bd/pydocket-0.16.6.tar.gz", hash = "sha256:b96c96ad7692827214ed4ff25fcf941ec38371314db5dcc1ae792b3e9d3a0294", size = 299054, upload-time = "2026-01-09T22:09:15.405Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/3f/7483e5a6dc6326b6e0c640619b5c5bd1d6e3c20e54d58f5fb86267cef00e/pydocket-0.16.6-py3-none-any.whl", hash = "sha256:683d21e2e846aa5106274e7d59210331b242d7fb0dce5b08d3b82065663ed183", size = 67697, upload-time = "2026-01-09T22:09:13.436Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +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 = "pyjwt" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" }, +] + +[[package]] +name = "pyserial-asyncio" +version = "0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyserial" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/9a/8477699dcbc1882ea51dcff4d3c25aa3f2063ed8f7d7a849fd8f610506b6/pyserial-asyncio-0.6.tar.gz", hash = "sha256:b6032923e05e9d75ec17a5af9a98429c46d2839adfaf80604d52e0faacd7a32f", size = 31322, upload-time = "2021-09-30T22:29:02.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/24/c820cf15f87f7b164e83710c1852d4f900d9793961579e5ef64189bc0c10/pyserial_asyncio-0.6-py3-none-any.whl", hash = "sha256:de9337922619421b62b9b1a84048634b3ac520e1d690a674ed246a2af7ce1fc5", size = 7594, upload-time = "2021-09-30T22:29:00.12Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "python-json-logger" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "redis" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, +] + +[[package]] +name = "referencing" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rich" +version = "14.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, +] + +[[package]] +name = "rich-rst" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" }, + { url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" }, + { url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" }, + { url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" }, + { url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/8d/00d280c03ffd39aaee0e86ec81e2d3b9253036a0f93f51d10503adef0e65/sse_starlette-3.2.0.tar.gz", hash = "sha256:8127594edfb51abe44eac9c49e59b0b01f1039d0c7461c6fd91d4e03b70da422", size = 27253, upload-time = "2026-01-17T13:11:05.62Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl", hash = "sha256:5876954bd51920fc2cd51baee47a080eb88a37b5b784e615abb0b283f801cdbf", size = 12763, upload-time = "2026-01-17T13:11:03.775Z" }, +] + +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + +[[package]] +name = "typer" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, + { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, + { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, + { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +]