mirror of
https://github.com/Horizontal42/netsleuth.git
synced 2026-11-10 20:22:03 +03:00
sort reports into logs/YYYY/MM/DD, readable filenames
Filenames only carry a time-of-day now (report_<asn>_HH-MM-SSZ) since the date lives in the directory path; history lookups walk the tree with rglob and sort by full path instead of basename, since the basename alone no longer encodes recency across days.
This commit is contained in:
parent
66dcd6f5bc
commit
053eb2f530
11 changed files with 111 additions and 74 deletions
|
|
@ -25,7 +25,7 @@ src/netsleuth/
|
||||||
artifacts get written is decided by the caller (cli.py), not here.
|
artifacts get written is decided by the caller (cli.py), not here.
|
||||||
alerting.py Pure webhook-transition contract (should_fire debounce) plus a plain httpx POST for --watch alerts.
|
alerting.py Pure webhook-transition contract (should_fire debounce) plus a plain httpx POST for --watch alerts.
|
||||||
compare.py --compare: diff two saved JSON reports; render_diff_brief() feeds the automatic vs-previous-run summary.
|
compare.py --compare: diff two saved JSON reports; render_diff_brief() feeds the automatic vs-previous-run summary.
|
||||||
history.py Same-ASN report lookup by filename convention: report_key(), find_previous(), latest_key().
|
history.py Same-ASN report lookup by walking the logs/YYYY/MM/DD tree: report_key(), find_previous(), latest_key().
|
||||||
trend.py --trend N: aggregates N saved JSON reports into per-label sparkline series.
|
trend.py --trend N: aggregates N saved JSON reports into per-label sparkline series.
|
||||||
metrics.py collect_metrics() extracts a flat Metric list from a report; render_prometheus()/render_csv() serialize it.
|
metrics.py collect_metrics() extracts a flat Metric list from a report; render_prometheus()/render_csv() serialize it.
|
||||||
watch.py --watch: periodic re-run loop with a live Rich dashboard.
|
watch.py --watch: periodic re-run loop with a live Rich dashboard.
|
||||||
|
|
@ -78,7 +78,7 @@ src/netsleuth/
|
||||||
|
|
|
|
||||||
+-- interpret ---> Finding[] + overall verdict
|
+-- interpret ---> Finding[] + overall verdict
|
||||||
|
|
|
|
||||||
+-- exporter ---> logs/report_<ASN>_<ts>.{md,ru.md,json}
|
+-- exporter ---> logs/YYYY/MM/DD/report_<ASN>_<ts>.{md,ru.md,json}
|
||||||
```
|
```
|
||||||
|
|
||||||
Three hard concurrency constraints:
|
Three hard concurrency constraints:
|
||||||
|
|
@ -154,7 +154,7 @@ The AS prefix benchmark (`probes/prefix_benchmark.py`) gets the same treatment f
|
||||||
|
|
||||||
**Prometheus and CSV are one extraction, two serializations — not two renderers.** `metrics.py::collect_metrics(report) -> list[Metric]` does the per-section special-casing a flat walk of the report dict can't (latency needs a label/host/method triple, TLS a label/host pair, path hops a target/ttl/ip/asn quadruple) as a single pure `dict -> list[Metric]` function; `render_prometheus()` and `render_csv()` are then ~15 lines each, formatting the same list two ways. `FORMAT_EXTENSIONS` (`config.py`) is still the single source of truth both `cli.py::parse_formats()` and `config.py::Output._normalize_formats()` key off of — adding `"prom"`/`"csv"` there was the only wiring point.
|
**Prometheus and CSV are one extraction, two serializations — not two renderers.** `metrics.py::collect_metrics(report) -> list[Metric]` does the per-section special-casing a flat walk of the report dict can't (latency needs a label/host/method triple, TLS a label/host pair, path hops a target/ttl/ip/asn quadruple) as a single pure `dict -> list[Metric]` function; `render_prometheus()` and `render_csv()` are then ~15 lines each, formatting the same list two ways. `FORMAT_EXTENSIONS` (`config.py`) is still the single source of truth both `cli.py::parse_formats()` and `config.py::Output._normalize_formats()` key off of — adding `"prom"`/`"csv"` there was the only wiring point.
|
||||||
|
|
||||||
**Same-ASN auto-diff runs before `write_report()`, or the current run would diff against itself.** `history.find_previous()` globs `./logs/` for `report_{key}_*.json` using the exact same `sanitize_name()`/timestamp convention `exporter.report_filename()` already writes, so the lookup key can never drift from what's actually on disk. It only fires when this run is itself writing JSON (there'd be nothing to diff against on a future run otherwise) and only prints a one-line note — never a warning — when JSON isn't in this run's formats, since silence is correct on every other skip path (first run ever, or a prior run that didn't write JSON). `compare.py::render_diff_brief()` exists because the full `render_diff()` table is too long to print unbidden after every run; it surfaces only the ASN/IP change, the single worst latency delta, the download delta, and new/resolved finding counts.
|
**Same-ASN auto-diff runs before `write_report()`, or the current run would diff against itself.** `history.find_previous()` walks `./logs/` (`Path.rglob("report_*.json")`, since reports now live under a `YYYY/MM/DD` subtree) for `report_{key}_*.json` using the exact same `sanitize_name()`/timestamp convention `exporter.report_filename()` already writes, so the lookup key can never drift from what's actually on disk. Because the filename itself only carries a time-of-day now, recency is sorted on the full path (`p.as_posix()`), not the basename — the zero-padded `YYYY/MM/DD` prefix keeps that sort chronological. It only fires when this run is itself writing JSON (there'd be nothing to diff against on a future run otherwise) and only prints a one-line note — never a warning — when JSON isn't in this run's formats, since silence is correct on every other skip path (first run ever, or a prior run that didn't write JSON). `compare.py::render_diff_brief()` exists because the full `render_diff()` table is too long to print unbidden after every run; it surfaces only the ASN/IP change, the single worst latency delta, the download delta, and new/resolved finding counts.
|
||||||
|
|
||||||
**`--trend` needs no live network call to find "this network's" history.** Rather than re-running identity detection just to know which ASN's reports to load, `history.latest_key()` reads it back off the newest `report_*.json` filename already on disk — the same file-naming convention already encodes it, and a read-only historical view has no reason to touch the network at all.
|
**`--trend` needs no live network call to find "this network's" history.** Rather than re-running identity detection just to know which ASN's reports to load, `history.latest_key()` reads it back off the newest `report_*.json` filename already on disk — the same file-naming convention already encodes it, and a read-only historical view has no reason to touch the network at all.
|
||||||
|
|
||||||
|
|
@ -190,8 +190,8 @@ The AS prefix benchmark (`probes/prefix_benchmark.py`) gets the same treatment f
|
||||||
|
|
||||||
## Storage
|
## Storage
|
||||||
|
|
||||||
- `./logs/report_<ASN>_<YYYYMMDDTHHMMSSZ>.{md,ru.md,json,prom,csv}` — the artifacts selected by `--format`/`output.formats` (default: English Markdown only; `all` writes all three: English Markdown, a full Russian translation with identical structure, and the JSON dump — English only, no `.ru.json`). When both Markdown languages are written they cross-link each other at the top, the same way `README.md`/`README.ru.md` do; writing only one skips the cross-link rather than pointing at a file that doesn't exist. `<ASN>` is the target's subject in target mode (an ASN, IP or domain) and the local egress ASN in auto mode. Compact ISO timestamps because Windows forbids `:` in filenames. Falls back to `report_unknown_…` when the ASN lookup fails entirely. Written temp-file-plus-`os.replace()`, always atomic. Gitignored: reports contain the external IP, city, coordinates and ISP name.
|
- `./logs/<YYYY>/<MM>/<DD>/report_<ASN>_<HH-MM-SSZ>.{md,ru.md,json,prom,csv}` — the artifacts selected by `--format`/`output.formats` (default: English Markdown only; `all` writes all three: English Markdown, a full Russian translation with identical structure, and the JSON dump — English only, no `.ru.json`). When both Markdown languages are written they cross-link each other at the top, the same way `README.md`/`README.ru.md` do; writing only one skips the cross-link rather than pointing at a file that doesn't exist. `<ASN>` is the target's subject in target mode (an ASN, IP or domain) and the local egress ASN in auto mode. The run's start date becomes the directory (`exporter.report_date_dir()`), so browsing `logs/` means walking year, then month, then day; the filename itself only needs a readable time-of-day (`exporter.readable_timestamp()`, e.g. `19-12-00Z`) since the date is already the path — no `:` because Windows forbids it in filenames, dashes instead. Falls back to a flat `report_unknown_unknown.…` at the `logs/` root when `started_at` doesn't parse. Written temp-file-plus-`os.replace()`, always atomic — `atomic_write()` creates the whole `YYYY/MM/DD` chain via `mkdir(parents=True)`. Gitignored: reports contain the external IP, city, coordinates and ISP name.
|
||||||
- `./logs/watch_<ASN>_<YYYYMMDDTHHMMSSZ>.json` — one time-series artifact per `--watch` session, not one report per tick.
|
- `./logs/<YYYY>/<MM>/<DD>/watch_<ASN>_<HH-MM-SSZ>.json` — one time-series artifact per `--watch` session, not one report per tick, dated by the session's `started_at` the same way one-shot reports are.
|
||||||
- `./.cache/firehol/*.netset` — downloaded blocklists, refreshed per `providers.firehol_refresh_hours`.
|
- `./.cache/firehol/*.netset` — downloaded blocklists, refreshed per `providers.firehol_refresh_hours`.
|
||||||
- `./.cache/pdb-net-<asn>.json`, `./.cache/pdb-netixlan-<net_id>.json` — PeeringDB responses, valid for `providers.peeringdb_cache_hours`.
|
- `./.cache/pdb-net-<asn>.json`, `./.cache/pdb-netixlan-<net_id>.json` — PeeringDB responses, valid for `providers.peeringdb_cache_hours`.
|
||||||
- `./config.yaml` — all non-secret settings. `./.env` — the three optional API keys and nothing else.
|
- `./config.yaml` — all non-secret settings. `./.env` — the three optional API keys and nothing else.
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ src/netsleuth/
|
||||||
артефакты писать, решает вызывающий код (cli.py), не этот модуль.
|
артефакты писать, решает вызывающий код (cli.py), не этот модуль.
|
||||||
alerting.py Чистый контракт перехода вебхука (дебаунс should_fire) плюс обычный httpx POST для алертов --watch.
|
alerting.py Чистый контракт перехода вебхука (дебаунс should_fire) плюс обычный httpx POST для алертов --watch.
|
||||||
compare.py --compare: сравнение двух сохранённых JSON-отчётов; render_diff_brief() питает автоматическую сводку vs-прошлый-прогон.
|
compare.py --compare: сравнение двух сохранённых JSON-отчётов; render_diff_brief() питает автоматическую сводку vs-прошлый-прогон.
|
||||||
history.py Поиск отчётов той же ASN по соглашению об именах файлов: report_key(), find_previous(), latest_key().
|
history.py Поиск отчётов той же ASN обходом дерева logs/YYYY/MM/DD: report_key(), find_previous(), latest_key().
|
||||||
trend.py --trend N: агрегирует N сохранённых JSON-отчётов в спарклайн-серии по меткам.
|
trend.py --trend N: агрегирует N сохранённых JSON-отчётов в спарклайн-серии по меткам.
|
||||||
metrics.py collect_metrics() извлекает плоский список Metric из отчёта; render_prometheus()/render_csv() его сериализуют.
|
metrics.py collect_metrics() извлекает плоский список Metric из отчёта; render_prometheus()/render_csv() его сериализуют.
|
||||||
watch.py --watch: цикл периодических прогонов с живой панелью Rich.
|
watch.py --watch: цикл периодических прогонов с живой панелью Rich.
|
||||||
|
|
@ -78,7 +78,7 @@ src/netsleuth/
|
||||||
|
|
|
|
||||||
+-- interpret ---> Finding[] + общий вердикт
|
+-- interpret ---> Finding[] + общий вердикт
|
||||||
|
|
|
|
||||||
+-- exporter ---> logs/report_<ASN>_<ts>.{md,ru.md,json}
|
+-- exporter ---> logs/YYYY/MM/DD/report_<ASN>_<ts>.{md,ru.md,json}
|
||||||
```
|
```
|
||||||
|
|
||||||
Три жёстких ограничения по конкурентности:
|
Три жёстких ограничения по конкурентности:
|
||||||
|
|
@ -154,7 +154,7 @@ src/netsleuth/
|
||||||
|
|
||||||
**Prometheus и CSV — одна экстракция, две сериализации, а не два рендерера.** `metrics.py::collect_metrics(report) -> list[Metric]` делает построчный особый разбор каждой секции, который плоский обход словаря отчёта не осилит (latency нужна тройка label/host/method, TLS — пара label/host, хопы пути — четвёрка target/ttl/ip/asn), как единая чистая функция `dict -> list[Metric]`; `render_prometheus()` и `render_csv()` — тогда ~15 строк каждая, форматирующие один и тот же список двумя способами. `FORMAT_EXTENSIONS` (`config.py`) остаётся единственным источником истины, от которого зависят и `cli.py::parse_formats()`, и `config.py::Output._normalize_formats()` — добавление `"prom"`/`"csv"` туда было единственной точкой подключения.
|
**Prometheus и CSV — одна экстракция, две сериализации, а не два рендерера.** `metrics.py::collect_metrics(report) -> list[Metric]` делает построчный особый разбор каждой секции, который плоский обход словаря отчёта не осилит (latency нужна тройка label/host/method, TLS — пара label/host, хопы пути — четвёрка target/ttl/ip/asn), как единая чистая функция `dict -> list[Metric]`; `render_prometheus()` и `render_csv()` — тогда ~15 строк каждая, форматирующие один и тот же список двумя способами. `FORMAT_EXTENSIONS` (`config.py`) остаётся единственным источником истины, от которого зависят и `cli.py::parse_formats()`, и `config.py::Output._normalize_formats()` — добавление `"prom"`/`"csv"` туда было единственной точкой подключения.
|
||||||
|
|
||||||
**Авто-diff по той же ASN работает до `write_report()`, иначе текущий прогон сравнивался бы сам с собой.** `history.find_previous()` ищет по `./logs/` файлы `report_{key}_*.json`, используя то же соглашение `sanitize_name()`/таймстампа, что уже пишет `exporter.report_filename()` — поэтому ключ поиска не может разойтись с тем, что реально на диске. Срабатывает только когда сам этот прогон пишет JSON (иначе не с чем сравнивать в будущем прогоне) и печатает одну строку-заметку, никогда предупреждение, только когда JSON не входит в форматы этого прогона — молчание корректно на всех остальных путях пропуска (самый первый прогон, либо прошлый прогон не писал JSON). `compare.py::render_diff_brief()` существует потому, что полная таблица `render_diff()` слишком длинная, чтобы печатать её незапрошенной после каждого прогона; она показывает только смену ASN/IP, худшую дельту задержки, дельту скачивания и число новых/устранённых находок.
|
**Авто-diff по той же ASN работает до `write_report()`, иначе текущий прогон сравнивался бы сам с собой.** `history.find_previous()` обходит `./logs/` (`Path.rglob("report_*.json")`, поскольку отчёты теперь лежат в поддереве `YYYY/MM/DD`) в поисках `report_{key}_*.json`, используя то же соглашение `sanitize_name()`/таймстампа, что уже пишет `exporter.report_filename()` — поэтому ключ поиска не может разойтись с тем, что реально на диске. Поскольку само имя файла теперь несёт только время суток, свежесть сортируется по полному пути (`p.as_posix()`), а не по имени файла — сортировку хронологичной делает именно дополненный нулями префикс `YYYY/MM/DD`. Срабатывает только когда сам этот прогон пишет JSON (иначе не с чем сравнивать в будущем прогоне) и печатает одну строку-заметку, никогда предупреждение, только когда JSON не входит в форматы этого прогона — молчание корректно на всех остальных путях пропуска (самый первый прогон, либо прошлый прогон не писал JSON). `compare.py::render_diff_brief()` существует потому, что полная таблица `render_diff()` слишком длинная, чтобы печатать её незапрошенной после каждого прогона; она показывает только смену ASN/IP, худшую дельту задержки, дельту скачивания и число новых/устранённых находок.
|
||||||
|
|
||||||
**`--trend` не требует живого сетевого запроса, чтобы понять «историю этой сети».** Вместо повторного запуска определения identity только чтобы узнать, чьи отчёты грузить, `history.latest_key()` считывает это обратно из имени самого нового `report_*.json` на диске — то же соглашение об именовании файлов уже это кодирует, а read-only исторический просмотр не имеет причин вообще трогать сеть.
|
**`--trend` не требует живого сетевого запроса, чтобы понять «историю этой сети».** Вместо повторного запуска определения identity только чтобы узнать, чьи отчёты грузить, `history.latest_key()` считывает это обратно из имени самого нового `report_*.json` на диске — то же соглашение об именовании файлов уже это кодирует, а read-only исторический просмотр не имеет причин вообще трогать сеть.
|
||||||
|
|
||||||
|
|
@ -190,8 +190,8 @@ src/netsleuth/
|
||||||
|
|
||||||
## Хранение
|
## Хранение
|
||||||
|
|
||||||
- `./logs/report_<ASN>_<YYYYMMDDTHHMMSSZ>.{md,ru.md,json,prom,csv}` — артефакты, выбранные `--format`/`output.formats` (по умолчанию: только английский Markdown; `all` пишет все три: английский Markdown, его полный перевод на русский с идентичной структурой и JSON-дамп — только английский, `.ru.json` нет). Когда пишутся оба языка Markdown, они ссылаются друг на друга вверху, так же как `README.md`/`README.ru.md`; если пишется только один — перекрёстная ссылка не ставится, чтобы не указывать на несуществующий файл. `<ASN>` — это цель в режиме target (ASN, IP или домен) и локальный ASN исходящего адреса в авто-режиме. Компактный ISO-таймстамп, потому что Windows запрещает `:` в именах файлов. При полном провале определения ASN — `report_unknown_…`. Запись через временный файл и `os.replace()`, всегда атомарно. В gitignore: отчёты содержат внешний IP, город, координаты и название провайдера.
|
- `./logs/<YYYY>/<MM>/<DD>/report_<ASN>_<HH-MM-SSZ>.{md,ru.md,json,prom,csv}` — артефакты, выбранные `--format`/`output.formats` (по умолчанию: только английский Markdown; `all` пишет все три: английский Markdown, его полный перевод на русский с идентичной структурой и JSON-дамп — только английский, `.ru.json` нет). Когда пишутся оба языка Markdown, они ссылаются друг на друга вверху, так же как `README.md`/`README.ru.md`; если пишется только один — перекрёстная ссылка не ставится, чтобы не указывать на несуществующий файл. `<ASN>` — это цель в режиме target (ASN, IP или домен) и локальный ASN исходящего адреса в авто-режиме. Дата старта прогона становится директорией (`exporter.report_date_dir()`), так что просмотр `logs/` — это спуск по году, затем месяцу, затем дню; самому имени файла достаточно читаемого времени суток (`exporter.readable_timestamp()`, например `19-12-00Z`), поскольку дата уже есть в пути — без `:`, потому что Windows запрещает его в именах файлов, вместо него дефисы. При непарсящемся `started_at` — плоский `report_unknown_unknown.…` прямо в корне `logs/`. Запись через временный файл и `os.replace()`, всегда атомарно — `atomic_write()` создаёт всю цепочку `YYYY/MM/DD` через `mkdir(parents=True)`. В gitignore: отчёты содержат внешний IP, город, координаты и название провайдера.
|
||||||
- `./logs/watch_<ASN>_<YYYYMMDDTHHMMSSZ>.json` — один тайм-ряд на сессию `--watch`, а не отдельный отчёт на каждый тик.
|
- `./logs/<YYYY>/<MM>/<DD>/watch_<ASN>_<HH-MM-SSZ>.json` — один тайм-ряд на сессию `--watch`, а не отдельный отчёт на каждый тик, датирован по `started_at` сессии так же, как и одноразовые отчёты.
|
||||||
- `./.cache/firehol/*.netset` — скачанные блоклисты, обновляются по `providers.firehol_refresh_hours`.
|
- `./.cache/firehol/*.netset` — скачанные блоклисты, обновляются по `providers.firehol_refresh_hours`.
|
||||||
- `./.cache/pdb-net-<asn>.json`, `./.cache/pdb-netixlan-<net_id>.json` — ответы PeeringDB, действительны `providers.peeringdb_cache_hours`.
|
- `./.cache/pdb-net-<asn>.json`, `./.cache/pdb-netixlan-<net_id>.json` — ответы PeeringDB, действительны `providers.peeringdb_cache_hours`.
|
||||||
- `./config.yaml` — все несекретные настройки. `./.env` — три опциональных API-ключа и ничего больше.
|
- `./config.yaml` — все несекретные настройки. `./.env` — три опциональных API-ключа и ничего больше.
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ Cross-platform CLI for deep network diagnostics: who your ISP actually is, wheth
|
||||||
$ netsleuth --quick
|
$ netsleuth --quick
|
||||||
netsleuth 0.1.0 · auto mode · Windows
|
netsleuth 0.1.0 · auto mode · Windows
|
||||||
Verdict: ok (100/100) — No problems found on this connection.
|
Verdict: ok (100/100) — No problems found on this connection.
|
||||||
Report written to logs/report_AS64500_20260808T191200Z.md
|
Report written to logs/2026/08/08/report_AS64500_19-12-00Z.md
|
||||||
```
|
```
|
||||||
|
|
||||||
Everything else — egress ASN, VPN verdict, latency, path, speed — lives in the report it just wrote, not on the console; the terminal only tells you the headline and where to look. By default that's a single English Markdown file; `--format` controls exactly which artifacts get written (see Handy things below).
|
Everything else — egress ASN, VPN verdict, latency, path, speed — lives in the report it just wrote, not on the console; the terminal only tells you the headline and where to look. By default that's a single English Markdown file; `--format` controls exactly which artifacts get written (see Handy things below).
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
$ netsleuth --quick
|
$ netsleuth --quick
|
||||||
netsleuth 0.1.0 · auto mode · Windows
|
netsleuth 0.1.0 · auto mode · Windows
|
||||||
Verdict: ok (100/100) — No problems found on this connection.
|
Verdict: ok (100/100) — No problems found on this connection.
|
||||||
Report written to logs/report_AS64500_20260808T191200Z.md
|
Report written to logs/2026/08/08/report_AS64500_19-12-00Z.md
|
||||||
```
|
```
|
||||||
|
|
||||||
Всё остальное — ASN, вердикт по VPN, задержки, маршрут, скорость — попадает в только что записанный отчёт, а не в консоль; терминал показывает только заголовок и куда смотреть дальше. По умолчанию это один Markdown-файл на английском; `--format` управляет тем, что именно пишется (см. «Полезное» ниже).
|
Всё остальное — ASN, вердикт по VPN, задержки, маршрут, скорость — попадает в только что записанный отчёт, а не в консоль; терминал показывает только заголовок и куда смотреть дальше. По умолчанию это один Markdown-файл на английском; `--format` управляет тем, что именно пишется (см. «Полезное» ниже).
|
||||||
|
|
|
||||||
|
|
@ -844,7 +844,7 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
|
||||||
if previous:
|
if previous:
|
||||||
previous_report = load_report(previous[0])
|
previous_report = load_report(previous[0])
|
||||||
console.print(render_diff_brief(diff_reports(previous_report, report), emoji=settings.output.emoji))
|
console.print(render_diff_brief(diff_reports(previous_report, report), emoji=settings.output.emoji))
|
||||||
report["meta"]["compared_to"] = previous[0].name
|
report["meta"]["compared_to"] = previous[0].relative_to(settings.output.logs_dir).as_posix()
|
||||||
except (OSError, ValueError, KeyError):
|
except (OSError, ValueError, KeyError):
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ SECTION_ORDER = (
|
||||||
)
|
)
|
||||||
|
|
||||||
_UNSAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
_UNSAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||||
|
_ISO_RE = re.compile(r"^(?P<y>\d{4})-(?P<mo>\d{2})-(?P<d>\d{2})T(?P<h>\d{2}):(?P<mi>\d{2}):(?P<s>\d{2})")
|
||||||
|
|
||||||
|
|
||||||
def sanitize_name(value: str | None, fallback: str = "unknown") -> str:
|
def sanitize_name(value: str | None, fallback: str = "unknown") -> str:
|
||||||
|
|
@ -39,13 +40,24 @@ def sanitize_name(value: str | None, fallback: str = "unknown") -> str:
|
||||||
return cleaned[:32] or fallback
|
return cleaned[:32] or fallback
|
||||||
|
|
||||||
|
|
||||||
def compact_timestamp(iso: str) -> str:
|
def _iso_parts(iso: str) -> dict[str, str] | None:
|
||||||
# Windows forbids ':' in filenames, so the stamp is the compact ISO form.
|
match = _ISO_RE.match((iso or "").strip())
|
||||||
return re.sub(r"[-:]", "", (iso or "").strip()) or "unknown"
|
return match.groupdict() if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def readable_timestamp(iso: str) -> str:
|
||||||
|
# Windows forbids ':' in filenames; the date lives in the directory path instead.
|
||||||
|
parts = _iso_parts(iso)
|
||||||
|
return f"{parts['h']}-{parts['mi']}-{parts['s']}Z" if parts else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def report_date_dir(iso: str) -> Path:
|
||||||
|
parts = _iso_parts(iso)
|
||||||
|
return Path(parts["y"], parts["mo"], parts["d"]) if parts else Path()
|
||||||
|
|
||||||
|
|
||||||
def report_filename(asn: str | None, started_at: str, extension: str) -> str:
|
def report_filename(asn: str | None, started_at: str, extension: str) -> str:
|
||||||
return f"report_{sanitize_name(asn)}_{compact_timestamp(started_at)}.{extension.lstrip('.')}"
|
return f"report_{sanitize_name(asn)}_{readable_timestamp(started_at)}.{extension.lstrip('.')}"
|
||||||
|
|
||||||
|
|
||||||
def flatten_errors(modules: dict[str, ModuleResult]) -> list[dict[str, Any]]:
|
def flatten_errors(modules: dict[str, ModuleResult]) -> list[dict[str, Any]]:
|
||||||
|
|
@ -102,7 +114,7 @@ def write_report(report: dict[str, Any], artifacts: dict[str, str], logs_dir: Pa
|
||||||
meta = report.get("meta") or {}
|
meta = report.get("meta") or {}
|
||||||
started = meta.get("started_at", "")
|
started = meta.get("started_at", "")
|
||||||
asn = meta.get("target") or egress_asn(report)
|
asn = meta.get("target") or egress_asn(report)
|
||||||
base = Path(logs_dir)
|
base = Path(logs_dir) / report_date_dir(started)
|
||||||
return [
|
return [
|
||||||
atomic_write(base / report_filename(asn, started, FORMAT_EXTENSIONS[fmt]), text)
|
atomic_write(base / report_filename(asn, started, FORMAT_EXTENSIONS[fmt]), text)
|
||||||
for fmt, text in artifacts.items()
|
for fmt, text in artifacts.items()
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,19 @@ from pathlib import Path
|
||||||
|
|
||||||
from netsleuth.exporter import egress_asn, sanitize_name
|
from netsleuth.exporter import egress_asn, sanitize_name
|
||||||
|
|
||||||
_REPORT_NAME_RE = re.compile(r"^report_(?P<key>.+)_(?P<ts>\d{8}T\d{6}Z)\.json$")
|
_REPORT_NAME_RE = re.compile(r"^report_(?P<key>.+)_(?P<ts>\d{2}-\d{2}-\d{2}Z)\.json$")
|
||||||
|
|
||||||
|
|
||||||
def report_key(meta: dict, report: dict) -> str | None:
|
def report_key(meta: dict, report: dict) -> str | None:
|
||||||
return meta.get("target") or egress_asn(report)
|
return meta.get("target") or egress_asn(report)
|
||||||
|
|
||||||
|
|
||||||
def matching_reports(names: list[str], key: str, *, exclude: str | None = None) -> list[str]:
|
def matching_reports(paths: list[Path], key: str, *, exclude: str | None = None) -> list[Path]:
|
||||||
prefix = f"report_{sanitize_name(key)}_"
|
prefix = f"report_{sanitize_name(key)}_"
|
||||||
|
# Filenames only carry a time-of-day now (the date lives in the YYYY/MM/DD
|
||||||
def _timestamp(name: str) -> str:
|
# directory), so recency must be sorted on the full path, not the basename.
|
||||||
match = _REPORT_NAME_RE.match(name)
|
candidates = [p for p in paths if p.name.startswith(prefix) and p.name.endswith(".json") and p.name != exclude]
|
||||||
return match.group("ts") if match else ""
|
return sorted(candidates, key=lambda p: p.as_posix(), reverse=True)
|
||||||
|
|
||||||
candidates = [n for n in names if n.startswith(prefix) and n.endswith(".json") and n != exclude]
|
|
||||||
return sorted(candidates, key=_timestamp, reverse=True)
|
|
||||||
|
|
||||||
|
|
||||||
def find_previous(
|
def find_previous(
|
||||||
|
|
@ -29,17 +26,15 @@ def find_previous(
|
||||||
logs_dir = Path(logs_dir)
|
logs_dir = Path(logs_dir)
|
||||||
if not logs_dir.is_dir():
|
if not logs_dir.is_dir():
|
||||||
return []
|
return []
|
||||||
names = [p.name for p in logs_dir.glob("report_*.json")]
|
ordered = matching_reports(list(logs_dir.rglob("report_*.json")), key, exclude=exclude)
|
||||||
ordered = matching_reports(names, key, exclude=exclude)
|
return ordered[:limit] if limit else ordered
|
||||||
paths = [logs_dir / name for name in ordered]
|
|
||||||
return paths[:limit] if limit else paths
|
|
||||||
|
|
||||||
|
|
||||||
def latest_key(logs_dir: Path | str) -> str | None:
|
def latest_key(logs_dir: Path | str) -> str | None:
|
||||||
logs_dir = Path(logs_dir)
|
logs_dir = Path(logs_dir)
|
||||||
if not logs_dir.is_dir():
|
if not logs_dir.is_dir():
|
||||||
return None
|
return None
|
||||||
for path in sorted(logs_dir.glob("report_*.json"), key=lambda p: p.name, reverse=True):
|
for path in sorted(logs_dir.rglob("report_*.json"), key=lambda p: p.as_posix(), reverse=True):
|
||||||
match = _REPORT_NAME_RE.match(path.name)
|
match = _REPORT_NAME_RE.match(path.name)
|
||||||
if match:
|
if match:
|
||||||
return match.group("key")
|
return match.group("key")
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from rich.table import Table
|
||||||
from netsleuth import __version__
|
from netsleuth import __version__
|
||||||
from netsleuth.alerting import build_payload, post_webhook, should_fire
|
from netsleuth.alerting import build_payload, post_webhook, should_fire
|
||||||
from netsleuth.config import Settings, Thresholds
|
from netsleuth.config import Settings, Thresholds
|
||||||
from netsleuth.exporter import atomic_write, compact_timestamp, dump_json, sanitize_name, sparkline
|
from netsleuth.exporter import atomic_write, dump_json, readable_timestamp, report_date_dir, sanitize_name, sparkline
|
||||||
from netsleuth.interpret import latency_findings, overall_verdict, speed_findings
|
from netsleuth.interpret import latency_findings, overall_verdict, speed_findings
|
||||||
from netsleuth.ip_geo import gather_identity
|
from netsleuth.ip_geo import gather_identity
|
||||||
from netsleuth.models import PingResult, SpeedResult, to_jsonable
|
from netsleuth.models import PingResult, SpeedResult, to_jsonable
|
||||||
|
|
@ -103,7 +103,7 @@ class WatchSession:
|
||||||
return seen
|
return seen
|
||||||
|
|
||||||
def filename(self) -> str:
|
def filename(self) -> str:
|
||||||
return f"watch_{sanitize_name(self.asn)}_{compact_timestamp(self.started_at)}.json"
|
return f"watch_{sanitize_name(self.asn)}_{readable_timestamp(self.started_at)}.json"
|
||||||
|
|
||||||
def to_report(self) -> dict[str, Any]:
|
def to_report(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
|
|
@ -125,7 +125,8 @@ class WatchSession:
|
||||||
|
|
||||||
|
|
||||||
def write_session(session: WatchSession, logs_dir: Path) -> Path:
|
def write_session(session: WatchSession, logs_dir: Path) -> Path:
|
||||||
return atomic_write(Path(logs_dir) / session.filename(), dump_json(session.to_report()))
|
base = Path(logs_dir) / report_date_dir(session.started_at)
|
||||||
|
return atomic_write(base / session.filename(), dump_json(session.to_report()))
|
||||||
|
|
||||||
|
|
||||||
def render_dashboard(session: WatchSession) -> Table:
|
def render_dashboard(session: WatchSession) -> Table:
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,11 @@ from netsleuth.exporter import (
|
||||||
SECTION_ORDER,
|
SECTION_ORDER,
|
||||||
atomic_write,
|
atomic_write,
|
||||||
build_report,
|
build_report,
|
||||||
compact_timestamp,
|
|
||||||
dump_json,
|
dump_json,
|
||||||
egress_asn,
|
egress_asn,
|
||||||
flatten_errors,
|
flatten_errors,
|
||||||
|
readable_timestamp,
|
||||||
|
report_date_dir,
|
||||||
report_filename,
|
report_filename,
|
||||||
sanitize_name,
|
sanitize_name,
|
||||||
write_report,
|
write_report,
|
||||||
|
|
@ -164,9 +165,14 @@ def test_dump_json_is_strict_and_round_trips():
|
||||||
assert math.isfinite(back["latency"]["duration_ms"])
|
assert math.isfinite(back["latency"]["duration_ms"])
|
||||||
|
|
||||||
|
|
||||||
def test_compact_timestamp_strips_the_characters_windows_forbids():
|
def test_readable_timestamp_strips_the_characters_windows_forbids():
|
||||||
assert compact_timestamp("2026-08-08T19:12:00Z") == "20260808T191200Z"
|
assert readable_timestamp("2026-08-08T19:12:00Z") == "19-12-00Z"
|
||||||
assert compact_timestamp("") == "unknown"
|
assert readable_timestamp("") == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_date_dir_splits_the_iso_date_into_year_month_day():
|
||||||
|
assert report_date_dir("2026-08-08T19:12:00Z") == Path("2026", "08", "08")
|
||||||
|
assert report_date_dir("") == Path()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|
@ -188,11 +194,11 @@ def test_sanitize_name_produces_windows_safe_fragments(value, expected):
|
||||||
def test_report_filename_matches_the_documented_pattern():
|
def test_report_filename_matches_the_documented_pattern():
|
||||||
assert (
|
assert (
|
||||||
report_filename("AS64500", "2026-08-08T19:12:00Z", "json")
|
report_filename("AS64500", "2026-08-08T19:12:00Z", "json")
|
||||||
== "report_AS64500_20260808T191200Z.json"
|
== "report_AS64500_19-12-00Z.json"
|
||||||
)
|
)
|
||||||
assert (
|
assert (
|
||||||
report_filename(None, "2026-08-08T19:12:00Z", "md")
|
report_filename(None, "2026-08-08T19:12:00Z", "md")
|
||||||
== "report_unknown_20260808T191200Z.md"
|
== "report_unknown_19-12-00Z.md"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -211,9 +217,10 @@ def test_write_report_names_the_file_after_the_target_in_target_mode(tmp_path: P
|
||||||
tmp_path,
|
tmp_path,
|
||||||
)
|
)
|
||||||
names = {p.name for p in paths}
|
names = {p.name for p in paths}
|
||||||
assert "report_AS15169_20260808T191200Z.json" in names
|
assert "report_AS15169_19-12-00Z.json" in names
|
||||||
assert "report_AS15169_20260808T191200Z.md" in names
|
assert "report_AS15169_19-12-00Z.md" in names
|
||||||
assert "report_AS15169_20260808T191200Z.ru.md" in names
|
assert "report_AS15169_19-12-00Z.ru.md" in names
|
||||||
|
assert {p.parent for p in paths} == {tmp_path / "2026" / "08" / "08"}
|
||||||
|
|
||||||
|
|
||||||
def test_atomic_write_creates_the_directory_and_leaves_no_temp_file(tmp_path: Path):
|
def test_atomic_write_creates_the_directory_and_leaves_no_temp_file(tmp_path: Path):
|
||||||
|
|
@ -239,9 +246,9 @@ def test_write_report_emits_all_requested_artifacts_with_matching_names(tmp_path
|
||||||
tmp_path,
|
tmp_path,
|
||||||
)
|
)
|
||||||
by_ext = {p.name.rsplit(".", 1)[-1] if not p.name.endswith(".ru.md") else "ru.md": p for p in paths}
|
by_ext = {p.name.rsplit(".", 1)[-1] if not p.name.endswith(".ru.md") else "ru.md": p for p in paths}
|
||||||
assert by_ext["json"].name == "report_AS64500_20260808T191200Z.json"
|
assert by_ext["json"].name == "report_AS64500_19-12-00Z.json"
|
||||||
assert by_ext["md"].name == "report_AS64500_20260808T191200Z.md"
|
assert by_ext["md"].name == "report_AS64500_19-12-00Z.md"
|
||||||
assert by_ext["ru.md"].name == "report_AS64500_20260808T191200Z.ru.md"
|
assert by_ext["ru.md"].name == "report_AS64500_19-12-00Z.ru.md"
|
||||||
assert json.loads(by_ext["json"].read_text(encoding="utf-8"))["schema_version"] == SCHEMA_VERSION
|
assert json.loads(by_ext["json"].read_text(encoding="utf-8"))["schema_version"] == SCHEMA_VERSION
|
||||||
assert by_ext["md"].read_text(encoding="utf-8").startswith("# netsleuth report")
|
assert by_ext["md"].read_text(encoding="utf-8").startswith("# netsleuth report")
|
||||||
assert by_ext["ru.md"].read_text(encoding="utf-8").startswith("# netsleuth report")
|
assert by_ext["ru.md"].read_text(encoding="utf-8").startswith("# netsleuth report")
|
||||||
|
|
@ -250,8 +257,8 @@ def test_write_report_emits_all_requested_artifacts_with_matching_names(tmp_path
|
||||||
def test_write_report_writes_only_the_requested_formats(tmp_path: Path):
|
def test_write_report_writes_only_the_requested_formats(tmp_path: Path):
|
||||||
report = build_report(meta(), modules(), [], {})
|
report = build_report(meta(), modules(), [], {})
|
||||||
paths = write_report(report, {"md": "# netsleuth report\n"}, tmp_path)
|
paths = write_report(report, {"md": "# netsleuth report\n"}, tmp_path)
|
||||||
assert [p.name for p in paths] == ["report_AS64500_20260808T191200Z.md"]
|
assert [p.name for p in paths] == ["report_AS64500_19-12-00Z.md"]
|
||||||
assert list(tmp_path.iterdir()) == paths
|
assert list((tmp_path / "2026" / "08" / "08").iterdir()) == paths
|
||||||
|
|
||||||
|
|
||||||
def test_write_report_with_empty_mapping_writes_nothing(tmp_path: Path):
|
def test_write_report_with_empty_mapping_writes_nothing(tmp_path: Path):
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from netsleuth.history import find_previous, latest_key, matching_reports, report_key
|
from netsleuth.history import find_previous, latest_key, matching_reports, report_key
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -12,34 +14,50 @@ def test_report_key_falls_back_to_egress_asn():
|
||||||
assert report_key({}, report) == "AS64500"
|
assert report_key({}, report) == "AS64500"
|
||||||
|
|
||||||
|
|
||||||
def test_matching_reports_orders_newest_first():
|
def test_matching_reports_orders_newest_first_across_days():
|
||||||
names = [
|
paths = [
|
||||||
"report_AS64500_20260101T000000Z.json",
|
Path("2026/01/01/report_AS64500_00-00-00Z.json"),
|
||||||
"report_AS64500_20260103T000000Z.json",
|
Path("2026/01/03/report_AS64500_00-00-00Z.json"),
|
||||||
"report_AS64500_20260102T000000Z.json",
|
Path("2026/01/02/report_AS64500_00-00-00Z.json"),
|
||||||
]
|
]
|
||||||
assert matching_reports(names, "AS64500") == [
|
assert matching_reports(paths, "AS64500") == [
|
||||||
"report_AS64500_20260103T000000Z.json",
|
Path("2026/01/03/report_AS64500_00-00-00Z.json"),
|
||||||
"report_AS64500_20260102T000000Z.json",
|
Path("2026/01/02/report_AS64500_00-00-00Z.json"),
|
||||||
"report_AS64500_20260101T000000Z.json",
|
Path("2026/01/01/report_AS64500_00-00-00Z.json"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_matching_reports_orders_newest_first_within_a_day():
|
||||||
|
paths = [
|
||||||
|
Path("2026/01/01/report_AS64500_09-00-00Z.json"),
|
||||||
|
Path("2026/01/01/report_AS64500_23-00-00Z.json"),
|
||||||
|
Path("2026/01/01/report_AS64500_14-00-00Z.json"),
|
||||||
|
]
|
||||||
|
assert matching_reports(paths, "AS64500") == [
|
||||||
|
Path("2026/01/01/report_AS64500_23-00-00Z.json"),
|
||||||
|
Path("2026/01/01/report_AS64500_14-00-00Z.json"),
|
||||||
|
Path("2026/01/01/report_AS64500_09-00-00Z.json"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_matching_reports_excludes_the_current_file():
|
def test_matching_reports_excludes_the_current_file():
|
||||||
names = ["report_AS64500_20260101T000000Z.json", "report_AS64500_20260102T000000Z.json"]
|
paths = [
|
||||||
assert matching_reports(names, "AS64500", exclude="report_AS64500_20260102T000000Z.json") == [
|
Path("2026/01/01/report_AS64500_00-00-00Z.json"),
|
||||||
"report_AS64500_20260101T000000Z.json"
|
Path("2026/01/02/report_AS64500_09-00-00Z.json"),
|
||||||
|
]
|
||||||
|
assert matching_reports(paths, "AS64500", exclude="report_AS64500_09-00-00Z.json") == [
|
||||||
|
Path("2026/01/01/report_AS64500_00-00-00Z.json")
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_matching_reports_does_not_cross_contaminate_different_keys():
|
def test_matching_reports_does_not_cross_contaminate_different_keys():
|
||||||
names = ["report_AS1_20260101T000000Z.json", "report_AS13335_20260101T000000Z.json"]
|
paths = [Path("2026/01/01/report_AS1_00-00-00Z.json"), Path("2026/01/01/report_AS13335_00-00-00Z.json")]
|
||||||
assert matching_reports(names, "AS1") == ["report_AS1_20260101T000000Z.json"]
|
assert matching_reports(paths, "AS1") == [Path("2026/01/01/report_AS1_00-00-00Z.json")]
|
||||||
|
|
||||||
|
|
||||||
def test_matching_reports_ignores_non_json_files():
|
def test_matching_reports_ignores_non_json_files():
|
||||||
names = ["report_AS64500_20260101T000000Z.md", "report_AS64500_20260101T000000Z.json"]
|
paths = [Path("2026/01/01/report_AS64500_00-00-00Z.md"), Path("2026/01/01/report_AS64500_00-00-00Z.json")]
|
||||||
assert matching_reports(names, "AS64500") == ["report_AS64500_20260101T000000Z.json"]
|
assert matching_reports(paths, "AS64500") == [Path("2026/01/01/report_AS64500_00-00-00Z.json")]
|
||||||
|
|
||||||
|
|
||||||
def test_find_previous_on_an_empty_directory(tmp_path):
|
def test_find_previous_on_an_empty_directory(tmp_path):
|
||||||
|
|
@ -50,17 +68,21 @@ def test_find_previous_on_a_nonexistent_directory(tmp_path):
|
||||||
assert find_previous(tmp_path / "nope", "AS64500") == []
|
assert find_previous(tmp_path / "nope", "AS64500") == []
|
||||||
|
|
||||||
|
|
||||||
def test_find_previous_respects_limit(tmp_path):
|
def test_find_previous_walks_the_year_month_day_tree(tmp_path):
|
||||||
for ts in ("20260101T000000Z", "20260102T000000Z", "20260103T000000Z"):
|
for date_dir, ts in (("2026/01/01", "00-00-00Z"), ("2026/01/02", "00-00-00Z"), ("2026/01/03", "00-00-00Z")):
|
||||||
(tmp_path / f"report_AS64500_{ts}.json").write_text("{}", encoding="utf-8")
|
d = tmp_path / date_dir
|
||||||
|
d.mkdir(parents=True)
|
||||||
|
(d / f"report_AS64500_{ts}.json").write_text("{}", encoding="utf-8")
|
||||||
result = find_previous(tmp_path, "AS64500", limit=2)
|
result = find_previous(tmp_path, "AS64500", limit=2)
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
assert result[0].name.endswith("20260103T000000Z.json")
|
assert result[0].parent.name == "03"
|
||||||
|
|
||||||
|
|
||||||
def test_latest_key_picks_the_newest_report_across_any_key(tmp_path):
|
def test_latest_key_picks_the_newest_report_across_any_key_and_day(tmp_path):
|
||||||
(tmp_path / "report_AS1_20260101T000000Z.json").write_text("{}", encoding="utf-8")
|
(tmp_path / "2026/01/01").mkdir(parents=True)
|
||||||
(tmp_path / "report_AS2_20260102T000000Z.json").write_text("{}", encoding="utf-8")
|
(tmp_path / "2026/01/01/report_AS1_00-00-00Z.json").write_text("{}", encoding="utf-8")
|
||||||
|
(tmp_path / "2026/01/02").mkdir(parents=True)
|
||||||
|
(tmp_path / "2026/01/02/report_AS2_00-00-00Z.json").write_text("{}", encoding="utf-8")
|
||||||
assert latest_key(tmp_path) == "AS2"
|
assert latest_key(tmp_path) == "AS2"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ def test_session_history_returns_the_series_for_one_host_with_gaps_preserved():
|
||||||
|
|
||||||
def test_session_filename_marks_it_as_a_watch_artifact():
|
def test_session_filename_marks_it_as_a_watch_artifact():
|
||||||
session = WatchSession(started_at="2026-08-08T19:12:00Z", asn="AS64500")
|
session = WatchSession(started_at="2026-08-08T19:12:00Z", asn="AS64500")
|
||||||
assert session.filename() == "watch_AS64500_20260808T191200Z.json"
|
assert session.filename() == "watch_AS64500_19-12-00Z.json"
|
||||||
assert WatchSession(started_at="2026-08-08T19:12:00Z").filename().startswith("watch_unknown_")
|
assert WatchSession(started_at="2026-08-08T19:12:00Z").filename().startswith("watch_unknown_")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -124,7 +124,7 @@ def test_a_session_writes_exactly_one_artifact_holding_every_cycle(tmp_path: Pat
|
||||||
for cycle in range(1, 4):
|
for cycle in range(1, 4):
|
||||||
session.add(summarize_cycle(cycle, f"t{cycle}", [ping("cloudflare-dns", 12.0 + cycle)], None))
|
session.add(summarize_cycle(cycle, f"t{cycle}", [ping("cloudflare-dns", 12.0 + cycle)], None))
|
||||||
path = write_session(session, tmp_path)
|
path = write_session(session, tmp_path)
|
||||||
assert list(tmp_path.iterdir()) == [path]
|
assert list((tmp_path / "2026" / "08" / "08").iterdir()) == [path]
|
||||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
assert payload["schema_version"] == WATCH_SCHEMA_VERSION
|
assert payload["schema_version"] == WATCH_SCHEMA_VERSION
|
||||||
assert payload["kind"] == "watch"
|
assert payload["kind"] == "watch"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue