measure QUIC/HTTP3 handshake RTT to catch UDP/443 blocking (--quic)

The only feature in this batch adding a real dependency, approved up front
and landed the same way tcptrace/ndt7 already do: an optional extra
(uv sync --extra quic), imported lazily inside measure_quic() so a missing
install degrades to a clear skip warning instead of a crash.

Verified the actually-installed aioquic 1.3.0 API directly rather than
assuming. connect() takes local_port but no local_host/source-address
parameter at all, so --quic structurally cannot honor --interface -
_quic_section() says so explicitly rather than silently measuring the
OS-default path under a report claiming a forced adapter. Its context
manager doesn't yield until the handshake finishes, so unlike the TLS RTT
probe's two-connection subtraction trick, handshake time here is timed
directly. HandshakeCompleted's fields matched what was expected; dropped
a planned quic_version field since aioquic has no clean public API for
it rather than reach into a private attribute.

quic_verdict(quic_ok, tcp_ok) is the actual payoff: QUIC failing while
plain TCP to the same host succeeds means the network specifically
drops or throttles UDP/443, silently degrading Chrome/YouTube's HTTP/3
preference in a way no TCP-based diagnostic would ever catch. Both
failing is ordinary unreachability already covered elsewhere, so it
stays silent rather than duplicating that finding.

Opt-in --quic, excluded from --full - an optionally-installed extra must
never become a silent requirement. Added the quic extra to the release
workflow's wheel-install verification.

Live-tested against real cloudflare.com/google.com: real handshakes,
ALPN h3 negotiated, correct end-to-end timing.

This completes the 16-feature extension batch from
plans/zesty-zooming-wilkinson.md (gitignored, local planning doc).
This commit is contained in:
Andrew Pimenov 2026-08-13 01:56:21 +03:00
parent bc8482cc99
commit 66dcd6f5bc
17 changed files with 576 additions and 12 deletions

View file

@ -15,7 +15,7 @@ jobs:
uses: astral-sh/setup-uv@v3
- name: Sync base + optional extras
run: uv sync --extra tcptrace --extra ndt7
run: uv sync --extra tcptrace --extra ndt7 --extra quic
- name: Run test suite
run: uv run pytest -q
@ -27,7 +27,7 @@ jobs:
shell: bash
run: |
python -m venv /tmp/wheel-check
/tmp/wheel-check/Scripts/pip install "$(ls dist/*.whl)[tcptrace,ndt7]"
/tmp/wheel-check/Scripts/pip install "$(ls dist/*.whl)[tcptrace,ndt7,quic]"
/tmp/wheel-check/Scripts/netsleuth --help
- uses: actions/upload-artifact@v4

View file

@ -41,6 +41,7 @@ src/netsleuth/
dpi_check.py --my-server: single-host TCP RST/filtering self-diagnostic. Not a scanner — see Key decisions.
ecmp.py --ecmp: pure N-run traceroute diff to spot per-hop load balancing. Backend-agnostic by design.
pmtud.py --pmtud: binary-searches the largest DF-set packet that reaches a target, to catch a PMTUD blackhole.
quic_rtt.py --quic: QUIC/HTTP3 handshake RTT via the optional aioquic dependency; detects UDP/443 blocking.
nat64.py One RFC 7050 AAAA query (ipv4only.arpa) to detect NAT64/464XLAT translation.
hop_asn.py Per-hop ASN/AS-name/country/PTR enrichment via Team Cymru DNS, in-run deduped.
captive_portal.py Phase-0 gate: three generate_204-style probes for a public-wifi login wall.
@ -179,6 +180,12 @@ The AS prefix benchmark (`probes/prefix_benchmark.py`) gets the same treatment f
**The classic PMTUD blackhole distinction is "did anything tell us why."** `classify_pmtu()` calls a discovered MTU `"reduced"` when a frag-needed signal was seen at any point during the search (PMTUD is working, the path is just narrower — normal behind a VPN/tunnel) and `"blackhole"` only when large packets simply vanish with no ICMP reply at all (the actual VPN/PPPoE-router-misconfiguration symptom: "SSH connects then hangs"). `path.pmtu_below_iface_mtu` is a separate, always-checked finding — a path MTU narrower than the local NIC's own MTU is exactly what makes this class of problem invisible to every locally-run tool.
**QUIC/HTTP3 is the one feature that adds a real dependency, and it stays optional the same way `tcptrace`/`ndt7` already do.** `aioquic` lands as a `[project.optional-dependencies]` extra (`uv sync --extra quic`), never a hard dependency of the base install; `probes/quic_rtt.py::measure_quic()` imports it lazily inside the function (the same `_tier_tcp_trace` pattern `traceroute.py` already uses for scapy), so an `ImportError` is a per-target skip, not a crash, and `_quic_section()` reports a clear `quic.unavailable`-style warning naming the install command rather than silently doing nothing. `aioquic`'s own `connect()` handshake — verified against the actually-installed 1.3.0, not assumed — completes before the async context manager yields, so unlike the TLS RTT probe's two-connection subtraction trick, QUIC's handshake time is measured directly and the report says so.
**QUIC does not support `--interface`, and the report says so rather than silently measuring the OS-default path.** `aioquic.asyncio.client.connect()`'s signature (checked against 1.3.0) takes `local_port` but no `local_host`/source-address parameter at all — there is no structural way to honor a forced adapter here, unlike every other probe in this codebase. `_quic_section()` adds a warning to that effect whenever `options.bind` is set, the same "never runs unbound and silent" posture `--interface`'s Key Decision above establishes for Windows `tracert`.
**A blocked-vs-unreachable distinction is the actual payoff, not the raw handshake number.** `quic_verdict(quic_ok, tcp_ok)` is the pure center of the feature: QUIC failing while a plain TCP connection to the same host on the same port succeeds means the network is specifically dropping or throttling UDP/443 — the network silently degrading Chrome/YouTube's HTTP/3 preference in a way no TCP-based diagnostic would ever surface. Both failing is ordinary unreachability, already covered by the existing TCP/latency findings, so `quic_findings()` stays silent in that case rather than duplicating it.
**Python 3.14 is the floor, not 3.10.** The project moved off `>=3.10` when this batch of probes was added; none of the new code actually depends on a 3.11+-only construct (`ssl.SSLObject.start_tls()`, `asyncio.timeout()`, `datetime.UTC`) — the two-sequential-connection TLS design above was chosen independently of what the interpreter allows. The version floor moved because there was no longer a reason to hold it back, not because a specific new feature required it.
## Storage
@ -193,8 +200,8 @@ The AS prefix benchmark (`probes/prefix_benchmark.py`) gets the same treatment f
`config.py` builds a pydantic-settings `Settings` object whose sources are ordered **init > environment > `.env` > `config.yaml` > field default**. The three API keys are `SecretStr | None = None` at the top level: a diagnostics tool must run with zero configuration, so a missing key downgrades the enrichment that needs it to `skipped` with a warning and never fails the run.
Five sections back the L4-L7 probes, all opt-in at the CLI level and all with a validator-enforced ceiling where the feature could otherwise become mass probing: `tls`, `dns_advanced`, `path_diversity`, `prefix_bench` (`max_prefixes` capped at 256), `dpi_check` (`ports` capped at 16 entries, `concurrency` capped at 4).
Several sections back the L4-L7 probes, all opt-in at the CLI level; the ones whose target list or sweep range could otherwise become mass probing carry a validator-enforced ceiling: `prefix_bench` (`max_prefixes` capped at 256), `dpi_check` (`ports` capped at 16 entries, `concurrency` capped at 4), `captive_portal` (`check_urls` capped at 4), `tls` (`pinned_fingerprints` capped at 32 entries), `ecmp` (`runs` capped at 5, `max_targets` capped at 3), `pmtud` (`targets` capped at 2, `max_bytes` capped at 9000), `quic` (`targets` capped at 4). `dns_advanced` and `path_diversity` are plain opt-in flags with no sweep range to cap.
## Tests
`uv run pytest -q`. Roughly 868 tests covering the parsers, normalizers, verdict engine, DNSBL decoding, throughput math, ping statistics, jitter percentiles, serialization and the report diff. Glue is deliberately untested: live HTTP, real ICMP, Typer wiring, `psutil`/`ctypes` calls and the `--watch` timing loop. Traceroute fixtures live in `tests/fixtures/traceroute/{windows,linux,darwin}/` and include a cp866-encoded Russian Windows sample, because that encoding is exactly what breaks naive parsers. DoH response fixtures (RFC 8484 JSON form) live in `tests/fixtures/api/doh_*.json` alongside the other provider fixtures.
`uv run pytest -q`. Roughly 880 tests covering the parsers, normalizers, verdict engine, DNSBL decoding, throughput math, ping statistics, jitter percentiles, serialization and the report diff. Glue is deliberately untested: live HTTP, real ICMP, Typer wiring, `psutil`/`ctypes` calls and the `--watch` timing loop. Traceroute fixtures live in `tests/fixtures/traceroute/{windows,linux,darwin}/` and include a cp866-encoded Russian Windows sample, because that encoding is exactly what breaks naive parsers. DoH response fixtures (RFC 8484 JSON form) live in `tests/fixtures/api/doh_*.json` alongside the other provider fixtures.

View file

@ -41,6 +41,7 @@ src/netsleuth/
dpi_check.py --my-server: одиночная самопроверка TCP RST/фильтрации. Не сканер — см. Ключевые решения.
ecmp.py --ecmp: чистый diff N прогонов трассировки для обнаружения балансировки нагрузки по хопам. Не зависит от бэкенда по дизайну.
pmtud.py --pmtud: бинарный поиск самого большого пакета с DF, доходящего до цели, для обнаружения чёрной дыры PMTUD.
quic_rtt.py --quic: RTT QUIC/HTTP3-рукопожатия через опциональную зависимость aioquic; детект блокировки UDP/443.
nat64.py Один AAAA-запрос по RFC 7050 (ipv4only.arpa) для детекта трансляции NAT64/464XLAT.
hop_asn.py Обогащение хопов ASN/именем AS/страной/PTR через DNS Team Cymru, дедуп в рамках прогона.
captive_portal.py Фаза 0: три generate_204-пробы на признаки логин-стены публичного wifi.
@ -179,6 +180,12 @@ src/netsleuth/
**Классическое различие чёрной дыры PMTUD — «сказал ли нам хоть кто-то почему».** `classify_pmtu()` называет найденный MTU `"reduced"`, когда сигнал «нужна фрагментация» был замечен в любой момент поиска (PMTUD работает, путь просто уже — нормально за VPN/туннелем), и `"blackhole"` только когда крупные пакеты просто исчезают вообще без ответа ICMP (настоящий симптом неверно настроенного VPN/PPPoE-роутера: «SSH подключается и зависает»). `path.pmtu_below_iface_mtu` — отдельная, всегда проверяемая находка: MTU пути уже, чем MTU самого локального сетевого адаптера — именно это делает такой класс проблем невидимым для любого локально запущенного инструмента.
**QUIC/HTTP3 — единственная фича, добавляющая настоящую зависимость, и она остаётся опциональной так же, как уже делают `tcptrace`/`ndt7`.** `aioquic` встаёт как extra в `[project.optional-dependencies]` (`uv sync --extra quic`), никогда не становясь жёсткой зависимостью базовой установки; `probes/quic_rtt.py::measure_quic()` импортирует его лениво внутри функции (тот же паттерн `_tier_tcp_trace`, что `traceroute.py` уже использует для scapy), поэтому `ImportError` — это пропуск по цели, а не падение, а `_quic_section()` сообщает чёткое предупреждение в духе `quic.unavailable`, называющее команду установки, вместо тихого бездействия. Рукопожатие `connect()` самого `aioquic` — проверено против реально установленной версии 1.3.0, а не предположено — завершается до того, как асинхронный контекстный менеджер отдаёт управление, поэтому, в отличие от трюка с вычитанием двух соединений у TLS RTT-пробы, время рукопожатия QUIC измеряется напрямую, и отчёт об этом прямо говорит.
**QUIC не поддерживает `--interface`, и отчёт об этом честно говорит, а не тихо замеряет путь ОС по умолчанию.** Сигнатура `aioquic.asyncio.client.connect()` (проверено против 1.3.0) принимает `local_port`, но вообще без параметра `local_host`/адреса-источника — структурно нет способа уважить форсированный адаптер здесь, в отличие от любой другой пробы в этой кодовой базе. `_quic_section()` добавляет соответствующее предупреждение всякий раз, когда задан `options.bind` — та же позиция «никогда не работает непривязанно и молча», что уже устанавливает ключевое решение про `--interface` для Windows `tracert` выше.
**Различие «заблокировано» против «недоступно» — реальная польза, а не сырое число рукопожатия.** `quic_verdict(quic_ok, tcp_ok)` — чистый центр фичи: если QUIC не проходит, пока обычное TCP-соединение с тем же хостом на том же порту проходит, значит сеть именно роняет или дросселирует UDP/443 — сеть незаметно ухудшает предпочтение Chrome/YouTube к HTTP/3 так, что никакая TCP-диагностика этого не покажет. Оба отказавших — обычная недоступность, уже покрытая существующими находками TCP/latency, поэтому `quic_findings()` молчит в этом случае, а не дублирует её.
**Python 3.14 — минимальная версия, не 3.10.** Проект ушёл с `>=3.10` одновременно с этим набором проб; ни один новый код на деле не зависит от конструкций только для 3.11+ (`ssl.SSLObject.start_tls()`, `asyncio.timeout()`, `datetime.UTC`) — схема TLS с двумя последовательными соединениями выше выбрана независимо от того, что позволяет интерпретатор. Планка версии сдвинулась потому, что не осталось причин её удерживать, а не потому, что это потребовалось для конкретной новой функции.
## Хранение
@ -193,8 +200,8 @@ src/netsleuth/
`config.py` собирает объект `Settings` на pydantic-settings, источники которого упорядочены **init > переменные окружения > `.env` > `config.yaml` > значение по умолчанию**. Три API-ключа объявлены как `SecretStr | None = None` на верхнем уровне: диагностический инструмент обязан работать с нулевой настройкой, поэтому отсутствие ключа переводит зависящее от него обогащение в `skipped` с предупреждением и никогда не роняет прогон.
Пять секций обслуживают L4-L7 пробы, все опциональны на уровне CLI и все с потолком через валидатор там, где функция иначе могла бы стать массовым прозвоном: `tls`, `dns_advanced`, `path_diversity`, `prefix_bench` (`max_prefixes` ограничен 256), `dpi_check` (`ports` ограничен 16 записями, `concurrency` — 4).
Несколько секций обслуживают L4-L7 пробы, все опциональны на уровне CLI; те, чей список целей или диапазон свипа иначе мог бы стать массовым прозвоном, несут потолок через валидатор: `prefix_bench` (`max_prefixes` ограничен 256), `dpi_check` (`ports` ограничен 16 записями, `concurrency` — 4), `captive_portal` (`check_urls` ограничен 4), `tls` (`pinned_fingerprints` ограничен 32 записями), `ecmp` (`runs` ограничен 5, `max_targets` — 3), `pmtud` (`targets` ограничен 2, `max_bytes` — 9000), `quic` (`targets` ограничен 4). `dns_advanced` и `path_diversity` — простые опциональные флаги без диапазона свипа, который стоило бы ограничивать.
## Тесты
`uv run pytest -q`. Около 868 тестов: парсеры, нормализаторы, движок вердиктов, декодирование DNSBL, математика пропускной способности, статистика пингов, перцентили джиттера, сериализация и сравнение отчётов. Клей намеренно не тестируется: живой HTTP, настоящий ICMP, обвязка Typer, вызовы `psutil`/`ctypes` и цикл `--watch`. Фикстуры трассировок лежат в `tests/fixtures/traceroute/{windows,linux,darwin}/` и включают образец русской Windows в cp866 — именно эта кодировка ломает наивные парсеры. Фикстуры DoH-ответов (формат RFC 8484 JSON) лежат в `tests/fixtures/api/doh_*.json` рядом с фикстурами других провайдеров.
`uv run pytest -q`. Около 880 тестов: парсеры, нормализаторы, движок вердиктов, декодирование DNSBL, математика пропускной способности, статистика пингов, перцентили джиттера, сериализация и сравнение отчётов. Клей намеренно не тестируется: живой HTTP, настоящий ICMP, обвязка Typer, вызовы `psutil`/`ctypes` и цикл `--watch`. Фикстуры трассировок лежат в `tests/fixtures/traceroute/{windows,linux,darwin}/` и включают образец русской Windows в cp866 — именно эта кодировка ломает наивные парсеры. Фикстуры DoH-ответов (формат RFC 8484 JSON) лежат в `tests/fixtures/api/doh_*.json` рядом с фикстурами других провайдеров.

View file

@ -30,10 +30,10 @@ uv sync
uv run netsleuth
```
Want `--tcp-trace` and `--ndt7` too? They're optional extras, not installed by default:
Want `--tcp-trace`, `--ndt7`, or `--quic` too? They're optional extras, not installed by default:
```bash
uv sync --extra tcptrace --extra ndt7
uv sync --extra tcptrace --extra ndt7 --extra quic
```
Without `uv`:
@ -69,6 +69,7 @@ None of this is required — netsleuth degrades gracefully without any of it —
- **DNS: system vs DoH** (`--dns-advanced`) — compares your system resolver's answers and latency against Cloudflare/Google/Quad9 DoH, and probes a bogus resolver IP to detect a transparent DNS proxy intercepting port 53.
- **Path diversity / Anycast** (`--path-diversity`) — reads Cloudflare's `CF-RAY` edge code to find which PoP is actually serving your traffic, and flags an international routing loop when it's in a different country than you are.
- **ECMP / multipath detection** (`--ecmp`) — traces a couple of reference hosts multiple times and diffs which next hop showed up at each TTL, catching per-hop load balancing that makes traceroutes look inconsistent, and flagging when one branch of a load-balanced pair is measurably worse than the other.
- **QUIC / HTTP3 handshake RTT** (`--quic`, needs the `quic` extra) — measures QUIC handshake time end-to-end and checks whether UDP/443 is reachable at all: when QUIC fails but a plain TCP connection to the same host succeeds, the network is specifically dropping or throttling QUIC, silently degrading Chrome/YouTube's HTTP/3 preference in a way no TCP-based diagnostic would ever catch.
- **Path MTU discovery** (`--pmtud`) — binary-searches the largest packet with the Don't Fragment bit set that actually reaches a target, distinguishing a path that's simply narrower (a router correctly says "fragmentation needed") from a PMTUD blackhole (large packets vanish silently — the classic "SSH connects then hangs" VPN/PPPoE symptom).
- **AS prefix benchmark** (`--prefix-bench`) — pings the first host of a handful of your own AS's announced prefixes to find the lowest-latency PoP. Capped at 256 prefixes and off by default — this samples your own network, it does not scan it.
- **DPI / port self-check** (`--my-server <host>`) — a single-host TCP probe across 6 fixed ports on **a server you own**, classifying TCP RST injection vs silent filtering vs a clean response. Not a port scanner: one host per run, a fixed short port list, and it never reads its target from your own egress IP or from BGP data — see [ARCHITECTURE.md](ARCHITECTURE.md) for why.
@ -95,6 +96,7 @@ None of this is required — netsleuth degrades gracefully without any of it —
| `--ecmp` | Trace a couple of reference hosts multiple times (`ecmp.runs`, capped at 5) to detect per-hop load balancing and measure how unequal the branches are. **Not** included in `--full` — it multiplies traceroute traffic to the same targets, opt in explicitly |
| `--ipv6` / `--no-ipv6` | Force IPv6 reference-host latency measurement on or off, overriding `probing.ipv6` (default `auto`: only on a dual-stack connection). Free to leave at auto — a no-op on a v4-only host |
| `--pmtud` | Binary-search the largest DF-set packet that reaches each `pmtud.targets` host (default: 2 hosts) to catch a path-MTU blackhole. **Not** included in `--full` — opt in explicitly |
| `--quic` | Measure QUIC/HTTP3 handshake time and check whether UDP/443 is blocked (needs `uv sync --extra quic`; degrades to a clear "not installed" note otherwise). Does not support `--interface` — aioquic has no source-address bind option. **Not** included in `--full` |
| `--my-server <host>` | Check a server **you own** for TCP port blocking / RST injection. Single host only — rejects CIDR input outright. **Not** included in `--full` — opt in explicitly |
| `--ndt7` | Opt in to M-Lab NDT7 (publishes your measurement, including your IP, as CC0 open data). On an interactive terminal it prints the consent notice and asks first; on a non-interactive one it proceeds without asking |
| `--tcp-trace` | Opt in to a scapy TCP-SYN traceroute through ICMP-filtering middleboxes (needs the `tcptrace` extra plus Npcap on Windows or root on Unix). Falls through to the normal cascade when it cannot run |

View file

@ -30,10 +30,10 @@ uv sync
uv run netsleuth
```
Нужны `--tcp-trace` и `--ndt7`? Это опциональные extras, по умолчанию не ставятся:
Нужны `--tcp-trace`, `--ndt7` или `--quic`? Это опциональные extras, по умолчанию не ставятся:
```bash
uv sync --extra tcptrace --extra ndt7
uv sync --extra tcptrace --extra ndt7 --extra quic
```
Без `uv`:
@ -69,6 +69,7 @@ netsleuth
- **DNS: системный vs DoH** (`--dns-advanced`) — сравнивает ответы и задержку системного резолвера с Cloudflare/Google/Quad9 DoH, а также проверяет фиктивный IP резолвера на признаки прозрачного DNS-прокси, перехватывающего 53 порт.
- **Диверсификация маршрута / Anycast** (`--path-diversity`) — по заголовку Cloudflare `CF-RAY` определяет, какая точка (PoP) реально обслуживает трафик, и отмечает международную петлю маршрута, если она в другой стране, чем клиент.
- **Детект ECMP / нескольких маршрутов** (`--ecmp`) — трассирует пару эталонных хостов несколько раз и сравнивает, какой следующий хоп появлялся на каждом TTL, ловя балансировку нагрузки по хопам, из-за которой трассировки выглядят непоследовательными, и отмечая, если одна из веток балансировки заметно хуже другой.
- **RTT QUIC/HTTP3-рукопожатия** (`--quic`, нужен extra `quic`) — измеряет время QUIC-рукопожатия целиком и проверяет, доступен ли вообще UDP/443: если QUIC не проходит, а обычное TCP-соединение с тем же хостом проходит, сеть именно роняет или дросселирует QUIC, незаметно ухудшая предпочтение Chrome/YouTube к HTTP/3 так, что никакая TCP-диагностика этого не поймает.
- **Обнаружение MTU пути** (`--pmtud`) — бинарным поиском находит самый большой пакет с флагом Don't Fragment, реально доходящий до цели, отличая путь, который просто уже (роутер корректно говорит «нужна фрагментация»), от чёрной дыры PMTUD (крупные пакеты молча исчезают — классический симптом VPN/PPPoE «SSH подключается и зависает»).
- **Бенчмарк префиксов AS** (`--prefix-bench`) — пингует первый хост нескольких анонсируемых префиксов вашей собственной AS, чтобы найти точку с наименьшей задержкой. Ограничено 256 префиксами и выключено по умолчанию — это выборочная проба своей же сети, а не её сканирование.
- **Самопроверка DPI / портов** (`--my-server <host>`) — одиночная TCP-проба по 6 фиксированным портам **сервера, которым вы владеете**, с классификацией инъекции TCP RST, тихой фильтрации или чистого ответа. Это не портсканер: один хост за запуск, фиксированный короткий список портов, и цель никогда не берётся из вашего собственного внешнего IP или из данных BGP — почему именно так, см. [ARCHITECTURE.ru.md](ARCHITECTURE.ru.md).
@ -95,6 +96,7 @@ netsleuth
| `--ecmp` | Трассировать пару эталонных хостов несколько раз (`ecmp.runs`, потолок 5) для детекта балансировки нагрузки по хопам и замера, насколько неравноценны ветки. **Не** входит в `--full` — умножает трафик трассировки к тем же целям, включается явно |
| `--ipv6` / `--no-ipv6` | Принудительно включить или выключить замер задержки до эталонных хостов IPv6, переопределяя `probing.ipv6` (по умолчанию `auto`: только на dual-stack соединении). На v4-only хосте `auto` — это no-op, можно не трогать |
| `--pmtud` | Бинарным поиском найти самый большой пакет с DF, доходящий до каждого хоста из `pmtud.targets` (по умолчанию: 2 хоста), для обнаружения чёрной дыры MTU пути. **Не** входит в `--full` — включается явно |
| `--quic` | Измерить время QUIC/HTTP3-рукопожатия и проверить, не заблокирован ли UDP/443 (нужен `uv sync --extra quic`; иначе деградирует до чёткой заметки «не установлен»). Не поддерживает `--interface` — у aioquic нет опции привязки адреса-источника. **Не** входит в `--full` |
| `--my-server <host>` | Проверить сервер, **которым вы владеете**, на блокировку портов / инъекцию RST. Только один хост — CIDR отклоняется сразу. **Не** входит в `--full` — включается явно |
| `--ndt7` | Включить M-Lab NDT7 (замер публикуется как открытые данные CC0, включая ваш IP). В интерактивном терминале сначала печатает уведомление о согласии и спрашивает подтверждение; в неинтерактивном — продолжает без вопроса |
| `--tcp-trace` | Включить TCP-SYN-трассировку через scapy сквозь middlebox'ы, режущие ICMP (нужны extras `tcptrace` и Npcap на Windows или root на Unix). Если не может запуститься — откатывается на обычный каскад |

View file

@ -82,6 +82,7 @@ thresholds:
prefix_spread_ms: { good: 20.0, warn: 80.0 }
tls_cpu_bound_ratio: 2.0
first_hop_ms: { good: 5.0, warn: 25.0 }
quic_handshake_ms: { good: 150.0, warn: 400.0 }
tls:
targets:
@ -132,6 +133,18 @@ path_diversity:
# Runs first, before any other probe. Checks a handful of known generate_204-style
# endpoints for a redirect/tampered response, the classic signature of a public-wifi
# login wall. check_urls is capped at 4 in code.
# Opt-in via --quic (needs the optional `quic` extra: uv sync --extra quic).
# Measures QUIC/HTTP3 handshake time and checks whether UDP/443 is reachable at
# all -- a network that silently drops or throttles QUIC degrades Chrome/YouTube
# without ever showing up in an ordinary TCP-based diagnostic.
quic:
targets:
- { label: "cloudflare", host: "cloudflare.com" }
- { label: "google", host: "google.com" }
port: 443
concurrency: 2
timeout_seconds: 5.0
# Opt-in via --pmtud. Binary-searches for the largest DF-set packet that gets
# through to each target, to catch a VPN/PPPoE-style PMTUD blackhole. targets is
# capped at 2 in code: this is a path-MTU sweep, not a scan.

View file

