Prometheus exporter for HomeHub 4000 router

This commit is contained in:
Isaac Mallampati
2026-01-05 20:47:00 -05:00
commit cf3a73d38c
24 changed files with 2933 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
.DS_Store
.env
.claude/
*.db
.venv/
.ruff_cache/
.pytest_cache/
debug_html/
session-*.md

27
Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
FROM python:3.13-slim
WORKDIR /app
# Install playwright dependencies and curl for health checks
RUN apt-get update && apt-get install -y --no-install-recommends \
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
libgbm1 libasound2 libpango-1.0-0 libcairo2 curl \
&& rm -rf /var/lib/apt/lists/*
# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
# Copy and install
COPY pyproject.toml uv.lock ./
COPY src/ ./src/
RUN uv sync --frozen && uv run playwright install chromium
ENV EXPORTER_PORT=9100
ENV HEADLESS_BROWSER=true
EXPOSE 9100
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost:9100/metrics || exit 1
ENTRYPOINT ["uv", "run", "python", "src/exporter.py"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 HomeHub Exporter Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

93
README.md Normal file
View File

@@ -0,0 +1,93 @@
# HomeHub 4000 Prometheus Exporter
Prometheus exporter for the [Virgin Plus Vincent](https://www.virginplus.ca/en/support/internet/modem-info-vincent.html) / [Bell HomeHub 4000](https://support.bell.ca/internet/products/home-hub-4000-modem) router. Uses Playwright to scrape the admin UI and expose metrics for Prometheus. Device events are stored in SQLite so other tools can query them independently of Prometheus.
<details>
<summary><strong>Grafana dashboard (included)</strong></summary>
![Grafana Dashboard](docs/grafana-dashboard.png)
</details>
<details>
<summary><strong>Architecture diagram</strong></summary>
```mermaid
flowchart LR
subgraph exporter.py
direction TB
prom[Prometheus Server]
scraper[router_scraper.py]
poller[router_log_fetcher.py]
end
subgraph Router["HomeHub 4000"]
mon["/monitoring"]
stats["/statistics"]
logs["/logs"]
end
scraper --> mon
scraper --> stats
poller --> logs
poller --> db[(SQLite)]
```
</details>
## Quick Start
```bash
# Install
uv sync
uv run playwright install chromium
# Run
ROUTER_PASSWORD="your_password" uv run python src/exporter.py
# Check
curl http://localhost:9100/metrics | grep homehub_
```
## Docker
```bash
cd deploy
echo "ROUTER_PASSWORD=your_password" > .env
docker-compose up -d
```
Services:
- Exporter: http://localhost:9100/metrics
- Prometheus: http://localhost:9090
- Grafana: http://localhost:3000 (pre-configured dashboard included)
Default Grafana credentials are `admin/admin`. Override with `GRAFANA_ADMIN_USER` and `GRAFANA_ADMIN_PASSWORD` in `.env`.
## Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `ROUTER_PASSWORD` | (required) | Router admin password |
| `ROUTER_IP` | 192.168.2.1 | Router IP address |
| `EXPORTER_PORT` | 9100 | Metrics port |
| `SCRAPE_INTERVAL` | 60 | Seconds between scrapes |
| `LOG_POLL_INTERVAL` | 75 | Seconds between log polls |
| `HEADLESS_BROWSER` | true | Run browser headless |
## Metrics
All metrics prefixed with `homehub_`. Covers system health (CPU, memory, load), network interfaces (status, throughput, errors), connected devices, router uptime, and log statistics. See the Grafana dashboard screenshot above or query `curl localhost:9100/metrics | grep homehub_` for the full list.
## Development
```bash
uv sync # includes dev dependencies
uv run pytest
uv run ruff check src/
```
## Troubleshooting
- Set `HEADLESS_BROWSER=false` to watch the browser and debug scraping issues
- Delete `virgin_monitor.db` to reset device history
Tested on Virgin Plus Vincent (Bell HomeHub 4000), firmware 2.13, UI 7.3.29.

7
deploy/.env.example Normal file
View File

@@ -0,0 +1,7 @@
# Router settings
ROUTER_PASSWORD=your_router_password_here
ROUTER_IP=192.168.2.1
# Grafana credentials (defaults to admin/admin if not set)
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=changeme

55
deploy/docker-compose.yml Normal file
View File

@@ -0,0 +1,55 @@
services:
exporter:
build:
context: ..
dockerfile: Dockerfile
container_name: homehub-exporter
ports:
- "9100:9100"
environment:
- ROUTER_IP=${ROUTER_IP:-192.168.2.1}
- ROUTER_PASSWORD=${ROUTER_PASSWORD}
- EXPORTER_PORT=9100
- HEADLESS_BROWSER=true
- SCRAPE_INTERVAL=60
- LOG_POLL_INTERVAL=75
- DB_PATH=/app/data/virgin_monitor.db
volumes:
- exporter-data:/app/data
restart: unless-stopped
# For accessing router on host network
extra_hosts:
- "host.docker.internal:host-gateway"
prometheus:
image: prom/prometheus:latest
container_name: homehub-prometheus
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
depends_on:
- exporter
restart: unless-stopped
grafana:
image: grafana/grafana:latest
container_name: homehub-grafana
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER:-admin}
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin}
- GF_USERS_ALLOW_SIGN_UP=false
volumes:
- grafana-data:/var/lib/grafana
- ./grafana:/etc/grafana/provisioning:ro
depends_on:
- prometheus
restart: unless-stopped
volumes:
exporter-data:
prometheus-data:
grafana-data:

View File

@@ -0,0 +1,11 @@
apiVersion: 1
providers:
- name: 'Virgin Router'
orgId: 1
folder: ''
type: file
disableDeletion: false
editable: true
options:
path: /etc/grafana/provisioning/dashboards

View File

@@ -0,0 +1,710 @@
{
"annotations": {
"list": []
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"panels": [
{
"collapsed": false,
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 0},
"id": 101,
"panels": [],
"title": "Devices",
"type": "row"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "short"
},
"overrides": []
},
"gridPos": {"h": 4, "w": 4, "x": 0, "y": 1},
"id": 1,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"textMode": "auto"
},
"pluginVersion": "10.0.0",
"targets": [{"expr": "homehub_connected_devices", "refId": "A"}],
"title": "Connected",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "yellow", "value": null}]},
"unit": "short"
},
"overrides": []
},
"gridPos": {"h": 4, "w": 4, "x": 4, "y": 1},
"id": 2,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"textMode": "auto"
},
"pluginVersion": "10.0.0",
"targets": [{"expr": "homehub_disconnected_devices", "refId": "A"}],
"title": "Disconnected",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"axisCenteredZero": false,
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {"type": "linear"},
"showPoints": "never",
"spanNulls": true
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "short"
},
"overrides": []
},
"gridPos": {"h": 4, "w": 8, "x": 0, "y": 5},
"id": 5,
"options": {
"legend": {"calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": false},
"tooltip": {"mode": "multi", "sort": "none"}
},
"pluginVersion": "10.0.0",
"targets": [
{"expr": "homehub_connected_devices", "legendFormat": "Connected", "refId": "A"},
{"expr": "homehub_disconnected_devices", "legendFormat": "Disconnected", "refId": "B"}
],
"title": "Device History",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"custom": {"align": "auto", "cellOptions": {"type": "auto"}, "inspect": false},
"mappings": [
{"options": {"0": {"color": "red", "index": 0, "text": "Offline"}}, "type": "value"},
{"options": {"1": {"color": "green", "index": 1, "text": "Online"}}, "type": "value"}
],
"thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "green", "value": 1}]}
},
"overrides": [
{"matcher": {"id": "byName", "options": "Status"}, "properties": [{"id": "custom.cellOptions", "value": {"type": "color-background"}}]},
{"matcher": {"id": "byName", "options": "Uptime"}, "properties": [{"id": "unit", "value": "s"}]}
]
},
"gridPos": {"h": 8, "w": 16, "x": 8, "y": 1},
"id": 7,
"options": {"cellHeight": "sm", "footer": {"countRows": false, "fields": "", "reducer": ["sum"], "show": false}, "showHeader": true},
"pluginVersion": "10.0.0",
"targets": [
{"expr": "homehub_device_connected", "format": "table", "instant": true, "refId": "A"},
{"expr": "homehub_device_uptime_seconds", "format": "table", "instant": true, "refId": "B"}
],
"title": "Device Status",
"transformations": [
{"id": "joinByField", "options": {"byField": "device_name", "mode": "outer"}},
{"id": "groupBy", "options": {"fields": {"device_name": {"aggregations": [], "operation": "groupby"}, "mac_address": {"aggregations": ["firstNotNull"], "operation": "aggregate"}, "mac_address 1": {"aggregations": ["firstNotNull"], "operation": "aggregate"}, "Value #A": {"aggregations": ["firstNotNull"], "operation": "aggregate"}, "Value #B": {"aggregations": ["firstNotNull"], "operation": "aggregate"}}}},
{"id": "organize", "options": {"excludeByName": {"Time": true, "Time 1": true, "Time 2": true, "__name__": true, "__name__ 1": true, "__name__ 2": true, "instance": true, "instance 1": true, "instance 2": true, "job": true, "job 1": true, "job 2": true, "mac_address 1 (firstNotNull)": true}, "indexByName": {"device_name": 0, "mac_address (firstNotNull)": 1, "Value #A (firstNotNull)": 2, "Value #B (firstNotNull)": 3}, "renameByName": {"Value #A (firstNotNull)": "Status", "Value #B (firstNotNull)": "Uptime", "device_name": "Device", "mac_address (firstNotNull)": "mac_address"}}}
],
"type": "table"
},
{
"collapsed": false,
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 9},
"id": 100,
"panels": [],
"title": "System Health",
"type": "row"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"mappings": [],
"max": 100,
"min": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": null},
{"color": "yellow", "value": 60},
{"color": "red", "value": 85}
]
},
"unit": "percent"
},
"overrides": []
},
"gridPos": {"h": 5, "w": 4, "x": 0, "y": 10},
"id": 11,
"options": {
"orientation": "auto",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"showThresholdLabels": false,
"showThresholdMarkers": true
},
"pluginVersion": "10.0.0",
"targets": [{"expr": "homehub_cpu_usage_ratio * 100", "refId": "A"}],
"title": "CPU Usage",
"type": "gauge"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"mappings": [],
"max": 100,
"min": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": null},
{"color": "yellow", "value": 70},
{"color": "red", "value": 90}
]
},
"unit": "percent"
},
"overrides": []
},
"gridPos": {"h": 5, "w": 4, "x": 4, "y": 10},
"id": 12,
"options": {
"orientation": "auto",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"showThresholdLabels": false,
"showThresholdMarkers": true
},
"pluginVersion": "10.0.0",
"targets": [{"expr": "100 - (homehub_memory_free_bytes / homehub_memory_total_bytes * 100)", "refId": "A"}],
"title": "Memory Usage",
"type": "gauge"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"axisCenteredZero": false,
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 20,
"lineInterpolation": "smooth",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {"type": "linear"},
"showPoints": "never",
"spanNulls": true
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "short"
},
"overrides": []
},
"gridPos": {"h": 5, "w": 6, "x": 11, "y": 10},
"id": 13,
"options": {
"legend": {"calcs": ["mean", "lastNotNull"], "displayMode": "list", "placement": "bottom", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"pluginVersion": "10.0.0",
"targets": [
{"expr": "homehub_load_average_1m", "legendFormat": "1m", "refId": "A"},
{"expr": "homehub_load_average_5m", "legendFormat": "5m", "refId": "B"},
{"expr": "homehub_load_average_15m", "legendFormat": "15m", "refId": "C"}
],
"title": "Load Average",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "decbytes"
},
"overrides": []
},
"gridPos": {"h": 5, "w": 5, "x": 17, "y": 10},
"id": 14,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "horizontal",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"textMode": "value_and_name"
},
"pluginVersion": "10.0.0",
"targets": [
{"expr": "homehub_memory_free_bytes", "legendFormat": "Free", "refId": "A"},
{"expr": "homehub_memory_total_bytes - homehub_memory_free_bytes", "legendFormat": "Used", "refId": "B"}
],
"title": "Memory",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"mappings": [
{"options": {"0": {"color": "red", "index": 0, "text": "Down"}}, "type": "value"},
{"options": {"1": {"color": "green", "index": 1, "text": "OK"}}, "type": "value"}
],
"thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "green", "value": 1}]},
"unit": "short"
},
"overrides": []
},
"gridPos": {"h": 5, "w": 2, "x": 22, "y": 10},
"id": 15,
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "center",
"orientation": "auto",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"textMode": "value"
},
"pluginVersion": "10.0.0",
"targets": [{"expr": "homehub_scrape_success", "refId": "A"}],
"title": "Scraper",
"type": "stat"
},
{
"collapsed": false,
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 15},
"id": 102,
"panels": [],
"title": "Network Interfaces",
"type": "row"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"custom": {"align": "auto", "cellOptions": {"type": "auto"}, "inspect": false},
"mappings": [
{"options": {"0": {"color": "red", "index": 0, "text": "Down"}}, "type": "value"},
{"options": {"1": {"color": "green", "index": 1, "text": "Up"}}, "type": "value"}
],
"thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "green", "value": 1}]}
},
"overrides": [
{"matcher": {"id": "byName", "options": "Status"}, "properties": [{"id": "custom.cellOptions", "value": {"type": "color-background"}}]},
{"matcher": {"id": "byName", "options": "Sent"}, "properties": [{"id": "unit", "value": "decbytes"}]},
{"matcher": {"id": "byName", "options": "Received"}, "properties": [{"id": "unit", "value": "decbytes"}]}
]
},
"gridPos": {"h": 8, "w": 10, "x": 2, "y": 16},
"id": 16,
"options": {"cellHeight": "sm", "footer": {"countRows": false, "fields": "", "reducer": ["sum"], "show": false}, "showHeader": true, "sortBy": [{"desc": true, "displayName": "Status"}]},
"pluginVersion": "10.0.0",
"targets": [
{"expr": "homehub_interface_up", "format": "table", "instant": true, "refId": "A"},
{"expr": "homehub_interface_bytes_sent", "format": "table", "instant": true, "refId": "B"},
{"expr": "homehub_interface_bytes_received", "format": "table", "instant": true, "refId": "C"}
],
"title": "Interface Status",
"transformations": [
{"id": "seriesToColumns", "options": {"byField": "interface"}},
{"id": "organize", "options": {"excludeByName": {"Time": true, "Time 1": true, "Time 2": true, "Time 3": true, "__name__": true, "__name__ 1": true, "__name__ 2": true, "__name__ 3": true, "instance": true, "instance 1": true, "instance 2": true, "instance 3": true, "job": true, "job 1": true, "job 2": true, "job 3": true}, "renameByName": {"Value #A": "Status", "Value #B": "Sent", "Value #C": "Received", "interface": "Interface"}}}
],
"type": "table"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"axisCenteredZero": false,
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 20,
"lineInterpolation": "smooth",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {"type": "linear"},
"showPoints": "never",
"spanNulls": true
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "Bps"
},
"overrides": []
},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 16},
"id": 17,
"options": {
"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"pluginVersion": "10.0.0",
"targets": [
{"expr": "rate(homehub_interface_bytes_sent{interface=~\"fibre|10g|lan_1\"}[5m])", "legendFormat": "{{interface}} TX", "refId": "A"},
{"expr": "rate(homehub_interface_bytes_received{interface=~\"fibre|10g|lan_1\"}[5m])", "legendFormat": "{{interface}} RX", "refId": "B"}
],
"title": "Interface Throughput (Active)",
"type": "timeseries"
},
{
"collapsed": false,
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 24},
"id": 103,
"panels": [],
"title": "Logs & Events",
"type": "row"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"hideFrom": {"legend": false, "tooltip": false, "viz": false}},
"mappings": []
},
"overrides": []
},
"gridPos": {"h": 3, "w": 4, "x": 0, "y": 25},
"id": 4,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"textMode": "auto"
},
"pluginVersion": "10.0.0",
"targets": [{"expr": "homehub_logs_total", "refId": "A"}],
"title": "Total Logs",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "orange", "value": null}]},
"unit": "short"
},
"overrides": []
},
"gridPos": {"h": 3, "w": 4, "x": 4, "y": 25},
"id": 18,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"textMode": "auto"
},
"pluginVersion": "10.0.0",
"targets": [{"expr": "homehub_logs_last_hour", "refId": "A"}],
"title": "Logs (Last Hour)",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "purple", "value": null}]},
"unit": "short"
},
"overrides": []
},
"gridPos": {"h": 3, "w": 4, "x": 8, "y": 25},
"id": 19,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"textMode": "auto"
},
"pluginVersion": "10.0.0",
"targets": [{"expr": "homehub_wifi_connects_total", "refId": "A"}],
"title": "Total Connects",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "semi-dark-red", "value": null}]},
"unit": "short"
},
"overrides": []
},
"gridPos": {"h": 3, "w": 4, "x": 12, "y": 25},
"id": 20,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"textMode": "auto"
},
"pluginVersion": "10.0.0",
"targets": [{"expr": "homehub_wifi_disconnects_total", "refId": "A"}],
"title": "Total Disconnects",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "s"
},
"overrides": []
},
"gridPos": {"h": 5, "w": 3, "x": 8, "y": 10},
"id": 21,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"textMode": "value"
},
"pluginVersion": "10.0.0",
"targets": [{"expr": "homehub_router_uptime_seconds", "refId": "A"}],
"title": "Router Uptime",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "blue", "value": null}]},
"unit": "decbytes"
},
"overrides": []
},
"gridPos": {"h": 4, "w": 2, "x": 0, "y": 16},
"id": 22,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"textMode": "value"
},
"pluginVersion": "10.0.0",
"targets": [{"expr": "homehub_interface_bytes_received{interface=\"fibre\"}", "refId": "A"}],
"title": "Data In",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "orange", "value": null}]},
"unit": "decbytes"
},
"overrides": []
},
"gridPos": {"h": 4, "w": 2, "x": 0, "y": 20},
"id": 23,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"textMode": "value"
},
"pluginVersion": "10.0.0",
"targets": [{"expr": "homehub_interface_bytes_sent{interface=\"fibre\"}", "refId": "A"}],
"title": "Data Out",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"hideFrom": {"legend": false, "tooltip": false, "viz": false}},
"mappings": []
},
"overrides": []
},
"gridPos": {"h": 6, "w": 6, "x": 0, "y": 28},
"id": 6,
"options": {
"legend": {"displayMode": "list", "placement": "right", "showLegend": true},
"pieType": "donut",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"tooltip": {"mode": "single", "sort": "none"}
},
"pluginVersion": "10.0.0",
"targets": [{"expr": "homehub_logs_by_module", "legendFormat": "{{module}}", "refId": "A"}],
"title": "Logs by Module",
"type": "piechart"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"hideFrom": {"legend": false, "tooltip": false, "viz": false}},
"mappings": []
},
"overrides": [
{"matcher": {"id": "byName", "options": "Error"}, "properties": [{"id": "color", "value": {"fixedColor": "red", "mode": "fixed"}}]},
{"matcher": {"id": "byName", "options": "Warning"}, "properties": [{"id": "color", "value": {"fixedColor": "yellow", "mode": "fixed"}}]},
{"matcher": {"id": "byName", "options": "Info"}, "properties": [{"id": "color", "value": {"fixedColor": "green", "mode": "fixed"}}]}
]
},
"gridPos": {"h": 6, "w": 6, "x": 6, "y": 28},
"id": 9,
"options": {
"legend": {"displayMode": "list", "placement": "right", "showLegend": true},
"pieType": "donut",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"tooltip": {"mode": "single", "sort": "none"}
},
"pluginVersion": "10.0.0",
"targets": [
{"expr": "homehub_logs_by_level{level=\"INF\"}", "legendFormat": "Info", "refId": "A"},
{"expr": "homehub_logs_by_level{level=\"ERR\"}", "legendFormat": "Error", "refId": "B"},
{"expr": "homehub_logs_by_level{level=\"WRN\"}", "legendFormat": "Warning", "refId": "C"}
],
"title": "Logs by Level",
"type": "piechart"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"axisCenteredZero": false,
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "bars",
"fillOpacity": 50,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {"type": "linear"},
"showPoints": "never",
"spanNulls": false,
"stacking": {"group": "A", "mode": "none"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "short"
},
"overrides": [
{"matcher": {"id": "byName", "options": "Connections"}, "properties": [{"id": "color", "value": {"fixedColor": "green", "mode": "fixed"}}]},
{"matcher": {"id": "byName", "options": "Disconnections"}, "properties": [{"id": "color", "value": {"fixedColor": "red", "mode": "fixed"}}]}
]
},
"gridPos": {"h": 6, "w": 12, "x": 12, "y": 28},
"id": 8,
"options": {
"legend": {"calcs": ["sum"], "displayMode": "list", "placement": "bottom", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "none"}
},
"pluginVersion": "10.0.0",
"targets": [
{"expr": "increase(homehub_wifi_connects_total[5m])", "legendFormat": "Connections", "refId": "A"},
{"expr": "increase(homehub_wifi_disconnects_total[5m])", "legendFormat": "Disconnections", "refId": "B"}
],
"title": "WiFi Events (5m)",
"type": "timeseries"
}
],
"refresh": "30s",
"schemaVersion": 38,
"tags": ["vincent", "homehub-4000", "router"],
"templating": {
"list": [
{
"current": {"selected": false, "text": "Prometheus", "value": "PBFA97CFB590B2093"},
"hide": 0,
"includeAll": false,
"multi": false,
"name": "DS_PROMETHEUS",
"options": [],
"query": "prometheus",
"queryValue": "",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"type": "datasource"
}
]
},
"time": {"from": "now-6h", "to": "now"},
"timepicker": {},
"timezone": "",
"title": "Vincent / HomeHub 4000 Router Monitor",
"uid": "vincent-homehub-4000-router-monitor",
"version": 3
}

View File

@@ -0,0 +1,9 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false

14
deploy/prometheus.yml Normal file
View File

@@ -0,0 +1,14 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
- job_name: 'homehub'
static_configs:
- targets: ['exporter:9100']
scrape_interval: 30s
scrape_timeout: 15s

BIN
docs/grafana-dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

24
pyproject.toml Normal file
View File

@@ -0,0 +1,24 @@
[project]
name = "homehub-exporter"
version = "0.1.0"
description = "Prometheus exporter for the HomeHub 4000 router"
readme = "README.md"
license = { file = "LICENSE" }
requires-python = ">=3.11"
dependencies = [
"beautifulsoup4>=4.12",
"playwright>=1.50",
"prometheus-client>=0.21",
]
[dependency-groups]
dev = [
"pytest>=8.0",
"pytest-cov>=4.0",
"ruff>=0.3",
"ty>=0.0.9",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]

65
src/config.py Normal file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Configuration settings for router scraping."""
import os
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from playwright.sync_api import sync_playwright
def get_chrome_binary() -> Optional[str]:
"""Get Chrome binary path from environment."""
return os.getenv("CHROME_BINARY")
def dump_html(name: str, html: str, output_dir: Optional[str] = None) -> Path:
"""Dump HTML content to a timestamped file for debugging."""
target_dir = Path(output_dir or tempfile.gettempdir())
target_dir.mkdir(parents=True, exist_ok=True)
filepath = target_dir / f"{name}_{int(time.time())}.html"
filepath.write_text(html)
return filepath
@dataclass
class RouterSettings:
"""Settings for router scraping."""
router_ip: str
router_password: str
headless: bool
scrape_interval: int
log_fetch_timeout: int
debug: bool
debug_output_dir: str
@classmethod
def from_env(cls) -> "RouterSettings":
"""Load settings from environment variables."""
password = os.getenv("ROUTER_PASSWORD", "")
if not password:
raise ValueError("ROUTER_PASSWORD is required")
return cls(
router_ip=os.getenv("ROUTER_IP", "192.168.2.1"),
router_password=password,
headless=os.getenv("HEADLESS_BROWSER", "true").lower() == "true",
scrape_interval=int(os.getenv("SCRAPE_INTERVAL", "60")),
log_fetch_timeout=int(os.getenv("TABLE_WAIT_TIMEOUT", "90")),
debug=os.getenv("SCRAPER_DEBUG", "false").lower() == "true",
debug_output_dir=os.getenv("SCRAPER_DEBUG_DIR", tempfile.gettempdir()),
)
def create_browser_context(settings: RouterSettings):
"""Create a new Playwright browser context. Caller must manage lifecycle."""
playwright = sync_playwright().start()
chrome_binary = get_chrome_binary()
browser = playwright.chromium.launch(
headless=settings.headless,
executable_path=chrome_binary,
)
context = browser.new_context(viewport={"width": 1024, "height": 768})
return playwright, browser, context

200
src/database.py Normal file
View File

@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""
SQLite helper for storing router logs from the Advanced Tools page.
Only the router_logs table is managed here.
"""
import logging
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
from typing import List, Dict, Any, Optional
from contextlib import contextmanager
class VirginMonitorDatabase:
"""Minimal SQLite wrapper for router logs."""
def __init__(self, db_path: str = "virgin_monitor.db"):
self.db_path = Path(db_path)
self.logger = logging.getLogger(__name__)
self._initialize_database()
self.logger.info("Database initialized: %s", self.db_path)
def _initialize_database(self):
"""Create router_logs table if it doesn't exist."""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS router_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
log_timestamp TEXT NOT NULL,
level TEXT,
module TEXT,
message TEXT,
raw_datetime TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(log_timestamp, module, message)
)
"""
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_router_logs_ts ON router_logs (log_timestamp)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_router_logs_module ON router_logs (module)"
)
conn.commit()
@contextmanager
def _get_connection(self):
"""Context manager for database connections."""
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
try:
yield conn
except Exception:
conn.rollback()
raise
finally:
conn.close()
def save_router_logs(self, logs: List[Dict[str, Any]]) -> int:
"""
Save router system logs from advancedtools/logs page.
Skips entries already stored (based on timestamp, module, message).
Args:
logs: List of dicts with keys: log_timestamp (ISO), level, module, message, raw_datetime
Returns:
Number of new rows inserted.
"""
if not logs:
return 0
with self._get_connection() as conn:
cursor = conn.cursor()
inserted = 0
for entry in logs:
cursor.execute(
"""
INSERT OR IGNORE INTO router_logs (log_timestamp, level, module, message, raw_datetime)
VALUES (?, ?, ?, ?, ?)
""",
(
entry.get("log_timestamp"),
entry.get("level"),
entry.get("module"),
entry.get("message"),
entry.get("raw_datetime", entry.get("log_timestamp")),
),
)
if cursor.rowcount > 0:
inserted += 1
conn.commit()
self.logger.info(
"Saved %d new router log entries (incoming: %d)", inserted, len(logs)
)
return inserted
def get_device_log_rows(self) -> List[Dict[str, Any]]:
"""Fetch log rows used for device status parsing."""
with self._get_connection() as conn:
cur = conn.cursor()
cur.execute(
"SELECT log_timestamp, message FROM router_logs ORDER BY log_timestamp ASC"
)
return [dict(row) for row in cur.fetchall()]
def get_wifi_log_messages(self) -> List[str]:
"""Fetch WiFi log messages for connect/disconnect counts."""
with self._get_connection() as conn:
cur = conn.cursor()
cur.execute("SELECT message FROM router_logs WHERE module = 'WIFI'")
return [row["message"] for row in cur.fetchall()]
def get_log_counts(self) -> Dict[str, Any]:
"""Aggregate counts by level/module and overall totals."""
with self._get_connection() as conn:
cur = conn.cursor()
cur.execute("SELECT level, COUNT(*) as cnt FROM router_logs GROUP BY level")
by_level = {row["level"] or "UNKNOWN": row["cnt"] for row in cur.fetchall()}
cur.execute(
"SELECT module, COUNT(*) as cnt FROM router_logs GROUP BY module"
)
by_module = {
row["module"] or "UNKNOWN": row["cnt"] for row in cur.fetchall()
}
cur.execute("SELECT COUNT(*) as cnt FROM router_logs")
total = cur.fetchone()["cnt"]
one_hour_ago = (datetime.now() - timedelta(hours=1)).isoformat()
cur.execute(
"SELECT COUNT(*) as cnt FROM router_logs WHERE log_timestamp > ?",
(one_hour_ago,),
)
last_hour = cur.fetchone()["cnt"]
return {
"by_level": by_level,
"by_module": by_module,
"total": total,
"last_hour": last_hour,
}
def max_log_id(self) -> int:
"""Get the current maximum router_logs.id (0 if table empty)."""
with self._get_connection() as conn:
cur = conn.cursor()
cur.execute("SELECT COALESCE(MAX(id), 0) FROM router_logs")
row = cur.fetchone()
return row[0] if row else 0
def get_logs_since_id(self, since_id: int) -> List[Dict[str, Any]]:
"""
Get logs with id greater than since_id.
Returns a list of dicts ordered ascending by id.
"""
with self._get_connection() as conn:
cur = conn.cursor()
cur.execute(
"SELECT id, log_timestamp, level, module, message FROM router_logs WHERE id > ? ORDER BY id ASC",
(since_id,),
)
return [dict(row) for row in cur.fetchall()]
def get_last_boot_time(self) -> Optional[str]:
"""Get the timestamp of the most recent boot event."""
with self._get_connection() as conn:
cur = conn.cursor()
cur.execute(
"SELECT log_timestamp FROM router_logs WHERE message LIKE '%TR69 event found : 1 BOOT%' ORDER BY log_timestamp DESC LIMIT 1"
)
row = cur.fetchone()
return row["log_timestamp"] if row else None
if __name__ == "__main__":
# Simple smoke test
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s"
)
db = VirginMonitorDatabase("test.db")
sample = [
{
"log_timestamp": datetime.now().isoformat(),
"level": "INF",
"module": "TEST",
"message": "Sample log entry",
"raw_datetime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
]
inserted = db.save_router_logs(sample)
print(f"Inserted {inserted} sample rows into test.db")

472
src/exporter.py Normal file
View File

@@ -0,0 +1,472 @@
#!/usr/bin/env python3
"""Prometheus exporter for the Vincent/HomeHub 4000 router."""
import logging
import os
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
from prometheus_client import start_http_server, REGISTRY
from prometheus_client.core import GaugeMetricFamily
from prometheus_client.registry import Collector
from config import RouterSettings
from database import VirginMonitorDatabase
from router_log_fetcher import RouterLogFetcher
from router_scraper import RouterScraper
from router_status import (
build_status,
parse_events,
DeviceStatus,
CONNECT_PHRASE,
DISCONNECT_PHRASE,
)
class LogPoller:
"""Background thread that periodically fetches router logs and saves to database."""
def __init__(self, db: VirginMonitorDatabase, interval: int = 60):
self.db = db
self.interval = interval
self.logger = logging.getLogger(__name__)
self._stop_event = threading.Event()
self._thread: Optional[threading.Thread] = None
self._fetcher: Optional[RouterLogFetcher] = None
def start(self):
if self._thread and self._thread.is_alive():
return
try:
settings = RouterSettings.from_env()
self._fetcher = RouterLogFetcher(settings, self.db)
except ValueError as e:
self.logger.warning("Log polling disabled: %s", e)
return
self._stop_event.clear()
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
self._thread.start()
self.logger.info("Started log poller (interval: %ds)", self.interval)
def stop(self):
self._stop_event.set()
if self._thread:
self._thread.join(timeout=5)
def _poll_loop(self):
while not self._stop_event.is_set():
if self._fetcher is None:
break
try:
logs = self._fetcher.fetch_logs()
if logs:
inserted = self.db.save_router_logs(logs)
self.logger.info("Log poll: %d new entries", inserted)
except Exception as e:
self.logger.warning("Log poll failed: %s", e)
self._stop_event.wait(self.interval)
class VirginRouterCollector(Collector):
"""Custom collector that queries the database on each scrape."""
def __init__(
self,
db_path: str = "virgin_monitor.db",
scraper: Optional[RouterScraper] = None,
):
self.db_path = Path(db_path)
self.scraper = scraper
self._db: Optional[VirginMonitorDatabase] = None
def _get_db(self) -> Optional[VirginMonitorDatabase]:
if not self.db_path.exists():
return None
if self._db is None:
self._db = VirginMonitorDatabase(str(self.db_path))
return self._db
def _get_device_status(self, db: VirginMonitorDatabase) -> Dict[str, DeviceStatus]:
rows = db.get_device_log_rows()
events = list(parse_events(rows, allow_missing_timestamp=True))
return build_status(events)
def _get_log_counts(self, db: VirginMonitorDatabase) -> Dict[str, Any]:
return db.get_log_counts()
def _get_wifi_event_counts(self, db: VirginMonitorDatabase):
connects = 0
disconnects = 0
for message in db.get_wifi_log_messages():
msg = (message or "").lower()
if CONNECT_PHRASE in msg:
connects += 1
elif DISCONNECT_PHRASE in msg:
disconnects += 1
return {"connects": connects, "disconnects": disconnects}
def collect(self):
db = self._get_db()
if not db:
return
now = time.time()
devices = self._get_device_status(db)
connected_count = sum(1 for d in devices.values() if d.is_connected)
disconnected_count = len(devices) - connected_count
g = GaugeMetricFamily(
"homehub_connected_devices",
"Number of currently connected WiFi devices",
)
g.add_metric([], connected_count)
yield g
g = GaugeMetricFamily(
"homehub_disconnected_devices",
"Number of currently disconnected WiFi devices",
)
g.add_metric([], disconnected_count)
yield g
g = GaugeMetricFamily(
"homehub_total_devices",
"Total unique devices seen",
)
g.add_metric([], len(devices))
yield g
g = GaugeMetricFamily(
"homehub_device_connected",
"Device connection status (1=connected, 0=disconnected)",
labels=["device_name", "mac_address"],
)
for dev in devices.values():
g.add_metric(
[dev.name, dev.mac],
1 if dev.is_connected else 0,
)
yield g
g = GaugeMetricFamily(
"homehub_device_uptime_seconds",
"Seconds since device connected (only for connected devices)",
labels=["device_name", "mac_address"],
)
for dev in devices.values():
if dev.is_connected and dev.last_connected_time:
uptime = (datetime.now() - dev.last_connected_time).total_seconds()
g.add_metric([dev.name, dev.mac], uptime)
yield g
log_counts = self._get_log_counts(db)
g = GaugeMetricFamily(
"homehub_logs_total",
"Total number of router log entries",
)
g.add_metric([], log_counts["total"])
yield g
g = GaugeMetricFamily(
"homehub_logs_last_hour",
"Number of log entries in the last hour",
)
g.add_metric([], log_counts["last_hour"])
yield g
g = GaugeMetricFamily(
"homehub_logs_by_level",
"Log entries by severity level",
labels=["level"],
)
for level, count in log_counts["by_level"].items():
g.add_metric([level], count)
yield g
g = GaugeMetricFamily(
"homehub_logs_by_module",
"Log entries by module",
labels=["module"],
)
for module, count in log_counts["by_module"].items():
g.add_metric([module], count)
yield g
wifi_counts = self._get_wifi_event_counts(db)
g = GaugeMetricFamily(
"homehub_wifi_connects_total",
"Total WiFi connection events",
)
g.add_metric([], wifi_counts["connects"])
yield g
g = GaugeMetricFamily(
"homehub_wifi_disconnects_total",
"Total WiFi disconnection events",
)
g.add_metric([], wifi_counts["disconnects"])
yield g
g = GaugeMetricFamily(
"homehub_last_scrape_timestamp",
"Unix timestamp of last successful scrape",
)
g.add_metric([], now)
yield g
last_boot = db.get_last_boot_time()
if last_boot:
try:
boot_time = datetime.fromisoformat(last_boot)
uptime_seconds = (datetime.now() - boot_time).total_seconds()
g = GaugeMetricFamily(
"homehub_router_uptime_seconds",
"Seconds since last router boot",
)
g.add_metric([], uptime_seconds)
yield g
g = GaugeMetricFamily(
"homehub_router_last_boot_timestamp",
"Unix timestamp of last router boot",
)
g.add_metric([], boot_time.timestamp())
yield g
except ValueError:
pass
if self.scraper:
yield from self._collect_router_metrics()
def _collect_router_metrics(self):
if self.scraper is None:
return
data = self.scraper.get_data()
g = GaugeMetricFamily(
"homehub_scrape_success",
"Whether the last router scrape succeeded (1=success, 0=failure)",
)
g.add_metric([], 1 if data.scrape_success else 0)
yield g
g = GaugeMetricFamily(
"homehub_router_scrape_timestamp",
"Unix timestamp of last router page scrape",
)
g.add_metric([], data.last_scrape_time)
yield g
g = GaugeMetricFamily(
"homehub_memory_free_bytes",
"Free physical memory in bytes",
)
g.add_metric([], data.monitoring.memory_free_mb * 1024 * 1024)
yield g
g = GaugeMetricFamily(
"homehub_memory_total_bytes",
"Total physical memory in bytes",
)
g.add_metric([], data.monitoring.memory_total_mb * 1024 * 1024)
yield g
g = GaugeMetricFamily(
"homehub_flash_available_bytes",
"Available flash storage in bytes",
)
g.add_metric([], data.monitoring.flash_available_mb * 1024 * 1024)
yield g
g = GaugeMetricFamily(
"homehub_flash_used_bytes",
"Used flash storage in bytes",
)
g.add_metric([], data.monitoring.flash_used_mb * 1024 * 1024)
yield g
g = GaugeMetricFamily(
"homehub_cpu_usage_ratio",
"CPU usage ratio (0.0-1.0)",
)
g.add_metric([], data.monitoring.cpu_usage_percent / 100.0)
yield g
g = GaugeMetricFamily(
"homehub_load_average_1m",
"1-minute load average",
)
g.add_metric([], data.monitoring.load_1m)
yield g
g = GaugeMetricFamily(
"homehub_load_average_5m",
"5-minute load average",
)
g.add_metric([], data.monitoring.load_5m)
yield g
g = GaugeMetricFamily(
"homehub_load_average_15m",
"15-minute load average",
)
g.add_metric([], data.monitoring.load_15m)
yield g
g = GaugeMetricFamily(
"homehub_interface_up",
"Interface status (1=UP, 0=DOWN/DORMANT)",
labels=["interface"],
)
for name, iface in data.interfaces.items():
g.add_metric([name], 1 if iface.status == "UP" else 0)
yield g
g = GaugeMetricFamily(
"homehub_interface_bytes_sent",
"Bytes sent on interface",
labels=["interface"],
)
for name, iface in data.interfaces.items():
g.add_metric([name], iface.bytes_sent)
yield g
g = GaugeMetricFamily(
"homehub_interface_bytes_received",
"Bytes received on interface",
labels=["interface"],
)
for name, iface in data.interfaces.items():
g.add_metric([name], iface.bytes_received)
yield g
g = GaugeMetricFamily(
"homehub_interface_packets_sent",
"Packets sent on interface",
labels=["interface"],
)
for name, iface in data.interfaces.items():
g.add_metric([name], iface.packets_sent)
yield g
g = GaugeMetricFamily(
"homehub_interface_packets_received",
"Packets received on interface",
labels=["interface"],
)
for name, iface in data.interfaces.items():
g.add_metric([name], iface.packets_received)
yield g
g = GaugeMetricFamily(
"homehub_interface_errors_sent",
"Send errors on interface",
labels=["interface"],
)
for name, iface in data.interfaces.items():
g.add_metric([name], iface.errors_sent)
yield g
g = GaugeMetricFamily(
"homehub_interface_errors_received",
"Receive errors on interface",
labels=["interface"],
)
for name, iface in data.interfaces.items():
g.add_metric([name], iface.errors_received)
yield g
def main():
import argparse
import signal
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s"
)
logger = logging.getLogger(__name__)
parser = argparse.ArgumentParser(
description="Prometheus exporter for Virgin Media router metrics"
)
parser.add_argument(
"--port",
type=int,
default=int(os.environ.get("EXPORTER_PORT", 9100)),
help="Port to expose metrics on (default: 9100)",
)
parser.add_argument(
"--db",
default=os.environ.get("DB_PATH", "virgin_monitor.db"),
help="Path to SQLite database (default: virgin_monitor.db)",
)
parser.add_argument(
"--no-scraper",
action="store_true",
help="Disable live router scraping (only use database metrics)",
)
parser.add_argument(
"--log-interval",
type=int,
default=int(os.environ.get("LOG_POLL_INTERVAL", 75)),
help="Seconds between log polls (default: 75)",
)
parser.add_argument(
"--no-log-poller",
action="store_true",
help="Disable log polling (only use live scraper metrics)",
)
args = parser.parse_args()
db = VirginMonitorDatabase(args.db)
scraper = None
if not args.no_scraper:
try:
scraper = RouterScraper()
scraper.start_background_refresh()
logger.info("Router scraper started")
except ValueError as e:
logger.warning("Router scraper disabled: %s", e)
scraper = None
log_poller = None
if not args.no_log_poller:
log_poller = LogPoller(db, interval=args.log_interval)
log_poller.start()
REGISTRY.register(VirginRouterCollector(args.db, scraper=scraper))
logger.info("Starting Prometheus exporter on port %d", args.port)
logger.info("Database: %s", args.db)
logger.info("Router scraping: %s", "enabled" if scraper else "disabled")
logger.info("Log polling: %s", "enabled" if log_poller else "disabled")
logger.info("Metrics available at http://localhost:%d/metrics", args.port)
start_http_server(args.port)
def shutdown(signum, frame):
logger.info("Shutting down...")
if scraper:
scraper.stop_background_refresh()
if log_poller:
log_poller.stop()
exit(0)
signal.signal(signal.SIGTERM, shutdown)
signal.signal(signal.SIGINT, shutdown)
while True:
time.sleep(60)
if __name__ == "__main__":
main()

132
src/router_log_fetcher.py Normal file
View File

@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Fetches router logs from the advancedtools/logs page."""
import logging
from datetime import datetime
from typing import List, Dict, Optional
from bs4 import BeautifulSoup
from bs4.element import Tag
from playwright.sync_api import sync_playwright
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
from config import RouterSettings, dump_html, get_chrome_binary
from database import VirginMonitorDatabase
from router_scraper import handle_admin_login
class RouterLogFetcher:
"""Fetch and parse router system logs."""
def __init__(
self,
settings: Optional[RouterSettings] = None,
database: Optional[VirginMonitorDatabase] = None,
):
self.settings = settings or RouterSettings.from_env()
self.database = database
self.logger = logging.getLogger(__name__)
def fetch_logs(self) -> List[Dict[str, str]]:
"""Open the logs page, parse the table, and return structured rows."""
logs_url = f"http://{self.settings.router_ip}/?c=advancedtools/logs"
table_wait = self.settings.log_fetch_timeout * 1000
try:
with sync_playwright() as playwright:
chrome_binary = get_chrome_binary()
browser = playwright.chromium.launch(
headless=self.settings.headless,
executable_path=chrome_binary,
)
context = browser.new_context(viewport={"width": 1024, "height": 768})
page = context.new_page()
page.set_default_timeout(15000)
page.set_default_navigation_timeout(60000)
page_html = ""
try:
self.logger.info("Fetching logs from %s", logs_url)
page.goto(logs_url, wait_until="domcontentloaded")
page.wait_for_timeout(2000)
if handle_admin_login(page, self.settings.router_password, self.logger):
page.wait_for_timeout(3000)
try:
page.evaluate(
"""
() => {
if (typeof loadSystemLogs === 'function') loadSystemLogs();
if (typeof refreshLogs === 'function') refreshLogs();
}
"""
)
page.wait_for_timeout(3000)
except PlaywrightError as e:
self.logger.debug("Could not trigger log table refresh: %s", e)
try:
page.wait_for_selector("#systemLogTable tbody tr", timeout=table_wait)
page.wait_for_timeout(2000)
except PlaywrightTimeoutError:
self.logger.warning("Log table rows not detected within timeout")
page_html = page.content()
finally:
try:
context.close()
except PlaywrightError as e:
self.logger.debug("Error closing log browser context: %s", e)
try:
browser.close()
except PlaywrightError as e:
self.logger.debug("Error closing log browser: %s", e)
if not page_html:
return []
parsed = self._parse_table(page_html)
if not parsed and self.settings.debug:
dump_html("router_logs_debug", page_html, self.settings.debug_output_dir)
return parsed
except PlaywrightError as exc:
self.logger.error("Failed to fetch router logs: %s", exc)
return []
def _parse_table(self, html: str) -> List[Dict[str, str]]:
"""Parse HTML table rows into structured dicts."""
soup = BeautifulSoup(html, "html.parser")
rows = soup.select("#systemLogTable tbody tr")
parsed = []
for row in rows:
date_text = self._text_or_none(row.select_one(".dateTime"))
level = row.get("level") or self._text_or_none(row.select_one(".level"))
module = row.get("module") or self._text_or_none(row.select_one(".module"))
message = self._text_or_none(row.select_one(".message"))
iso_timestamp = date_text
if date_text:
try:
iso_timestamp = datetime.strptime(date_text, "%Y-%m-%d %H:%M:%S").isoformat()
except ValueError:
iso_timestamp = date_text
parsed.append({
"log_timestamp": iso_timestamp,
"raw_datetime": date_text,
"level": level,
"module": module,
"message": message,
})
self.logger.info("Parsed %s log rows", len(parsed))
return parsed
@staticmethod
def _text_or_none(el: Optional[Tag]) -> Optional[str]:
return el.get_text(strip=True) if el else None

397
src/router_scraper.py Normal file
View File

@@ -0,0 +1,397 @@
#!/usr/bin/env python3
"""Router scraper for HomeHub 4000 monitoring/statistics pages."""
import copy
import logging
import re
import threading
import time
from dataclasses import dataclass, field
from typing import Dict, Optional
from bs4 import BeautifulSoup
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import Page
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
from config import RouterSettings, create_browser_context, dump_html
@dataclass
class MonitoringData:
memory_free_mb: float = 0.0
memory_total_mb: float = 0.0
flash_available_mb: float = 0.0
flash_used_mb: float = 0.0
cpu_usage_percent: float = 0.0
load_1m: float = 0.0
load_5m: float = 0.0
load_15m: float = 0.0
@dataclass
class InterfaceStats:
name: str
status: str = "UNKNOWN"
bytes_sent: int = 0
bytes_received: int = 0
packets_sent: int = 0
packets_received: int = 0
errors_sent: int = 0
errors_received: int = 0
@dataclass
class ScrapedData:
monitoring: MonitoringData = field(default_factory=MonitoringData)
interfaces: Dict[str, InterfaceStats] = field(default_factory=dict)
last_scrape_time: float = 0.0
scrape_success: bool = False
def handle_admin_login(
page: Page,
password: str,
logger: Optional[logging.Logger] = None,
log_warnings: bool = True,
) -> bool:
"""Handle admin login popup if present."""
try:
login_popup = page.locator("#login-popup")
if not login_popup.count():
header_login = page.locator("#headerLogin")
if not header_login.count():
return False
header_login.first.click()
try:
page.wait_for_selector("#login-popup", timeout=10000)
except PlaywrightTimeoutError:
return False
page.evaluate(
"""
() => {
const popup = document.getElementById('login-popup');
if (popup) {
popup.style.display = 'block';
popup.style.visibility = 'visible';
popup.style.opacity = '1';
popup.style.zIndex = '9999';
popup.classList.add('show');
popup.classList.remove('hide');
}
}
"""
)
try:
pwd_input = page.wait_for_selector(
"#login-popup input[name='admin-password'], #login-popup #password",
timeout=15000,
)
except PlaywrightTimeoutError:
if logger and log_warnings:
logger.warning("Password input not found after forcing popup visible")
return False
if pwd_input is None:
return False
try:
pwd_input.fill(password)
except PlaywrightError:
page.evaluate(
"""
(password) => {
const field = document.querySelector("#login-popup input[name='admin-password']")
|| document.querySelector('#login-popup #password');
if (field) {
field.value = password;
field.dispatchEvent(new Event('input', { bubbles: true }));
}
}
""",
password,
)
login_btn = page.locator("#loginButton")
if login_btn.count():
login_btn.first.click()
else:
page.evaluate("document.getElementById('loginButton')?.click();")
try:
page.wait_for_selector("#login-popup", state="hidden", timeout=15000)
return True
except PlaywrightTimeoutError:
if logger and log_warnings:
logger.warning("Login popup did not disappear after submit")
return False
except PlaywrightError as exc:
if logger:
logger.warning("Admin login handling failed: %s", exc)
return False
class RouterScraper:
"""Scrapes router monitoring and statistics pages."""
def __init__(self, settings: Optional[RouterSettings] = None):
self.settings = settings or RouterSettings.from_env()
self.logger = logging.getLogger(__name__)
self._data = ScrapedData()
self._lock = threading.Lock()
self._stop_event = threading.Event()
self._thread: Optional[threading.Thread] = None
self._playwright = None
self._browser = None
self._context = None
def get_data(self) -> ScrapedData:
with self._lock:
return copy.deepcopy(self._data)
def start_background_refresh(self):
if self._thread and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(target=self._refresh_loop, daemon=True)
self._thread.start()
self.logger.info("Started background scraper (interval: %ss)", self.settings.scrape_interval)
def stop_background_refresh(self):
self._stop_event.set()
if self._thread:
self._thread.join(timeout=5)
self._close_browser()
def _close_browser(self):
if self._context:
try:
self._context.close()
except Exception as e:
self.logger.debug("Error closing browser context: %s", e)
if self._browser:
try:
self._browser.close()
except Exception as e:
self.logger.debug("Error closing browser: %s", e)
if self._playwright:
try:
self._playwright.stop()
except Exception as e:
self.logger.debug("Error stopping playwright: %s", e)
self._playwright = None
self._browser = None
self._context = None
def _ensure_browser(self):
if self._browser is None:
self._playwright, self._browser, self._context = create_browser_context(self.settings)
self.logger.info("Browser started")
def _refresh_loop(self):
while not self._stop_event.is_set():
try:
self._refresh_data()
except PlaywrightError as exc:
self.logger.warning("Scrape failed: %s", exc)
self._close_browser()
with self._lock:
self._data.scrape_success = False
except Exception as exc:
self.logger.exception("Scrape failed unexpectedly: %s", exc)
self._close_browser()
with self._lock:
self._data.scrape_success = False
self._stop_event.wait(self.settings.scrape_interval)
def _refresh_data(self):
self._ensure_browser()
if self._context is None:
return
page = self._context.new_page()
page.set_default_timeout(15000)
page.set_default_navigation_timeout(60000)
try:
monitoring_url = f"http://{self.settings.router_ip}/?c=advancedtools/monitoring"
self.logger.info("Scraping %s", monitoring_url)
page.goto(monitoring_url, wait_until="domcontentloaded")
page.wait_for_timeout(2000)
if handle_admin_login(page, self.settings.router_password, self.logger, log_warnings=False):
page.wait_for_timeout(3000)
try:
page.wait_for_selector("#Monitoring-View", timeout=15000)
except PlaywrightTimeoutError:
self.logger.warning("Monitoring view not found")
page.wait_for_timeout(2000)
monitoring_html = page.content()
if self.settings.debug:
dump_html("router_monitoring", monitoring_html, self.settings.debug_output_dir)
monitoring_data = self._parse_monitoring_page(monitoring_html)
# Close any popup
close_btn = page.locator("#closePopup")
if close_btn.count():
try:
close_btn.first.click()
page.wait_for_timeout(1000)
except PlaywrightError:
pass
stats_url = f"http://{self.settings.router_ip}/?c=advancedtools/statistics"
self.logger.info("Scraping %s", stats_url)
page.goto(stats_url, wait_until="domcontentloaded")
try:
page.wait_for_selector("#Statistics-View, .statisticsTable, table.dataTable", timeout=15000)
except PlaywrightTimeoutError:
page.wait_for_timeout(5000)
page.wait_for_timeout(2000)
stats_html = page.content()
if self.settings.debug:
dump_html("router_statistics", stats_html, self.settings.debug_output_dir)
interfaces = self._parse_statistics_page(stats_html)
with self._lock:
self._data.monitoring = monitoring_data
self._data.interfaces = interfaces
self._data.last_scrape_time = time.time()
self._data.scrape_success = True
self.logger.info(
"Scrape complete: CPU=%s%%, Memory=%s/%sMB, Interfaces=%s",
monitoring_data.cpu_usage_percent,
monitoring_data.memory_free_mb,
monitoring_data.memory_total_mb,
len(interfaces),
)
finally:
try:
page.close()
except Exception as e:
self.logger.debug("Error closing page: %s", e)
def _parse_monitoring_page(self, html: str) -> MonitoringData:
data = MonitoringData()
soup = BeautifulSoup(html, "html.parser")
text = soup.get_text()
mem_free = re.search(r"Free:\s*([\d.]+)\s*MB", text)
mem_total = re.search(r"Total:\s*([\d.]+)\s*MB", text)
if mem_free:
data.memory_free_mb = float(mem_free.group(1))
if mem_total:
data.memory_total_mb = float(mem_total.group(1))
flash_avail = re.search(r"Available:\s*([\d.]+)\s*MB", text)
flash_used = re.search(r"Used:\s*([\d.]+)\s*MB", text)
if flash_avail:
data.flash_available_mb = float(flash_avail.group(1))
if flash_used:
data.flash_used_mb = float(flash_used.group(1))
cpu = re.search(r"Usage:\s*(\d+)%", text)
if cpu:
data.cpu_usage_percent = float(cpu.group(1))
load_1m = re.search(r"Last minute:\s*([\d.]+)", text)
load_5m = re.search(r"Last 5 minutes:\s*([\d.]+)", text)
load_15m = re.search(r"Last 15 minutes:\s*([\d.]+)", text)
if load_1m:
data.load_1m = float(load_1m.group(1))
if load_5m:
data.load_5m = float(load_5m.group(1))
if load_15m:
data.load_15m = float(load_15m.group(1))
return data
def _parse_statistics_page(self, html: str) -> Dict[str, InterfaceStats]:
"""Parse interface statistics from HTML table."""
interfaces = {}
soup = BeautifulSoup(html, "html.parser")
for table_id in ["statsTable-Ethernet", "statsTable-WiFi"]:
table = soup.find("table", id=table_id)
if not table:
continue
thead = table.find("thead")
if not thead:
continue
header_cells = thead.find_all("th")
interface_columns = []
for i, th in enumerate(header_cells):
if i == 0:
continue
iface_name = th.get_text(strip=True)
normalized = self._normalize_interface_name(iface_name)
interface_columns.append((i, normalized))
for col_idx, normalized in interface_columns:
if normalized and normalized not in interfaces:
interfaces[normalized] = InterfaceStats(name=normalized)
tbody = table.find("tbody")
if not tbody:
continue
for row in tbody.find_all("tr"):
row_name = row.get("name", "")
cells = row.find_all("td")
for col_idx, normalized in interface_columns:
if not normalized or col_idx >= len(cells):
continue
cell = cells[col_idx]
value_span = cell.find("span", class_="value") or cell.find("span")
value = value_span.get_text(strip=True) if value_span else cell.get_text(strip=True)
iface = interfaces[normalized]
if row_name == "status":
iface.status = value.upper() if value else "UNKNOWN"
elif row_name == "bytesSent":
iface.bytes_sent = self._parse_int(value)
elif row_name == "bytesReceived":
iface.bytes_received = self._parse_int(value)
elif row_name == "packetsSent":
iface.packets_sent = self._parse_int(value)
elif row_name == "packetsReceived":
iface.packets_received = self._parse_int(value)
elif row_name == "errorsSent":
iface.errors_sent = self._parse_int(value)
elif row_name == "errorsReceived":
iface.errors_received = self._parse_int(value)
return interfaces
def _normalize_interface_name(self, name: str) -> Optional[str]:
name = name.lower().strip().replace("\xa0", " ")
mappings = {
"lan 1": "lan_1",
"lan 2": "lan_2",
"lan 3": "lan_3",
"lan 4": "lan_4",
"10g": "10g",
"fibre": "fibre",
"2.4 ghz": "wifi_2.4ghz",
"5.0 ghz radio 1": "wifi_5ghz_radio1",
"5.0 ghz radio 2": "wifi_5ghz_radio2",
"guest": "wifi_guest",
}
return mappings.get(name)
@staticmethod
def _parse_int(value: str) -> int:
try:
return int(value.replace(",", "").strip())
except (ValueError, AttributeError):
return 0

78
src/router_status.py Normal file
View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Parse router logs to determine device connection status."""
import re
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, Generator, Iterable, List, Optional
DEVICE_RE = re.compile(r"<(?P<name>.+?) \[(?P<mac>[^\]]+)\]>")
CONNECT_PHRASE = "has successfully connected"
DISCONNECT_PHRASE = "was disconnected"
@dataclass
class DeviceStatus:
name: str
mac: str
last_event: Optional[str] = None # "connected" or "disconnected"
last_event_time: Optional[datetime] = None
last_connected_time: Optional[datetime] = None
@property
def is_connected(self) -> bool:
return self.last_event == "connected"
def parse_timestamp(ts_str: Optional[str]) -> Optional[datetime]:
if not ts_str:
return None
try:
return datetime.fromisoformat(ts_str)
except ValueError:
try:
return datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")
except ValueError:
return None
def parse_events(rows: List[Dict[str, str]], allow_missing_timestamp: bool = False) -> Generator[Dict[str, Any], None, None]:
"""Parse log rows into connect/disconnect events."""
for row in rows:
msg = row.get("message", "")
ts_str = row.get("log_timestamp")
ts = parse_timestamp(ts_str)
match = DEVICE_RE.search(msg)
if not match:
continue
if not ts and not allow_missing_timestamp:
continue
name = match.group("name").strip()
mac = match.group("mac").strip()
text_lower = msg.lower()
if CONNECT_PHRASE in text_lower:
yield {"name": name, "mac": mac, "event": "connected", "time": ts}
elif DISCONNECT_PHRASE in text_lower:
yield {"name": name, "mac": mac, "event": "disconnected", "time": ts}
def build_status(events: Iterable[Dict[str, Any]]) -> Dict[str, DeviceStatus]:
"""Build current device status from events."""
devices: Dict[str, DeviceStatus] = {}
for ev in events:
device = devices.get(ev["mac"])
if not device:
device = DeviceStatus(name=ev["name"], mac=ev["mac"])
devices[ev["mac"]] = device
device.name = ev["name"]
device.last_event = ev["event"]
device.last_event_time = ev["time"]
if ev["event"] == "connected":
device.last_connected_time = ev["time"]
return devices

0
tests/__init__.py Normal file
View File

16
tests/conftest.py Normal file
View File

@@ -0,0 +1,16 @@
import tempfile
from pathlib import Path
import pytest
from database import VirginMonitorDatabase
@pytest.fixture
def temp_db():
"""Create a temporary database for testing."""
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = f.name
db = VirginMonitorDatabase(db_path)
yield db
Path(db_path).unlink(missing_ok=True)

63
tests/test_database.py Normal file
View File

@@ -0,0 +1,63 @@
from datetime import datetime
class TestSaveRouterLogs:
def test_inserts_new_entries(self, temp_db):
logs = [
{
"log_timestamp": "2024-01-15T10:30:00",
"level": "INF",
"module": "WIFI",
"message": "Device connected",
}
]
inserted = temp_db.save_router_logs(logs)
assert inserted == 1
def test_deduplicates_entries(self, temp_db):
logs = [
{
"log_timestamp": "2024-01-15T10:30:00",
"level": "INF",
"module": "WIFI",
"message": "Device connected",
}
]
temp_db.save_router_logs(logs)
inserted = temp_db.save_router_logs(logs)
assert inserted == 0
def test_empty_list_returns_zero(self, temp_db):
assert temp_db.save_router_logs([]) == 0
class TestGetLogCounts:
def test_empty_database(self, temp_db):
counts = temp_db.get_log_counts()
assert counts["total"] == 0
assert counts["by_level"] == {}
assert counts["by_module"] == {}
def test_with_data(self, temp_db):
logs = [
{"log_timestamp": datetime.now().isoformat(), "level": "INF", "module": "WIFI", "message": "msg1"},
{"log_timestamp": datetime.now().isoformat(), "level": "INF", "module": "WIFI", "message": "msg2"},
{"log_timestamp": datetime.now().isoformat(), "level": "ERR", "module": "SYS", "message": "msg3"},
]
temp_db.save_router_logs(logs)
counts = temp_db.get_log_counts()
assert counts["total"] == 3
assert counts["by_level"]["INF"] == 2
assert counts["by_level"]["ERR"] == 1
assert counts["by_module"]["WIFI"] == 2
class TestGetDeviceLogRows:
def test_returns_logs_for_parsing(self, temp_db):
logs = [
{"log_timestamp": "2024-01-15T10:30:00", "level": "INF", "module": "WIFI", "message": "test message"},
]
temp_db.save_router_logs(logs)
rows = temp_db.get_device_log_rows()
assert len(rows) == 1
assert rows[0]["message"] == "test message"

View File

@@ -0,0 +1,32 @@
from router_scraper import RouterScraper
class TestParseInt:
def test_simple_number(self):
assert RouterScraper._parse_int("123") == 123
def test_with_commas(self):
assert RouterScraper._parse_int("1,234,567") == 1234567
def test_with_whitespace(self):
assert RouterScraper._parse_int(" 456 ") == 456
def test_invalid_returns_zero(self):
assert RouterScraper._parse_int("not a number") == 0
assert RouterScraper._parse_int("") == 0
class TestNormalizeInterfaceName:
def test_lan_ports(self):
scraper = RouterScraper.__new__(RouterScraper)
assert scraper._normalize_interface_name("LAN 1") == "lan_1"
assert scraper._normalize_interface_name("lan 2") == "lan_2"
def test_wifi_bands(self):
scraper = RouterScraper.__new__(RouterScraper)
assert scraper._normalize_interface_name("2.4 GHz") == "wifi_2.4ghz"
assert scraper._normalize_interface_name("5.0 GHz Radio 1") == "wifi_5ghz_radio1"
def test_unknown_returns_none(self):
scraper = RouterScraper.__new__(RouterScraper)
assert scraper._normalize_interface_name("Unknown Interface") is None

View File

@@ -0,0 +1,73 @@
from datetime import datetime
from router_status import parse_timestamp, parse_events, build_status
class TestParseTimestamp:
def test_iso_format(self):
result = parse_timestamp("2024-01-15T10:30:00")
assert result == datetime(2024, 1, 15, 10, 30, 0)
def test_strptime_format(self):
result = parse_timestamp("2024-01-15 10:30:00")
assert result == datetime(2024, 1, 15, 10, 30, 0)
def test_invalid_returns_none(self):
assert parse_timestamp("not a date") is None
assert parse_timestamp("") is None
assert parse_timestamp(None) is None
class TestParseEvents:
def test_extracts_connected_device(self):
rows = [
{
"log_timestamp": "2024-01-15T10:30:00",
"message": "<MyPhone [AA:BB:CC:DD:EE:FF]> has successfully connected to WiFi",
}
]
events = list(parse_events(rows))
assert len(events) == 1
assert events[0]["name"] == "MyPhone"
assert events[0]["mac"] == "AA:BB:CC:DD:EE:FF"
assert events[0]["event"] == "connected"
def test_extracts_disconnected_device(self):
rows = [
{
"log_timestamp": "2024-01-15T10:30:00",
"message": "<Laptop [11:22:33:44:55:66]> was disconnected from WiFi",
}
]
events = list(parse_events(rows))
assert len(events) == 1
assert events[0]["event"] == "disconnected"
def test_skips_missing_timestamp(self):
rows = [{"message": "<Device [AA:BB:CC:DD:EE:FF]> has successfully connected"}]
events = list(parse_events(rows))
assert len(events) == 0
def test_allows_missing_timestamp_when_flagged(self):
rows = [{"message": "<Device [AA:BB:CC:DD:EE:FF]> has successfully connected"}]
events = list(parse_events(rows, allow_missing_timestamp=True))
assert len(events) == 1
class TestBuildStatus:
def test_tracks_connection(self):
events = [
{"name": "Phone", "mac": "AA:BB:CC:DD:EE:FF", "event": "connected", "time": datetime(2024, 1, 15, 10, 0)},
]
status = build_status(events)
assert "AA:BB:CC:DD:EE:FF" in status
assert status["AA:BB:CC:DD:EE:FF"].is_connected is True
def test_tracks_disconnection(self):
events = [
{"name": "Phone", "mac": "AA:BB:CC:DD:EE:FF", "event": "connected", "time": datetime(2024, 1, 15, 10, 0)},
{"name": "Phone", "mac": "AA:BB:CC:DD:EE:FF", "event": "disconnected", "time": datetime(2024, 1, 15, 11, 0)},
]
status = build_status(events)
assert status["AA:BB:CC:DD:EE:FF"].is_connected is False
assert status["AA:BB:CC:DD:EE:FF"].last_connected_time == datetime(2024, 1, 15, 10, 0)

421
uv.lock generated Normal file
View File

@@ -0,0 +1,421 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "beautifulsoup4"
version = "4.14.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
]
[[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 = "coverage"
version = "7.13.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/9b/77baf488516e9ced25fc215a6f75d803493fc3f6a1a1227ac35697910c2a/coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88", size = 218755, upload-time = "2025-12-28T15:40:30.812Z" },
{ url = "https://files.pythonhosted.org/packages/d7/cd/7ab01154e6eb79ee2fab76bf4d89e94c6648116557307ee4ebbb85e5c1bf/coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3", size = 219257, upload-time = "2025-12-28T15:40:32.333Z" },
{ url = "https://files.pythonhosted.org/packages/01/d5/b11ef7863ffbbdb509da0023fad1e9eda1c0eaea61a6d2ea5b17d4ac706e/coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9", size = 249657, upload-time = "2025-12-28T15:40:34.1Z" },
{ url = "https://files.pythonhosted.org/packages/f7/7c/347280982982383621d29b8c544cf497ae07ac41e44b1ca4903024131f55/coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee", size = 251581, upload-time = "2025-12-28T15:40:36.131Z" },
{ url = "https://files.pythonhosted.org/packages/82/f6/ebcfed11036ade4c0d75fa4453a6282bdd225bc073862766eec184a4c643/coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf", size = 253691, upload-time = "2025-12-28T15:40:37.626Z" },
{ url = "https://files.pythonhosted.org/packages/02/92/af8f5582787f5d1a8b130b2dcba785fa5e9a7a8e121a0bb2220a6fdbdb8a/coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3", size = 249799, upload-time = "2025-12-28T15:40:39.47Z" },
{ url = "https://files.pythonhosted.org/packages/24/aa/0e39a2a3b16eebf7f193863323edbff38b6daba711abaaf807d4290cf61a/coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef", size = 251389, upload-time = "2025-12-28T15:40:40.954Z" },
{ url = "https://files.pythonhosted.org/packages/73/46/7f0c13111154dc5b978900c0ccee2e2ca239b910890e674a77f1363d483e/coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851", size = 249450, upload-time = "2025-12-28T15:40:42.489Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ca/e80da6769e8b669ec3695598c58eef7ad98b0e26e66333996aee6316db23/coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb", size = 249170, upload-time = "2025-12-28T15:40:44.279Z" },
{ url = "https://files.pythonhosted.org/packages/af/18/9e29baabdec1a8644157f572541079b4658199cfd372a578f84228e860de/coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba", size = 250081, upload-time = "2025-12-28T15:40:45.748Z" },
{ url = "https://files.pythonhosted.org/packages/00/f8/c3021625a71c3b2f516464d322e41636aea381018319050a8114105872ee/coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19", size = 221281, upload-time = "2025-12-28T15:40:47.232Z" },
{ url = "https://files.pythonhosted.org/packages/27/56/c216625f453df6e0559ed666d246fcbaaa93f3aa99eaa5080cea1229aa3d/coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a", size = 222215, upload-time = "2025-12-28T15:40:49.19Z" },
{ url = "https://files.pythonhosted.org/packages/5c/9a/be342e76f6e531cae6406dc46af0d350586f24d9b67fdfa6daee02df71af/coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c", size = 220886, upload-time = "2025-12-28T15:40:51.067Z" },
{ url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" },
{ url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" },
{ url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" },
{ url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" },
{ url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" },
{ url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" },
{ url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" },
{ url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" },
{ url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" },
{ url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" },
{ url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" },
{ url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" },
{ url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" },
{ url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" },
{ url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" },
{ url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" },
{ url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" },
{ url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" },
{ url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" },
{ url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" },
{ url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" },
{ url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" },
{ url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" },
{ url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" },
{ url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" },
{ url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" },
{ url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" },
{ url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" },
{ url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" },
{ url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" },
{ url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" },
{ url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" },
{ url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" },
{ url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" },
{ url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" },
{ url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" },
{ url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" },
{ url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" },
{ url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" },
{ url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" },
{ url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" },
{ url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" },
{ url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" },
{ url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" },
{ url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" },
{ url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" },
{ url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" },
{ url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" },
{ url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" },
{ url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" },
{ url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" },
{ url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" },
{ url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" },
{ url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" },
{ url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" },
{ url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" },
{ url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" },
{ url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" },
{ url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" },
{ url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" },
{ url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" },
{ url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" },
{ url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" },
]
[package.optional-dependencies]
toml = [
{ name = "tomli", marker = "python_full_version <= '3.11'" },
]
[[package]]
name = "greenlet"
version = "3.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908, upload-time = "2025-12-04T14:23:26.435Z" },
{ url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113, upload-time = "2025-12-04T14:50:05.493Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338, upload-time = "2025-12-04T14:57:41.136Z" },
{ url = "https://files.pythonhosted.org/packages/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45", size = 601098, upload-time = "2025-12-04T15:07:11.898Z" },
{ url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206, upload-time = "2025-12-04T14:26:01.254Z" },
{ url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668, upload-time = "2025-12-04T15:04:22.439Z" },
{ url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483, upload-time = "2025-12-04T14:27:28.083Z" },
{ url = "https://files.pythonhosted.org/packages/1d/d5/c339b3b4bc8198b7caa4f2bd9fd685ac9f29795816d8db112da3d04175bb/greenlet-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:7652ee180d16d447a683c04e4c5f6441bae7ba7b17ffd9f6b3aff4605e9e6f71", size = 301164, upload-time = "2025-12-04T14:42:51.577Z" },
{ url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" },
{ url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" },
{ url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" },
{ url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" },
{ url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" },
{ url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" },
{ url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" },
{ url = "https://files.pythonhosted.org/packages/6c/79/3912a94cf27ec503e51ba493692d6db1e3cd8ac7ac52b0b47c8e33d7f4f9/greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39", size = 301964, upload-time = "2025-12-04T14:36:58.316Z" },
{ url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" },
{ url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" },
{ url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" },
{ url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" },
{ url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" },
{ url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" },
{ url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" },
{ url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" },
{ url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" },
{ url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" },
{ url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" },
{ url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" },
{ url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" },
{ url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" },
{ url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387, upload-time = "2025-12-04T14:26:51.063Z" },
{ url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" },
{ url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" },
{ url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" },
{ url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" },
{ url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" },
{ url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },
]
[[package]]
name = "homehub-exporter"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "beautifulsoup4" },
{ name = "playwright" },
{ name = "prometheus-client" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
{ name = "ty" },
]
[package.metadata]
requires-dist = [
{ name = "beautifulsoup4", specifier = ">=4.12" },
{ name = "playwright", specifier = ">=1.50" },
{ name = "prometheus-client", specifier = ">=0.21" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.0" },
{ name = "pytest-cov", specifier = ">=4.0" },
{ name = "ruff", specifier = ">=0.3" },
{ name = "ty", specifier = ">=0.0.9" },
]
[[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 = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "playwright"
version = "1.57.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet" },
{ name = "pyee" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/b6/e17543cea8290ae4dced10be21d5a43c360096aa2cce0aa7039e60c50df3/playwright-1.57.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:9351c1ac3dfd9b3820fe7fc4340d96c0d3736bb68097b9b7a69bd45d25e9370c", size = 41985039, upload-time = "2025-12-09T08:06:18.408Z" },
{ url = "https://files.pythonhosted.org/packages/8b/04/ef95b67e1ff59c080b2effd1a9a96984d6953f667c91dfe9d77c838fc956/playwright-1.57.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4a9d65027bce48eeba842408bcc1421502dfd7e41e28d207e94260fa93ca67e", size = 40775575, upload-time = "2025-12-09T08:06:22.105Z" },
{ url = "https://files.pythonhosted.org/packages/60/bd/5563850322a663956c927eefcf1457d12917e8f118c214410e815f2147d1/playwright-1.57.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:99104771abc4eafee48f47dac2369e0015516dc1ce8c409807d2dd440828b9a4", size = 41985042, upload-time = "2025-12-09T08:06:25.357Z" },
{ url = "https://files.pythonhosted.org/packages/56/61/3a803cb5ae0321715bfd5247ea871d25b32c8f372aeb70550a90c5f586df/playwright-1.57.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:284ed5a706b7c389a06caa431b2f0ba9ac4130113c3a779767dda758c2497bb1", size = 45975252, upload-time = "2025-12-09T08:06:29.186Z" },
{ url = "https://files.pythonhosted.org/packages/83/d7/b72eb59dfbea0013a7f9731878df8c670f5f35318cedb010c8a30292c118/playwright-1.57.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a1bae6c0a07839cdeaddbc0756b3b2b85e476c07945f64ece08f1f956a86f1", size = 45706917, upload-time = "2025-12-09T08:06:32.549Z" },
{ url = "https://files.pythonhosted.org/packages/e4/09/3fc9ebd7c95ee54ba6a68d5c0bc23e449f7235f4603fc60534a364934c16/playwright-1.57.0-py3-none-win32.whl", hash = "sha256:1dd93b265688da46e91ecb0606d36f777f8eadcf7fbef12f6426b20bf0c9137c", size = 36553860, upload-time = "2025-12-09T08:06:35.864Z" },
{ url = "https://files.pythonhosted.org/packages/58/d4/dcdfd2a33096aeda6ca0d15584800443dd2be64becca8f315634044b135b/playwright-1.57.0-py3-none-win_amd64.whl", hash = "sha256:6caefb08ed2c6f29d33b8088d05d09376946e49a73be19271c8cd5384b82b14c", size = 36553864, upload-time = "2025-12-09T08:06:38.915Z" },
{ url = "https://files.pythonhosted.org/packages/6a/60/fe31d7e6b8907789dcb0584f88be741ba388413e4fbce35f1eba4e3073de/playwright-1.57.0-py3-none-win_arm64.whl", hash = "sha256:5f065f5a133dbc15e6e7c71e7bc04f258195755b1c32a432b792e28338c8335e", size = 32837940, upload-time = "2025-12-09T08:06:42.268Z" },
]
[[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.23.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/23/53/3edb5d68ecf6b38fcbcc1ad28391117d2a322d9a1a3eff04bfdb184d8c3b/prometheus_client-0.23.1.tar.gz", hash = "sha256:6ae8f9081eaaaf153a2e959d2e6c4f4fb57b12ef76c8c7980202f1e57b48b2ce", size = 80481, upload-time = "2025-09-18T20:47:25.043Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/db/14bafcb4af2139e046d03fd00dea7873e48eafe18b7d2797e73d6681f210/prometheus_client-0.23.1-py3-none-any.whl", hash = "sha256:dd1913e6e76b59cfe44e7a4b83e01afc9873c1bdfd2ed8739f1e76aeca115f99", size = 61145, upload-time = "2025-09-18T20:47:23.875Z" },
]
[[package]]
name = "pyee"
version = "13.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" },
]
[[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 = "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-cov"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage", extra = ["toml"] },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
]
[[package]]
name = "ruff"
version = "0.14.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" },
{ url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" },
{ url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" },
{ url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" },
{ url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" },
{ url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" },
{ url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" },
{ url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" },
{ url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" },
{ url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" },
{ url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" },
{ url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" },
{ url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" },
{ url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" },
{ url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" },
{ url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" },
{ url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" },
{ url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" },
]
[[package]]
name = "soupsieve"
version = "2.8.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/89/23/adf3796d740536d63a6fbda113d07e60c734b6ed5d3058d1e47fc0495e47/soupsieve-2.8.1.tar.gz", hash = "sha256:4cf733bc50fa805f5df4b8ef4740fc0e0fa6218cf3006269afd3f9d6d80fd350", size = 117856, upload-time = "2025-12-18T13:50:34.655Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/f3/b67d6ea49ca9154453b6d70b34ea22f3996b9fa55da105a79d8732227adc/soupsieve-2.8.1-py3-none-any.whl", hash = "sha256:a11fe2a6f3d76ab3cf2de04eb339c1be5b506a8a47f2ceb6d139803177f85434", size = 36710, upload-time = "2025-12-18T13:50:33.267Z" },
]
[[package]]
name = "tomli"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" },
{ url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" },
{ url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" },
{ url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" },
{ url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" },
{ url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" },
{ url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" },
{ url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" },
{ url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" },
{ url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" },
{ url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" },
{ url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" },
{ url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" },
{ url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" },
{ url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" },
{ url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" },
{ url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" },
{ url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" },
{ url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" },
{ url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" },
{ url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" },
{ url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" },
{ url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" },
{ url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" },
{ url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" },
{ url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" },
{ url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" },
{ url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" },
{ url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" },
{ url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" },
{ url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" },
{ url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" },
{ url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" },
{ url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" },
{ url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" },
{ url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" },
{ url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" },
{ url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" },
{ url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" },
{ url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" },
]
[[package]]
name = "ty"
version = "0.0.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/7b/4f677c622d58563c593c32081f8a8572afd90e43dc15b0dedd27b4305038/ty-0.0.9.tar.gz", hash = "sha256:83f980c46df17586953ab3060542915827b43c4748a59eea04190c59162957fe", size = 4858642, upload-time = "2026-01-05T12:24:56.528Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/3f/c1ee119738b401a8081ff84341781122296b66982e5982e6f162d946a1ff/ty-0.0.9-py3-none-linux_armv6l.whl", hash = "sha256:dd270d4dd6ebeb0abb37aee96cbf9618610723677f500fec1ba58f35bfa8337d", size = 9763596, upload-time = "2026-01-05T12:24:37.43Z" },
{ url = "https://files.pythonhosted.org/packages/63/41/6b0669ef4cd806d4bd5c30263e6b732a362278abac1bc3a363a316cde896/ty-0.0.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:debfb2ba418b00e86ffd5403cb666b3f04e16853f070439517dd1eaaeeff9255", size = 9591514, upload-time = "2026-01-05T12:24:26.891Z" },
{ url = "https://files.pythonhosted.org/packages/02/a1/874aa756aee5118e690340a771fb9ded0d0c2168c0b7cc7d9561c2a750b0/ty-0.0.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:107c76ebb05a13cdb669172956421f7ffd289ad98f36d42a44a465588d434d58", size = 9097773, upload-time = "2026-01-05T12:24:14.442Z" },
{ url = "https://files.pythonhosted.org/packages/32/62/cb9a460cf03baab77b3361d13106b93b40c98e274d07c55f333ce3c716f6/ty-0.0.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6868ca5c87ca0caa1b3cb84603c767356242b0659b88307eda69b2fb0bfa416b", size = 9581824, upload-time = "2026-01-05T12:24:35.074Z" },
{ url = "https://files.pythonhosted.org/packages/5a/97/633ecb348c75c954f09f8913669de8c440b13b43ea7d214503f3f1c4bb60/ty-0.0.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d14a4aa0eb5c1d3591c2adbdda4e44429a6bb5d2e298a704398bb2a7ccdafdfe", size = 9591050, upload-time = "2026-01-05T12:24:08.804Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e6/4b0c6a7a8a234e2113f88c80cc7aaa9af5868de7a693859f3c49da981934/ty-0.0.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01bd4466504cefa36b465c6608e9af4504415fa67f6affc01c7d6ce36663c7f4", size = 10018262, upload-time = "2026-01-05T12:24:53.791Z" },
{ url = "https://files.pythonhosted.org/packages/cb/97/076d72a028f6b31e0b87287aa27c5b71a2f9927ee525260ea9f2f56828b8/ty-0.0.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:76c8253d1b30bc2c3eaa1b1411a1c34423decde0f4de0277aa6a5ceacfea93d9", size = 10911642, upload-time = "2026-01-05T12:24:48.264Z" },
{ url = "https://files.pythonhosted.org/packages/3f/5a/705d6a5ed07ea36b1f23592c3f0dbc8fc7649267bfbb3bf06464cdc9a98a/ty-0.0.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8992fa4a9c6a5434eae4159fdd4842ec8726259bfd860e143ab95d078de6f8e3", size = 10632468, upload-time = "2026-01-05T12:24:24.118Z" },
{ url = "https://files.pythonhosted.org/packages/44/78/4339a254537488d62bf392a936b3ec047702c0cc33d6ce3a5d613f275cd0/ty-0.0.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c79d503d151acb4a145a3d98702d07cb641c47292f63e5ffa0151e4020a5d33", size = 10273422, upload-time = "2026-01-05T12:24:45.8Z" },
{ url = "https://files.pythonhosted.org/packages/90/40/e7f386e87c9abd3670dcee8311674d7e551baa23b2e4754e2405976e6c92/ty-0.0.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a7ebf89ed276b564baa1f0dd9cd708e7b5aa89f19ce1b2f7d7132075abf93e", size = 10120289, upload-time = "2026-01-05T12:24:17.424Z" },
{ url = "https://files.pythonhosted.org/packages/f7/46/1027442596e725c50d0d1ab5179e9fa78a398ab412994b3006d0ee0899c7/ty-0.0.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ae3866e50109d2400a886bb11d9ef607f23afc020b226af773615cf82ae61141", size = 9566657, upload-time = "2026-01-05T12:24:51.048Z" },
{ url = "https://files.pythonhosted.org/packages/56/be/df921cf1967226aa01690152002b370a7135c6cced81e86c12b86552cdc4/ty-0.0.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:185244a5eacfcd8f5e2d85b95e4276316772f1e586520a6cb24aa072ec1bac26", size = 9610334, upload-time = "2026-01-05T12:24:20.334Z" },
{ url = "https://files.pythonhosted.org/packages/ac/e8/f085268860232cc92ebe95415e5c8640f7f1797ac3a49ddd137c6222924d/ty-0.0.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f834ff27d940edb24b2e86bbb3fb45ab9e07cf59ca8c5ac615095b2542786408", size = 9726701, upload-time = "2026-01-05T12:24:29.785Z" },
{ url = "https://files.pythonhosted.org/packages/42/b4/9394210c66041cd221442e38f68a596945103d9446ece505889ffa9b3da9/ty-0.0.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:773f4b3ba046de952d7c1ad3a2c09b24f3ed4bc8342ae3cbff62ebc14aa6d48c", size = 10227082, upload-time = "2026-01-05T12:24:40.132Z" },
{ url = "https://files.pythonhosted.org/packages/dc/9f/75951eb573b473d35dd9570546fc1319f7ca2d5b5c50a5825ba6ea6cb33a/ty-0.0.9-py3-none-win32.whl", hash = "sha256:1f20f67e373038ff20f36d5449e787c0430a072b92d5933c5b6e6fc79d3de4c8", size = 9176458, upload-time = "2026-01-05T12:24:32.559Z" },
{ url = "https://files.pythonhosted.org/packages/9b/80/b1cdf71ac874e72678161e25e2326a7d30bc3489cd3699561355a168e54f/ty-0.0.9-py3-none-win_amd64.whl", hash = "sha256:2c415f3bbb730f8de2e6e0b3c42eb3a91f1b5fbbcaaead2e113056c3b361c53c", size = 10040479, upload-time = "2026-01-05T12:24:42.697Z" },
{ url = "https://files.pythonhosted.org/packages/b5/8f/abc75c4bb774b12698629f02d0d12501b0a7dff9c31dc3bd6b6c6467e90a/ty-0.0.9-py3-none-win_arm64.whl", hash = "sha256:48e339d794542afeed710ea4f846ead865cc38cecc335a9c781804d02eaa2722", size = 9543127, upload-time = "2026-01-05T12:24:11.731Z" },
]
[[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" },
]