@ -22,6 +22,7 @@ dependencies = [
[project.optional-dependencies]
tcptrace = ["scapy>=2.5"]
ndt7 = ["websockets>=12"]
quic = ["aioquic>=1.2"]
[project.scripts]
netsleuth = "netsleuth.cli:main"

View file

@ -37,6 +37,7 @@ from netsleuth.interpret import (
path_findings,
pmtud_findings,
prefix_findings,
quic_findings,
speed_findings,
tls_findings,
)
@ -94,6 +95,7 @@ class Options:
prefix_bench: bool = False
ecmp: bool = False
pmtud: bool = False
quic: bool = False
ipv6: Optional[bool] = None
dpi_target: str | None = None
formats: frozenset[str] = frozenset({"md"})
@ -403,6 +405,31 @@ async def _dpi_section(settings: Settings, options: Options) -> ModuleResult:
return ModuleResult(name="dpi_check", status="ok", data=result)
async def _quic_section(settings: Settings, options: Options) -> ModuleResult:
if not options.quic:
return ModuleResult(name="quic", status="skipped", warnings=["quic probe skipped: not requested"])
try:
import aioquic # noqa: F401
except ImportError:
return ModuleResult(
name="quic",
status="skipped",
warnings=["quic probe skipped: aioquic not installed (uv sync --extra quic)"],
)
from netsleuth.probes.quic_rtt import quic_fanout
warnings: list[str] = []
if options.bind:
# aioquic's connect() takes only local_port, never a source address, so a
# forced adapter cannot be honored here -- say so rather than silently
# measuring the OS-default path under a report claiming a forced interface.
warnings.append("quic probe ran over the OS default route: aioquic has no source-address bind option")
cfg = settings.quic
targets = [(t.label, t.host) for t in cfg.targets]
results = await quic_fanout(targets, port=cfg.port, timeout=cfg.timeout_seconds, concurrency=cfg.concurrency)
return ModuleResult(name="quic", status="ok", data=results, warnings=warnings)
async def _pmtud_section(settings: Settings, options: Options, iface_mtu: int | None) -> ModuleResult:
if not options.pmtud:
return ModuleResult(name="pmtud", status="skipped", warnings=["pmtud probe skipped: not requested"])
@ -689,6 +716,9 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
modules["pmtud"] = await run_module(
"pmtud", _pmtud_section(settings, options, local.iface_mtu), timeout=timeouts.subprocess_seconds
)
modules["quic"] = await run_module(
"quic", _quic_section(settings, options), timeout=timeouts.module_seconds
)
# Phase 3c — per-hop ASN/AS-name/country enrichment. Mutates the trace hops
# in place; a lookup failure must never downgrade the trace itself, so only
@ -732,6 +762,8 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
l7_findings += dns_advanced_findings(modules["dns_advanced"].data, settings.thresholds)
if modules["path_diversity"].data is not None:
l7_findings += path_diversity_findings(modules["path_diversity"].data)
if modules["quic"].data is not None:
l7_findings += quic_findings(modules["quic"].data, settings.thresholds)
traces = modules["path"].data or []
findings = _dedupe(
latency_findings(pings, settings.thresholds)
@ -895,6 +927,11 @@ def run(
"--pmtud",
help="Binary-search the largest DF-set packet that reaches each pmtud.target, to catch a PMTUD blackhole.",
),
quic: bool = typer.Option(
False,
"--quic",
help="Measure QUIC/HTTP3 handshake time and check whether UDP/443 is blocked (needs the quic extra).",
),
my_server: Optional[str] = typer.Option(
None,
"--my-server",
@ -968,6 +1005,7 @@ def run(
prefix_bench=prefix_bench,
ecmp=ecmp,
pmtud=pmtud,
quic=quic,
ipv6=ipv6,
dpi_target=my_server,
formats=formats,

View file

@ -144,6 +144,7 @@ class Thresholds(BaseModel):
prefix_spread_ms: Band = Band(good=20.0, warn=80.0)
tls_cpu_bound_ratio: float = 2.0
first_hop_ms: Band = Band(good=5.0, warn=25.0)
quic_handshake_ms: Band = Band(good=150.0, warn=400.0)
_SHA256_HEX_RE = re.compile(r"^[0-9a-f]{64}$")
@ -238,6 +239,25 @@ class DnsAdvancedConfig(BaseModel):
bogus_probe_name: str = "cloudflare.com"
class QuicConfig(BaseModel):
targets: list[HostSpec] = Field(
default_factory=lambda: [
HostSpec(label="cloudflare", host="cloudflare.com"),
HostSpec(label="google", host="google.com"),
]
)
port: int = 443
concurrency: int = 2
timeout_seconds: float = 5.0
@field_validator("targets")
@classmethod
def _cap_targets(cls, value: list[HostSpec]) -> list[HostSpec]:
if len(value) > 4:
raise ValueError("quic.targets may list at most 4 hosts")
return value
class PmtudConfig(BaseModel):
targets: list[HostSpec] = Field(
default_factory=lambda: [
@ -414,6 +434,7 @@ class Settings(BaseSettings):
captive_portal: CaptivePortalConfig = CaptivePortalConfig()
ecmp: EcmpConfig = EcmpConfig()
pmtud: PmtudConfig = PmtudConfig()
quic: QuicConfig = QuicConfig()
ipinfo_token: SecretStr | None = Field(default=None, alias="IPINFO_TOKEN")
peeringdb_api_key: SecretStr | None = Field(default=None, alias="PEERINGDB_API_KEY")

View file

@ -21,6 +21,7 @@ SECTION_ORDER = (
"dns_advanced",
"latency",
"tls",
"quic",
"path",
"ecmp",
"pmtud",
@ -131,6 +132,9 @@ _WARNING_RU = {
"captive portal check disabled in config": "проверка captive portal отключена в конфиге",
"ecmp probe skipped: not requested": "проверка ECMP пропущена: не запрошена",
"pmtud probe skipped: not requested": "проверка PMTUD пропущена: не запрошена",
"quic probe skipped: not requested": "проверка QUIC пропущена: не запрошена",
"quic probe skipped: aioquic not installed (uv sync --extra quic)": "проверка QUIC пропущена: aioquic не установлен (uv sync --extra quic)",
"quic probe ran over the OS default route: aioquic has no source-address bind option": "проверка QUIC выполнена через маршрут ОС по умолчанию: у aioquic нет опции привязки адреса-источника",
}
@ -258,6 +262,7 @@ _SECTION_TITLES = {
"dns_advanced": "## DNS: system vs DoH",
"latency": "## Latency",
"tls": "## TLS handshake",
"quic": "## QUIC / HTTP3",
"path": "## Path",
"ecmp": "## ECMP / multipath",
"pmtud": "## Path MTU discovery",
@ -277,6 +282,7 @@ _SECTION_TITLES_RU = {
"dns_advanced": "## DNS: системный резолвер vs DoH",
"latency": "## Задержки",
"tls": "## TLS-рукопожатие",
"quic": "## QUIC / HTTP3",
"path": "## Маршрут",
"ecmp": "## ECMP / несколько маршрутов",
"pmtud": "## Обнаружение MTU пути",
@ -835,6 +841,48 @@ def _tls(report: dict, lang: str = "en") -> list[str]:
return lines + [""]
def _quic(report: dict, lang: str = "en") -> list[str]:
title = _section_title("quic", lang)
note = unavailable(report, "quic", lang)
if note:
return [title, "", note, ""]
results = _data(report, "quic") or []
lines = [title, ""] + _table(
[
_t(lang, "Target", "Цель"),
_t(lang, "Handshake (ms)", "Рукопожатие (мс)"),
_t(lang, "ALPN", "ALPN"),
_t(lang, "Session resumed", "Сессия возобновлена"),
_t(lang, "TCP RTT (ms)", "TCP RTT (мс)"),
],
[
[
r.get("label", ""),
_num(r.get("handshake_ms")),
r.get("alpn") or "—",
_t(lang, "yes", "да") if r.get("session_resumed") else _t(lang, "no", "нет"),
_num(r.get("tcp_rtt_ms")),
]
for r in results
],
lang,
)
blocked = [r for r in results if r.get("error") and r.get("tcp_rtt_ms") is not None]
if blocked:
lines += [""] + [
f"- ⚠️ {r.get('label')}: {_t(lang, 'QUIC failed while TCP succeeded', 'QUIC не прошёл, хотя TCP прошёл')} ({r.get('error')})"
for r in blocked
]
errored = [r for r in results if r.get("error") and r.get("tcp_rtt_ms") is None]
if errored:
lines += [""] + [f"- {r.get('label')}: {r.get('error')}" for r in errored]
lines += [
"",
f"> {_t(lang, 'Unlike the TLS measurement above, the handshake time here is timed end-to-end by aioquic, not derived by subtraction.', 'В отличие от замера TLS выше, время рукопожатия здесь измерено aioquic целиком, а не получено вычитанием.')}",
]
return lines + [""]
def _dns_advanced(report: dict, lang: str = "en") -> list[str]:
title = _section_title("dns_advanced", lang)
note = unavailable(report, "dns_advanced", lang)
@ -1027,6 +1075,7 @@ def render_markdown(
lines += _dns_advanced(report, lang)
lines += _latency(report, lang)
lines += _tls(report, lang)
lines += _quic(report, lang)
lines += _path(report, lang)
lines += _ecmp(report, lang)
lines += _pmtud(report, lang)

View file

@ -15,6 +15,7 @@ from netsleuth.models import (
PathDiversity,
PingResult,
PmtuResult,
QuicResult,
PrefixBenchmark,
Signal,
SpeedResult,
@ -25,6 +26,7 @@ from netsleuth.models import (
VpnContext,
)
from netsleuth.netinfo import is_cgnat, is_tunnel_iface, mtu_anomaly
from netsleuth.probes.quic_rtt import quic_verdict
_SEVERITY_ORDER = {"ok": 0, "info": 1, "warn": 2, "crit": 3}
@ -374,6 +376,55 @@ def dual_stack_findings(nat64_prefix: str | None, local: LocalNet) -> list[Findi
]
def quic_findings(results: list[QuicResult], t: Thresholds) -> list[Finding]:
findings: list[Finding] = []
for r in results:
quic_ok = r.error is None and r.handshake_ms is not None
tcp_ok = r.tcp_rtt_ms is not None
if quic_verdict(quic_ok, tcp_ok) == "blocked":
findings.append(
Finding(
id=f"quic.blocked.{r.label}",
severity="warn",
title=f"QUIC to {r.host} is blocked or filtered",
detail=f"UDP/443 handshake to {r.host} failed ({r.error}) while TCP/443 succeeded "
f"({r.tcp_rtt_ms} ms), so the host itself is reachable.",
metric="error",
value=r.error,
advice="The network drops or throttles QUIC while allowing TCP; this silently degrades "
"Chrome/YouTube and anything else that prefers HTTP/3, without ever showing up in a "
"TCP-only diagnostic.",
title_ru=f"QUIC до {r.host} заблокирован или отфильтрован",
detail_ru=f"Рукопожатие UDP/443 с {r.host} не удалось ({r.error}), а TCP/443 прошёл "
f"({r.tcp_rtt_ms} мс) — сам хост доступен.",
advice_ru="Сеть роняет или дросселирует QUIC, пропуская TCP; это незаметно ухудшает "
"Chrome/YouTube и всё, что предпочитает HTTP/3, никогда не проявляясь в TCP-only диагностике.",
)
)
continue
if quic_ok:
severity = severity_for(r.handshake_ms, t.quic_handshake_ms)
if severity not in ("ok", "info"):
findings.append(
Finding(
id=f"quic.handshake_slow.{r.label}",
severity=severity,
title=f"QUIC handshake to {r.host} above target",
detail=f"QUIC handshake took {r.handshake_ms} ms.",
metric="handshake_ms",
value=r.handshake_ms,
threshold=t.quic_handshake_ms.warn,
advice="Unlike the TLS handshake measurement, this is timed end-to-end by aioquic, "
"not derived by subtraction.",
title_ru=f"QUIC-рукопожатие с {r.host} выше целевого",
detail_ru=f"QUIC-рукопожатие заняло {r.handshake_ms} мс.",
advice_ru="В отличие от замера TLS-рукопожатия, это время измерено aioquic целиком, "
"а не получено вычитанием.",
)
)
return findings
def pmtud_findings(result: PmtuResult) -> list[Finding]:
findings: list[Finding] = []
if result.verdict == "blackhole":

View file

@ -523,6 +523,18 @@ class EcmpReport:
note_ru: str | None = None
@dataclass
class QuicResult:
label: str = ""
host: str = ""
port: int = 443
handshake_ms: float | None = None
alpn: str | None = None
session_resumed: bool | None = None
tcp_rtt_ms: float | None = None
error: str | None = None
@dataclass
class PmtuResult:
host: str

View file

@ -0,0 +1,69 @@
from __future__ import annotations
import asyncio
import time
from netsleuth.models import QuicResult
from netsleuth.probes.latency import tcp_connect_rtt
def quic_verdict(quic_ok: bool, tcp_ok: bool) -> str:
if quic_ok:
return "ok"
if tcp_ok:
return "blocked"
return "unreachable"
async def measure_quic(label: str, host: str, *, port: int = 443, timeout: float = 5.0) -> QuicResult:
result = QuicResult(label=label, host=host, port=port)
try:
import aioquic # noqa: F401
except ImportError:
result.error = "aioquic not installed"
return result
from aioquic.asyncio.client import connect
from aioquic.asyncio.protocol import QuicConnectionProtocol
from aioquic.quic.configuration import QuicConfiguration
from aioquic.quic.events import HandshakeCompleted
class _HandshakeCapture(QuicConnectionProtocol):
handshake_event: HandshakeCompleted | None = None
def quic_event_received(self, event) -> None:
if isinstance(event, HandshakeCompleted):
self.handshake_event = event
result.tcp_rtt_ms = await tcp_connect_rtt(host, port=port, timeout=timeout)
configuration = QuicConfiguration(is_client=True, alpn_protocols=["h3"], server_name=host)
async def _handshake() -> None:
began = time.perf_counter()
async with connect(
host, port, configuration=configuration, create_protocol=_HandshakeCapture, wait_connected=True
) as protocol:
result.handshake_ms = round((time.perf_counter() - began) * 1000.0, 3)
event = protocol.handshake_event
if event is not None:
result.alpn = event.alpn_protocol
result.session_resumed = event.session_resumed
try:
await asyncio.wait_for(_handshake(), timeout=timeout)
except Exception as exc: # noqa: BLE001 - a dead UDP/443 path is data, not control flow
result.error = str(exc) or exc.__class__.__name__
return result
async def quic_fanout(
targets: list[tuple[str, str]], *, port: int, timeout: float, concurrency: int
) -> list[QuicResult]:
semaphore = asyncio.Semaphore(concurrency)
async def _bounded(label: str, host: str) -> QuicResult:
async with semaphore:
return await measure_quic(label, host, port=port, timeout=timeout)
return list(await asyncio.gather(*(_bounded(label, host) for label, host in targets)))

View file

@ -267,6 +267,14 @@ def test_probing_ipv6_accepts_each_known_value(tmp_path):
assert s.probing.ipv6 == value
def test_quic_targets_are_capped(tmp_path):
path = tmp_path / "config.yaml"
entries = ", ".join(f"{{label: h{i}, host: h{i}.com}}" for i in range(5))
path.write_text(f"quic:\n targets: [{entries}]\n", encoding="utf-8")
with pytest.raises(ValidationError, match="at most 4"):
load_settings(config_path=path, env_file=tmp_path / "nope.env")
def test_pmtud_targets_are_capped(tmp_path):
path = tmp_path / "config.yaml"
path.write_text(

View file

@ -90,6 +90,7 @@ def test_missing_modules_are_rendered_as_skipped_sections_not_dropped():
"dns_advanced",
"latency",
"tls",
"quic",
"path",
"ecmp",
"pmtud",
@ -601,6 +602,7 @@ def test_markdown_has_every_section_from_the_spec_in_order():
"## DNS: system vs DoH",
"## Latency",
"## TLS handshake",
"## QUIC / HTTP3",
"## Path",
"## ECMP / multipath",
"## Path MTU discovery",
@ -806,7 +808,7 @@ def test_diagnostics_translates_module_warnings_into_russian():
def test_an_all_modules_failed_run_still_renders_every_section():
text = render_markdown(dead_report())
headings = [line for line in text.splitlines() if line.startswith("## ")]
assert len(headings) == 18
assert len(headings) == 19
assert text.count("Not available") == len(SECTION_ORDER)
@ -911,6 +913,7 @@ def test_render_markdown_default_call_is_byte_identical_to_before_the_lang_featu
"## DNS: system vs DoH",
"## Latency",
"## TLS handshake",
"## QUIC / HTTP3",
"## Path",
"## ECMP / multipath",
"## Path MTU discovery",

80
tests/test_quic_rtt.py Normal file
View file

@ -0,0 +1,80 @@
from __future__ import annotations
import pytest
from netsleuth.config import Thresholds
from netsleuth.interpret import quic_findings
from netsleuth.models import QuicResult
from netsleuth.probes.quic_rtt import quic_verdict
def test_quic_verdict_ok_when_quic_succeeds():
assert quic_verdict(quic_ok=True, tcp_ok=True) == "ok"
def test_quic_verdict_ok_even_if_tcp_state_is_unknown():
assert quic_verdict(quic_ok=True, tcp_ok=False) == "ok"
def test_quic_verdict_blocked_when_quic_fails_but_tcp_succeeds():
assert quic_verdict(quic_ok=False, tcp_ok=True) == "blocked"
def test_quic_verdict_unreachable_when_both_fail():
assert quic_verdict(quic_ok=False, tcp_ok=False) == "unreachable"
def quic(**kw) -> QuicResult:
base = dict(label="cloudflare", host="cloudflare.com", port=443)
base.update(kw)
return QuicResult(**base)
@pytest.fixture()
def thresholds() -> Thresholds:
return Thresholds()
def test_quic_findings_silent_on_a_healthy_result(thresholds):
assert quic_findings([quic(handshake_ms=50.0, tcp_rtt_ms=20.0)], thresholds) == []
def test_quic_findings_flags_blocked_quic_with_working_tcp(thresholds):
findings = quic_findings([quic(error="timeout", tcp_rtt_ms=20.0)], thresholds)
assert [f.id for f in findings] == ["quic.blocked.cloudflare"]
assert findings[0].severity == "warn"
def test_quic_findings_silent_when_both_quic_and_tcp_fail(thresholds):
# a fully dead host is latency_findings' job (latency.unreachable), not quic's.
assert quic_findings([quic(error="timeout", tcp_rtt_ms=None)], thresholds) == []
def test_quic_findings_flags_a_slow_handshake(thresholds):
findings = quic_findings([quic(handshake_ms=500.0, tcp_rtt_ms=20.0)], thresholds)
assert [f.id for f in findings] == ["quic.handshake_slow.cloudflare"]
def test_quic_findings_fast_handshake_produces_nothing(thresholds):
findings = quic_findings([quic(handshake_ms=50.0, tcp_rtt_ms=20.0)], thresholds)
assert findings == []
aioquic = pytest.importorskip("aioquic")
async def test_measure_quic_reports_an_error_for_an_unreachable_host():
from netsleuth.probes.quic_rtt import measure_quic
result = await measure_quic("dead", "192.0.2.1", port=1, timeout=0.5)
assert result.label == "dead"
assert result.error
assert result.handshake_ms is None
async def test_quic_fanout_returns_a_result_per_target():
from netsleuth.probes.quic_rtt import quic_fanout
results = await quic_fanout([("a", "192.0.2.1"), ("b", "192.0.2.2")], port=1, timeout=0.5, concurrency=2)
assert len(results) == 2
assert all(r.error for r in results)

203
uv.lock generated
View file

@ -2,6 +2,32 @@ version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "aioquic"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "cryptography" },
{ name = "pylsqpack" },
{ name = "pyopenssl" },
{ name = "service-identity" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6b/0c/858bb02e0ff96b40735b09ed7be25690197851e4c1bcde51af3348c851fc/aioquic-1.3.0.tar.gz", hash = "sha256:28d070b2183e3e79afa9d4e7bd558960d0d53aeb98bc0cf0a358b279ba797c92", size = 181923, upload-time = "2025-10-11T09:16:30.91Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/41/9a6cf092f2d21768091969dccd4723270f4cd8138d00097160d9c8eabeb8/aioquic-1.3.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:59da070ff0f55a54f5623c9190dbc86638daa0bcf84bbdb11ebe507abc641435", size = 1922701, upload-time = "2025-10-11T09:16:10.971Z" },
{ url = "https://files.pythonhosted.org/packages/9e/ea/ac91850a3e6c915802d8c0ee782f966ddfaeed9f870696c1cdb98b25c9a1/aioquic-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:48590fa38ec13f01a3d4e44fb3cfd373661094c9c7248f3c54d2d9512b6c3469", size = 2240281, upload-time = "2025-10-11T09:16:12.895Z" },
{ url = "https://files.pythonhosted.org/packages/a8/65/383f3b3921e1d6b9b757bff3c805c24f7180eda690aecb5e8df50eb7b028/aioquic-1.3.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:019b16580d53541b5d77b4a44a61966921156554fad2536d74895713c800caa5", size = 2752433, upload-time = "2025-10-11T09:16:14.724Z" },
{ url = "https://files.pythonhosted.org/packages/b9/00/66f9a2f95db35ccbe1d9384d44beae28072fceec6ca0ffa29f6c640516c2/aioquic-1.3.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:396e5f53f6ddb27713d9b5bb11d8f0f842e42857b7e671c5ae203bf618528550", size = 2445180, upload-time = "2025-10-11T09:16:17.136Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7a/f020815b9fa6ea9b83354deb213b90a25fd01466f5a8e517e1c0e672be8c/aioquic-1.3.0-cp310-abi3-manylinux_2_28_i686.whl", hash = "sha256:4098afc6337adf19bdb54474f6c37983988e7bfa407892a277259c32eb664b00", size = 2361800, upload-time = "2025-10-11T09:16:18.685Z" },
{ url = "https://files.pythonhosted.org/packages/87/be/a141aafe8984ed380e610397d606a9d9818ef30ce352aa9ede048a966d81/aioquic-1.3.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:48292279a248422b6289fffd82159eba8d8b35ff4b1f660b9f74ff85e10ca265", size = 2797515, upload-time = "2025-10-11T09:16:20.451Z" },
{ url = "https://files.pythonhosted.org/packages/52/50/b421e7aedff4a96840bf8734c2c11c18a8434c780c0cb59dff7f0906cee8/aioquic-1.3.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:0538acdfbf839d87b175676664737c248cd51f1a2295c5fef8e131ddde478a86", size = 2388628, upload-time = "2025-10-11T09:16:21.661Z" },
{ url = "https://files.pythonhosted.org/packages/bc/f4/3c674f4608883e7fc7212f067c599d1321b0c5dd45bda5c77ab5a1e73924/aioquic-1.3.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a8881239801279188e33ced6f9849cedf033325a48a6f44d7e55e583abc555a3", size = 2465059, upload-time = "2025-10-11T09:16:23.474Z" },
{ url = "https://files.pythonhosted.org/packages/23/f2/7b1908feffb29b89d2f6d4adc583e83543cd559676354f85c5b4b77a6428/aioquic-1.3.0-cp310-abi3-win32.whl", hash = "sha256:ba30016244e45d9222fdd1fbd4e8b0e5f6811e81a5d0643475ad7024a537274a", size = 1326532, upload-time = "2025-10-11T09:16:25.971Z" },
{ url = "https://files.pythonhosted.org/packages/82/45/4e47404984d65ee31cc9e1370f1fbc4e8c92b25da71f61429dbdba437246/aioquic-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:2d7957ba14a6c5efcc14fdc685ccda7ecf0ad048c410a2bdcad1b63bf9527e8e", size = 1675068, upload-time = "2025-10-11T09:16:27.258Z" },
{ url = "https://files.pythonhosted.org/packages/43/60/a8cb5f85c5a6a3cc630124a45644ca5a0ab3eecae2df558b6e0ab7847e1c/aioquic-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:9d15a89213d38cbc4679990fa5151af8ea02655a1d6ce5ec972b0a6af74d5f1c", size = 1234825, upload-time = "2025-10-11T09:16:28.994Z" },
]
[[package]]
name = "annotated-doc"
version = "0.0.5"
@ -32,6 +58,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
]
[[package]]
name = "attrs"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
@ -41,6 +76,65 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
@ -50,6 +144,56 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cryptography"
version = "50.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" },
{ url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" },
{ url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" },
{ url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" },
{ url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" },
{ url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" },
{ url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" },
{ url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" },
{ url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" },
{ url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" },
{ url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" },
{ url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" },
{ url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" },
{ url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" },
{ url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" },
{ url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" },
{ url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" },
{ url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" },
{ url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" },
{ url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" },
{ url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" },
{ url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" },
{ url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" },
{ url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" },
{ url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" },
{ url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" },
{ url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" },
{ url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" },
{ url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" },
{ url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" },
{ url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" },
{ url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" },
{ url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" },
{ url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" },
{ url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" },
{ url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" },
{ url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" },
{ url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" },
]
[[package]]
name = "dnspython"
version = "2.8.0"
@ -200,6 +344,9 @@ dependencies = [
ndt7 = [
{ name = "websockets" },
]
quic = [
{ name = "aioquic" },
]
tcptrace = [
{ name = "scapy" },
]
@ -213,6 +360,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aioquic", marker = "extra == 'quic'", specifier = ">=1.2" },
{ name = "dnspython", specifier = ">=2.6" },
{ name = "httpx", extras = ["http2"], specifier = ">=0.28" },
{ name = "icmplib", specifier = ">=3.0" },
@ -225,7 +373,7 @@ requires-dist = [
{ name = "typer", specifier = ">=0.12" },
{ name = "websockets", marker = "extra == 'ndt7'", specifier = ">=12" },
]
provides-extras = ["tcptrace", "ndt7"]
provides-extras = ["tcptrace", "ndt7", "quic"]
[package.metadata.requires-dev]
dev = [
@ -274,6 +422,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
@ -353,6 +510,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pylsqpack"
version = "0.3.24"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7c/a0/20b34e654b911a9abb736b242cc0a11912bc79ea3e911f139ea756e39ea2/pylsqpack-0.3.24.tar.gz", hash = "sha256:8ec455f44614228f89e38d40c1b1e37895620e20ec6b21e3b562fa8b79a23890", size = 677187, upload-time = "2026-03-29T15:42:40.136Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/88/71b79d334f67dd595fbed5f3a337e2aa997a96e452bb1b64120bccf5679d/pylsqpack-0.3.24-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:8edf48d0a023cd3629b2c4aaccac9b79a46d566c0f61e7416b5678228433763d", size = 162525, upload-time = "2026-03-29T15:42:25.436Z" },
{ url = "https://files.pythonhosted.org/packages/4e/96/f0a7625075394e93db42bd476abb7240ff1a474acd1ad404158baf68dc6a/pylsqpack-0.3.24-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e7d956dbc8f7d597b237b9157d0a16bc7c655a1b031239763c18dc8582aff8cc", size = 168643, upload-time = "2026-03-29T15:42:26.744Z" },
{ url = "https://files.pythonhosted.org/packages/42/de/49ec59856ea41468ed879ec143fc429729e37e4860b2119959a2a66fb652/pylsqpack-0.3.24-cp310-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b6a8bb42127d5ece8d301a673c8205df25b73b69f8c46b9f0c3034588de1789a", size = 246930, upload-time = "2026-03-29T15:42:28.136Z" },
{ url = "https://files.pythonhosted.org/packages/cd/d3/3e748fa5317782bfe68a7eaf890524aee48281c59f07e9bdfd7774f158db/pylsqpack-0.3.24-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3f977d419c60c1d6c2240e6d7a52df820d37eb8c36b4057113bcd7859f53e2c", size = 249234, upload-time = "2026-03-29T15:42:29.583Z" },
{ url = "https://files.pythonhosted.org/packages/22/5b/06f5e354ed882ce036ed65f2a393c98d0f6c71a23fa64b53251ddeb40a7b/pylsqpack-0.3.24-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6024854eb16d32803d4890fb90a73b9348c74b61c0770680aefaaa75f8456e8c", size = 250274, upload-time = "2026-03-29T15:42:31.03Z" },
{ url = "https://files.pythonhosted.org/packages/61/0e/c95cae2817a5c272b7a3132376165aa16875efcccbbd3e6608f5082770cc/pylsqpack-0.3.24-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:54978a9879471596d84bbad5e67d727014048926bc5bb2dac0eb3701b48c5ac9", size = 246966, upload-time = "2026-03-29T15:42:32.035Z" },
{ url = "https://files.pythonhosted.org/packages/40/fe/d5e84c3b4b2fa716df9e95aeb40d3bfb4de50c21cccccd66e194cfc084ac/pylsqpack-0.3.24-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:caf63ddc2e581c764d17432893acce02c5c29ff879d77c2abf1e26aa4eeb831b", size = 246546, upload-time = "2026-03-29T15:42:33.105Z" },
{ url = "https://files.pythonhosted.org/packages/65/f5/88e442ced83c0305f50f45bf521bbce3344ef0c29c3442f010086ff0c124/pylsqpack-0.3.24-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc5f146fd456b50b227858aed59faa0ff8445aa426e69bb4e50d46c487aab0", size = 248517, upload-time = "2026-03-29T15:42:34.237Z" },
{ url = "https://files.pythonhosted.org/packages/a6/c2/886348974bba20db2a80cf37e97203d7334223b3c1c1babe4159dd12626d/pylsqpack-0.3.24-cp310-abi3-win32.whl", hash = "sha256:8da12be7b35b7c9a8cf73a4c077f72e5022a311f80a401c79904213376f2d767", size = 153483, upload-time = "2026-03-29T15:42:35.214Z" },
{ url = "https://files.pythonhosted.org/packages/0d/22/adbce7adfb41b8f5f222195f7f4f5e58655aa3e83f525bc5f3882b07d6e8/pylsqpack-0.3.24-cp310-abi3-win_amd64.whl", hash = "sha256:c3e2327af25ee616ce4483a8748f0957cf017cbca82d58ed15efea68f70f94ff", size = 156145, upload-time = "2026-03-29T15:42:36.902Z" },
{ url = "https://files.pythonhosted.org/packages/a5/2e/6fb6d797ce88741a0e18984bbab69160abc0971a41f4478cab6c8255a8dc/pylsqpack-0.3.24-cp310-abi3-win_arm64.whl", hash = "sha256:23b4d8af48836893beac356c10ca268161953de5bf9ed691526a93f5c82433e9", size = 153424, upload-time = "2026-03-29T15:42:38.73Z" },
]
[[package]]
name = "pyopenssl"
version = "26.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl", hash = "sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", size = 56026, upload-time = "2026-08-01T19:50:48.94Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
@ -451,6 +639,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/6f/bd32e5e8adc391063858da17271bb444e4b009842cffa593bca8b2b78af7/scapy-2.7.0-py3-none-any.whl", hash = "sha256:eb22786da92be6fd8e5c694ae5595e4f5b9ac1f4364c9c45986844f3e3063561", size = 2590982, upload-time = "2025-12-26T22:07:48.941Z" },
]
[[package]]
name = "service-identity"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/87/ad52e2c582c0f0e7f0a1b86950494c38d67422dc0f5ed9044a5fb9569a49/service_identity-26.1.0.tar.gz", hash = "sha256:6358c52882c96e66ac4a55eb3a72c7dd4a70763f8cc6fa4e70abde2656f4bf3b", size = 42898, upload-time = "2026-05-30T12:04:55.184Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/eb/2433e1af4ff903499144de4846569fb3300b816179ae99a03c2f011b666a/service_identity-26.1.0-py3-none-any.whl", hash = "sha256:68c32dadbb69135fb951077677e07cd7f6031020f3a8c8f47a28cda8a0742118", size = 11370, upload-time = "2026-05-30T12:04:53.911Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"