mirror of
https://github.com/Horizontal42/netsleuth.git
synced 2026-11-11 01:02:10 +03:00
add --format/--ru/--json flags to control which report artifacts get written
Every run used to write all three files (en.md, ru.md, json) unconditionally.
Default is now English Markdown only, sourced from output.formats in
config.yaml; --format/--ru/--json pick exactly what a run writes, and any
explicit --format replaces the default rather than adding to it (all/none
supported). write_report() moved from a fixed 3-positional-arg signature to
a {format: text} mapping so writes are conditional instead of always-three.
The en/ru Markdown cross-link header is now only set when both languages are
actually being written.
This commit is contained in:
parent
29b3fb1bb4
commit
4ffc39906b
11 changed files with 303 additions and 55 deletions
|
|
@ -20,7 +20,8 @@ src/netsleuth/
|
||||||
traceparse.py Pure text -> TraceHop parsers for Windows, Linux and BSD/macOS output.
|
traceparse.py Pure text -> TraceHop parsers for Windows, Linux and BSD/macOS output.
|
||||||
interpret.py Pure verdict engine: thresholds -> Finding[], VPN scoring, bufferbloat grade.
|
interpret.py Pure verdict engine: thresholds -> Finding[], VPN scoring, bufferbloat grade.
|
||||||
speed.py Cascading speedtest, throughput math, cfL4 header parsing, bufferbloat probe.
|
speed.py Cascading speedtest, throughput math, cfL4 header parsing, bufferbloat probe.
|
||||||
exporter.py JSON and Markdown rendering, atomic writes into ./logs/.
|
exporter.py JSON and Markdown rendering, atomic writes into ./logs/. Which
|
||||||
|
artifacts get written is decided by the caller (cli.py), not here.
|
||||||
compare.py --compare: diff two saved JSON reports.
|
compare.py --compare: diff two saved JSON reports.
|
||||||
watch.py --watch: periodic re-run loop with a live Rich dashboard.
|
watch.py --watch: periodic re-run loop with a live Rich dashboard.
|
||||||
probes/
|
probes/
|
||||||
|
|
@ -89,7 +90,7 @@ Two hard concurrency constraints:
|
||||||
|
|
||||||
## Storage
|
## Storage
|
||||||
|
|
||||||
- `./logs/report_<ASN>_<YYYYMMDDTHHMMSSZ>.{md,ru.md,json}` — one triple per run: English Markdown, a full Russian translation with identical structure, and the JSON dump (English only — no `.ru.json`). The two Markdown files cross-link each other at the top, the same way `README.md`/`README.ru.md` do. `<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/report_<ASN>_<YYYYMMDDTHHMMSSZ>.{md,ru.md,json}` — 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/watch_<ASN>_<YYYYMMDDTHHMMSSZ>.json` — one time-series artifact per `--watch` session, not one report per tick.
|
- `./logs/watch_<ASN>_<YYYYMMDDTHHMMSSZ>.json` — one time-series artifact per `--watch` session, not one report per tick.
|
||||||
- `./.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`.
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@ src/netsleuth/
|
||||||
traceparse.py Чистые парсеры текста в TraceHop для Windows, Linux и BSD/macOS.
|
traceparse.py Чистые парсеры текста в TraceHop для Windows, Linux и BSD/macOS.
|
||||||
interpret.py Чистый движок вердиктов: пороги -> Finding[], скоринг VPN, оценка bufferbloat.
|
interpret.py Чистый движок вердиктов: пороги -> Finding[], скоринг VPN, оценка bufferbloat.
|
||||||
speed.py Каскадный замер скорости, математика пропускной способности, разбор cfL4, bufferbloat.
|
speed.py Каскадный замер скорости, математика пропускной способности, разбор cfL4, bufferbloat.
|
||||||
exporter.py Рендер JSON и Markdown, атомарная запись в ./logs/.
|
exporter.py Рендер JSON и Markdown, атомарная запись в ./logs/. Какие
|
||||||
|
артефакты писать, решает вызывающий код (cli.py), не этот модуль.
|
||||||
compare.py --compare: сравнение двух сохранённых JSON-отчётов.
|
compare.py --compare: сравнение двух сохранённых JSON-отчётов.
|
||||||
watch.py --watch: цикл периодических прогонов с живой панелью Rich.
|
watch.py --watch: цикл периодических прогонов с живой панелью Rich.
|
||||||
probes/
|
probes/
|
||||||
|
|
@ -89,7 +90,7 @@ src/netsleuth/
|
||||||
|
|
||||||
## Хранение
|
## Хранение
|
||||||
|
|
||||||
- `./logs/report_<ASN>_<YYYYMMDDTHHMMSSZ>.{md,ru.md,json}` — тройка файлов на прогон: английский 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/report_<ASN>_<YYYYMMDDTHHMMSSZ>.{md,ru.md,json}` — артефакты, выбранные `--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/watch_<ASN>_<YYYYMMDDTHHMMSSZ>.json` — один тайм-ряд на сессию `--watch`, а не отдельный отчёт на каждый тик.
|
- `./logs/watch_<ASN>_<YYYYMMDDTHHMMSSZ>.json` — один тайм-ряд на сессию `--watch`, а не отдельный отчёт на каждый тик.
|
||||||
- `./.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`.
|
||||||
|
|
|
||||||
11
README.md
11
README.md
|
|
@ -9,11 +9,9 @@ $ 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/report_AS64500_20260808T191200Z.md
|
||||||
logs/report_AS64500_20260808T191200Z.ru.md
|
|
||||||
logs/report_AS64500_20260808T191200Z.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Everything else — egress ASN, VPN verdict, latency, path, speed — lives in the three files it just wrote, not on the console; the terminal only tells you the headline and where to look.
|
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).
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
|
|
@ -68,12 +66,15 @@ No configuration is required. Copy `.env.example` to `.env` only if you have opt
|
||||||
| `--target-host <ip\|domain>` | Add an extra host to the ping and traceroute fan-out |
|
| `--target-host <ip\|domain>` | Add an extra host to the ping and traceroute fan-out |
|
||||||
| `--speedtest-server <id\|url>` | Pin the speedtest to a specific server |
|
| `--speedtest-server <id\|url>` | Pin the speedtest to a specific server |
|
||||||
| `--watch` | Continuous monitoring with a live dashboard — for catching intermittent drops. Writes one `logs/watch_*.json` time series per session, not one report per tick |
|
| `--watch` | Continuous monitoring with a live dashboard — for catching intermittent drops. Writes one `logs/watch_*.json` time series per session, not one report per tick |
|
||||||
| `--compare a.json b.json` | Diff two earlier reports: before/after a VPN switch, before/after an ISP call. Read-only — no probing, no network calls |
|
| `--compare a.json b.json` | Diff two earlier reports: before/after a VPN switch, before/after an ISP call. Read-only — no probing, no network calls. Reads reports produced with `--format json` |
|
||||||
| `--dnsbl` | Opt in to classic DNSBL reputation zones |
|
| `--dnsbl` | Opt in to classic DNSBL reputation zones |
|
||||||
| `--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 |
|
| `--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 |
|
| `--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 |
|
||||||
|
| `--format md,ru-md,json,all,none` | Choose which report artifacts to write (default: `md`). Repeatable and comma-separated (`--format md,json`); any use replaces the default instead of adding to it. `all` writes all three, `none` writes nothing. Ignored with `--watch`/`--compare` |
|
||||||
|
| `--ru` | Shorthand for `--format ru-md` |
|
||||||
|
| `--json` | Shorthand for `--format json` |
|
||||||
|
|
||||||
Every run writes three files into `./logs/`: an English Markdown report, a full Russian translation of the same report (`.ru.md`, same sections, same tables, same numbers — cross-linked with the English one), and a JSON dump containing every raw provider response, untruncated. `./logs/` is gitignored — reports contain your external IP, city, coordinates and ISP name.
|
Each run writes the selected artifacts into `./logs/`: an English Markdown report, a full Russian translation of the same report (`.ru.md`, same sections, same tables, same numbers — cross-linked with the English one when both are written), and/or a JSON dump containing every raw provider response, untruncated. Default is the English Markdown report only; set `output.formats` in `config.yaml` to change the default permanently, or pass `--format` per run. `./logs/` is gitignored — reports contain your external IP, city, coordinates and ISP name.
|
||||||
|
|
||||||
netsleuth never requires administrator or root. Where privileges would give better data, it degrades to an unprivileged method and says so in the report.
|
netsleuth never requires administrator or root. Where privileges would give better data, it degrades to an unprivileged method and says so in the report.
|
||||||
|
|
||||||
|
|
|
||||||
11
README.ru.md
11
README.ru.md
|
|
@ -9,11 +9,9 @@ $ 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/report_AS64500_20260808T191200Z.md
|
||||||
logs/report_AS64500_20260808T191200Z.ru.md
|
|
||||||
logs/report_AS64500_20260808T191200Z.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Всё остальное — ASN, вердикт по VPN, задержки, маршрут, скорость — попадает в три только что записанных файла, а не в консоль; терминал показывает только заголовок и куда смотреть дальше.
|
Всё остальное — ASN, вердикт по VPN, задержки, маршрут, скорость — попадает в только что записанный отчёт, а не в консоль; терминал показывает только заголовок и куда смотреть дальше. По умолчанию это один Markdown-файл на английском; `--format` управляет тем, что именно пишется (см. «Полезное» ниже).
|
||||||
|
|
||||||
## Установка
|
## Установка
|
||||||
|
|
||||||
|
|
@ -68,12 +66,15 @@ netsleuth
|
||||||
| `--target-host <ip\|domain>` | Добавить хост в веер пингов и трассировок |
|
| `--target-host <ip\|domain>` | Добавить хост в веер пингов и трассировок |
|
||||||
| `--speedtest-server <id\|url>` | Зафиксировать конкретный сервер замера скорости |
|
| `--speedtest-server <id\|url>` | Зафиксировать конкретный сервер замера скорости |
|
||||||
| `--watch` | Непрерывный мониторинг с живой панелью — чтобы поймать плавающие обрывы. Пишет один тайм-ряд `logs/watch_*.json` за сессию, а не отдельный отчёт на каждый тик |
|
| `--watch` | Непрерывный мониторинг с живой панелью — чтобы поймать плавающие обрывы. Пишет один тайм-ряд `logs/watch_*.json` за сессию, а не отдельный отчёт на каждый тик |
|
||||||
| `--compare a.json b.json` | Сравнить два прошлых отчёта: до/после смены VPN, до/после звонка провайдеру. Только чтение — без проб и сетевых запросов |
|
| `--compare a.json b.json` | Сравнить два прошлых отчёта: до/после смены VPN, до/после звонка провайдеру. Только чтение — без проб и сетевых запросов. Читает отчёты, полученные с `--format json` |
|
||||||
| `--dnsbl` | Включить проверку по классическим зонам DNSBL |
|
| `--dnsbl` | Включить проверку по классическим зонам DNSBL |
|
||||||
| `--ndt7` | Включить M-Lab NDT7 (замер публикуется как открытые данные CC0, включая ваш IP). В интерактивном терминале сначала печатает уведомление о согласии и спрашивает подтверждение; в неинтерактивном — продолжает без вопроса |
|
| `--ndt7` | Включить M-Lab NDT7 (замер публикуется как открытые данные CC0, включая ваш IP). В интерактивном терминале сначала печатает уведомление о согласии и спрашивает подтверждение; в неинтерактивном — продолжает без вопроса |
|
||||||
| `--tcp-trace` | Включить TCP-SYN-трассировку через scapy сквозь middlebox'ы, режущие ICMP (нужны extras `tcptrace` и Npcap на Windows или root на Unix). Если не может запуститься — откатывается на обычный каскад |
|
| `--tcp-trace` | Включить TCP-SYN-трассировку через scapy сквозь middlebox'ы, режущие ICMP (нужны extras `tcptrace` и Npcap на Windows или root на Unix). Если не может запуститься — откатывается на обычный каскад |
|
||||||
|
| `--format md,ru-md,json,all,none` | Выбрать, какие артефакты отчёта писать (по умолчанию: `md`). Повторяемый флаг и через запятую (`--format md,json`); любое использование заменяет дефолт, а не дополняет его. `all` пишет все три, `none` — ничего. Игнорируется с `--watch`/`--compare` |
|
||||||
|
| `--ru` | Сокращение для `--format ru-md` |
|
||||||
|
| `--json` | Сокращение для `--format json` |
|
||||||
|
|
||||||
Каждый прогон пишет в `./logs/` три файла: Markdown-отчёт на английском, его полный перевод на русский (`.ru.md` — те же разделы, те же таблицы, те же числа, со взаимными ссылками) и JSON-дамп со всеми сырыми ответами провайдеров без усечения. `./logs/` в gitignore — отчёты содержат ваш внешний IP, город, координаты и название провайдера.
|
Каждый прогон пишет в `./logs/` выбранные артефакты: Markdown-отчёт на английском, его полный перевод на русский (`.ru.md` — те же разделы, те же таблицы, те же числа, со взаимными ссылками, если пишутся оба) и/или JSON-дамп со всеми сырыми ответами провайдеров без усечения. По умолчанию — только английский Markdown; чтобы поменять дефолт навсегда, задайте `output.formats` в `config.yaml`, либо используйте `--format` на конкретном прогоне. `./logs/` в gitignore — отчёты содержат ваш внешний IP, город, координаты и название провайдера.
|
||||||
|
|
||||||
netsleuth никогда не требует администратора или root. Там, где привилегии дали бы данные лучше, он деградирует до непривилегированного метода и пишет об этом в отчёте.
|
netsleuth никогда не требует администратора или root. Там, где привилегии дали бы данные лучше, он деградирует до непривилегированного метода и пишет об этом в отчёте.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,9 @@ output:
|
||||||
logs_dir: "./logs"
|
logs_dir: "./logs"
|
||||||
cache_dir: "./.cache"
|
cache_dir: "./.cache"
|
||||||
emoji: true
|
emoji: true
|
||||||
|
# Which report artifacts a run writes by default: md, ru-md, json, or all.
|
||||||
|
# Overridden per run by --format/--ru/--json.
|
||||||
|
formats: ["md"]
|
||||||
|
|
||||||
watch:
|
watch:
|
||||||
interval_seconds: 60
|
interval_seconds: 60
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import sys
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional, Tuple
|
from typing import Any, List, Optional, Tuple
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import typer
|
import typer
|
||||||
|
|
@ -15,8 +15,8 @@ from rich.console import Console
|
||||||
|
|
||||||
from netsleuth import __version__
|
from netsleuth import __version__
|
||||||
from netsleuth.compare import diff_reports, load_report, render_diff
|
from netsleuth.compare import diff_reports, load_report, render_diff
|
||||||
from netsleuth.config import Settings, load_settings
|
from netsleuth.config import FORMAT_EXTENSIONS, Settings, load_settings
|
||||||
from netsleuth.exporter import build_report, egress_asn, render_markdown, report_filename, write_report
|
from netsleuth.exporter import build_report, dump_json, egress_asn, render_markdown, report_filename, write_report
|
||||||
from netsleuth.interpret import (
|
from netsleuth.interpret import (
|
||||||
assess_vpn,
|
assess_vpn,
|
||||||
gather_vpn_signals,
|
gather_vpn_signals,
|
||||||
|
|
@ -59,6 +59,39 @@ class Options:
|
||||||
dnsbl: bool = False
|
dnsbl: bool = False
|
||||||
ndt7: bool = False
|
ndt7: bool = False
|
||||||
tcp_trace: bool = False
|
tcp_trace: bool = False
|
||||||
|
formats: frozenset[str] = frozenset({"md"})
|
||||||
|
|
||||||
|
|
||||||
|
def parse_formats(
|
||||||
|
values: list[str], ru: bool, json_flag: bool, default: frozenset[str]
|
||||||
|
) -> frozenset[str]:
|
||||||
|
tokens = [t.strip().lower() for raw in values for t in raw.split(",") if t.strip()]
|
||||||
|
if ru:
|
||||||
|
tokens.append("ru-md")
|
||||||
|
if json_flag:
|
||||||
|
tokens.append("json")
|
||||||
|
if not tokens:
|
||||||
|
return default
|
||||||
|
if "none" in tokens:
|
||||||
|
if len(set(tokens)) > 1:
|
||||||
|
raise typer.BadParameter("'none' cannot be combined with other formats.")
|
||||||
|
return frozenset()
|
||||||
|
if "all" in tokens:
|
||||||
|
return frozenset(FORMAT_EXTENSIONS)
|
||||||
|
unknown = sorted(set(tokens) - set(FORMAT_EXTENSIONS))
|
||||||
|
if unknown:
|
||||||
|
raise typer.BadParameter(
|
||||||
|
f"unknown format(s) {unknown}; expected one of {sorted(FORMAT_EXTENSIONS)}, 'all' or 'none'"
|
||||||
|
)
|
||||||
|
return frozenset(tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def format_siblings(
|
||||||
|
formats: frozenset[str], md_name: str, ru_md_name: str
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
if "md" in formats and "ru-md" in formats:
|
||||||
|
return ru_md_name, md_name
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
def parse_target(value: str) -> tuple[str, str]:
|
def parse_target(value: str) -> tuple[str, str]:
|
||||||
|
|
@ -274,7 +307,7 @@ async def _speed_section(client, settings: Settings, options: Options, idle_rtt_
|
||||||
return ModuleResult(name="speed", status="ok", data=result)
|
return ModuleResult(name="speed", status="ok", data=result)
|
||||||
|
|
||||||
|
|
||||||
async def diagnose(settings: Settings, options: Options) -> tuple[dict, str, str, Path, Path, Path]:
|
async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Path]]:
|
||||||
started_at = utc_now_iso()
|
started_at = utc_now_iso()
|
||||||
caps = detect_capabilities()
|
caps = detect_capabilities()
|
||||||
timeouts = settings.timeouts
|
timeouts = settings.timeouts
|
||||||
|
|
@ -400,6 +433,7 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, str, str
|
||||||
"ndt7": options.ndt7,
|
"ndt7": options.ndt7,
|
||||||
"tcp_trace": options.tcp_trace,
|
"tcp_trace": options.tcp_trace,
|
||||||
},
|
},
|
||||||
|
"formats": sorted(options.formats),
|
||||||
"host_os": f"{platform.system()} {platform.release()}",
|
"host_os": f"{platform.system()} {platform.release()}",
|
||||||
"capabilities": caps,
|
"capabilities": caps,
|
||||||
}
|
}
|
||||||
|
|
@ -407,12 +441,16 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, str, str
|
||||||
asn = meta.get("target") or egress_asn(report)
|
asn = meta.get("target") or egress_asn(report)
|
||||||
md_name = report_filename(asn, started_at, "md")
|
md_name = report_filename(asn, started_at, "md")
|
||||||
ru_md_name = report_filename(asn, started_at, "ru.md")
|
ru_md_name = report_filename(asn, started_at, "ru.md")
|
||||||
markdown = render_markdown(report, emoji=settings.output.emoji, lang="en", sibling=ru_md_name)
|
sibling_for_md, sibling_for_ru = format_siblings(options.formats, md_name, ru_md_name)
|
||||||
markdown_ru = render_markdown(report, emoji=settings.output.emoji, lang="ru", sibling=md_name)
|
artifacts: dict[str, str] = {}
|
||||||
json_path, md_path, ru_md_path = write_report(
|
if "md" in options.formats:
|
||||||
report, markdown, markdown_ru, Path(settings.output.logs_dir)
|
artifacts["md"] = render_markdown(report, emoji=settings.output.emoji, lang="en", sibling=sibling_for_md)
|
||||||
)
|
if "ru-md" in options.formats:
|
||||||
return report, markdown, markdown_ru, json_path, md_path, ru_md_path
|
artifacts["ru-md"] = render_markdown(report, emoji=settings.output.emoji, lang="ru", sibling=sibling_for_ru)
|
||||||
|
if "json" in options.formats:
|
||||||
|
artifacts["json"] = dump_json(report)
|
||||||
|
paths = write_report(report, artifacts, Path(settings.output.logs_dir))
|
||||||
|
return report, paths
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
|
|
@ -423,10 +461,23 @@ def run(
|
||||||
target_host: Optional[str] = typer.Option(None, "--target-host", help="Extra host for ping/traceroute."),
|
target_host: Optional[str] = typer.Option(None, "--target-host", help="Extra host for ping/traceroute."),
|
||||||
speedtest_server: Optional[str] = typer.Option(None, "--speedtest-server", help="Pin the speedtest server."),
|
speedtest_server: Optional[str] = typer.Option(None, "--speedtest-server", help="Pin the speedtest server."),
|
||||||
watch: bool = typer.Option(False, "--watch", help="Continuous monitoring with a live dashboard."),
|
watch: bool = typer.Option(False, "--watch", help="Continuous monitoring with a live dashboard."),
|
||||||
compare: Optional[Tuple[Path, Path]] = typer.Option(None, "--compare", help="Diff two saved JSON reports."),
|
compare: Optional[Tuple[Path, Path]] = typer.Option(
|
||||||
|
None, "--compare", help="Diff two saved JSON reports — reads reports produced with --format json."
|
||||||
|
),
|
||||||
dnsbl: bool = typer.Option(False, "--dnsbl", help="Also query classic DNSBL zones."),
|
dnsbl: bool = typer.Option(False, "--dnsbl", help="Also query classic DNSBL zones."),
|
||||||
ndt7: bool = typer.Option(False, "--ndt7", help="Add the M-Lab NDT7 speedtest tier (publishes your IP)."),
|
ndt7: bool = typer.Option(False, "--ndt7", help="Add the M-Lab NDT7 speedtest tier (publishes your IP)."),
|
||||||
tcp_trace: bool = typer.Option(False, "--tcp-trace", help="Add a scapy TCP-SYN traceroute tier."),
|
tcp_trace: bool = typer.Option(False, "--tcp-trace", help="Add a scapy TCP-SYN traceroute tier."),
|
||||||
|
format_: List[str] = typer.Option(
|
||||||
|
[],
|
||||||
|
"--format",
|
||||||
|
help=(
|
||||||
|
"Which report artifacts to write: md, ru-md, json, all, none. Repeatable and "
|
||||||
|
"comma-separated (--format md,json). Any --format replaces the default instead of "
|
||||||
|
"adding to it. Ignored with --watch and --compare."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ru: bool = typer.Option(False, "--ru", help="Shorthand for --format ru-md."),
|
||||||
|
json_flag: bool = typer.Option(False, "--json", help="Shorthand for --format json."),
|
||||||
) -> None:
|
) -> None:
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
if compare:
|
if compare:
|
||||||
|
|
@ -435,6 +486,7 @@ def run(
|
||||||
raise typer.Exit(0)
|
raise typer.Exit(0)
|
||||||
|
|
||||||
kind, value = parse_target(target) if target else (None, None)
|
kind, value = parse_target(target) if target else (None, None)
|
||||||
|
formats = parse_formats(format_, ru=ru, json_flag=json_flag, default=frozenset(settings.output.formats))
|
||||||
options = Options(
|
options = Options(
|
||||||
mode="target" if target else "auto",
|
mode="target" if target else "auto",
|
||||||
target_kind=kind,
|
target_kind=kind,
|
||||||
|
|
@ -446,6 +498,7 @@ def run(
|
||||||
dnsbl=dnsbl,
|
dnsbl=dnsbl,
|
||||||
ndt7=ndt7,
|
ndt7=ndt7,
|
||||||
tcp_trace=tcp_trace,
|
tcp_trace=tcp_trace,
|
||||||
|
formats=formats,
|
||||||
)
|
)
|
||||||
if ndt7:
|
if ndt7:
|
||||||
consent_marker = Path(settings.output.cache_dir) / "ndt7_consent"
|
consent_marker = Path(settings.output.cache_dir) / "ndt7_consent"
|
||||||
|
|
@ -469,13 +522,19 @@ def run(
|
||||||
raise typer.Exit(0)
|
raise typer.Exit(0)
|
||||||
|
|
||||||
console.print(f"netsleuth {__version__} · {options.mode} mode · {platform.system()}")
|
console.print(f"netsleuth {__version__} · {options.mode} mode · {platform.system()}")
|
||||||
report, _markdown, _markdown_ru, json_path, md_path, ru_md_path = asyncio.run(diagnose(settings, options))
|
report, paths = asyncio.run(diagnose(settings, options))
|
||||||
interpretation = report["interpretation"]
|
interpretation = report["interpretation"]
|
||||||
console.print(
|
console.print(
|
||||||
f"Verdict: [bold]{interpretation['overall_status']}[/bold] "
|
f"Verdict: [bold]{interpretation['overall_status']}[/bold] "
|
||||||
f"({interpretation['overall_score']}/100) — {interpretation['summary_text']}"
|
f"({interpretation['overall_score']}/100) — {interpretation['summary_text']}"
|
||||||
)
|
)
|
||||||
console.print(f"Report written to {md_path}\n {ru_md_path}\n {json_path}")
|
if not paths:
|
||||||
|
console.print("No report written (--format none).")
|
||||||
|
else:
|
||||||
|
label = "Report written to"
|
||||||
|
for path in paths:
|
||||||
|
console.print(f"{label} {path}")
|
||||||
|
label = " " * len("Report written to")
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ from __future__ import annotations
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, SecretStr
|
from pydantic import BaseModel, Field, SecretStr, field_validator
|
||||||
|
|
||||||
|
FORMAT_EXTENSIONS = {"md": "md", "ru-md": "ru.md", "json": "json"}
|
||||||
from pydantic_settings import (
|
from pydantic_settings import (
|
||||||
BaseSettings,
|
BaseSettings,
|
||||||
PydanticBaseSettingsSource,
|
PydanticBaseSettingsSource,
|
||||||
|
|
@ -124,6 +126,24 @@ class Output(BaseModel):
|
||||||
logs_dir: str = "./logs"
|
logs_dir: str = "./logs"
|
||||||
cache_dir: str = "./.cache"
|
cache_dir: str = "./.cache"
|
||||||
emoji: bool = True
|
emoji: bool = True
|
||||||
|
formats: list[str] = Field(default_factory=lambda: ["md"])
|
||||||
|
|
||||||
|
@field_validator("formats")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_formats(cls, value: list[str]) -> list[str]:
|
||||||
|
tokens = [str(v).strip().lower() for v in value]
|
||||||
|
if "all" in tokens:
|
||||||
|
tokens = list(FORMAT_EXTENSIONS)
|
||||||
|
seen: list[str] = []
|
||||||
|
for token in tokens:
|
||||||
|
if token not in FORMAT_EXTENSIONS:
|
||||||
|
raise ValueError(
|
||||||
|
f"unknown output format {token!r}; expected one of "
|
||||||
|
f"{sorted(FORMAT_EXTENSIONS)} or 'all'"
|
||||||
|
)
|
||||||
|
if token not in seen:
|
||||||
|
seen.append(token)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
class Watch(BaseModel):
|
class Watch(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from netsleuth.config import FORMAT_EXTENSIONS
|
||||||
from netsleuth.interpret import overall_verdict
|
from netsleuth.interpret import overall_verdict
|
||||||
from netsleuth.models import Finding, ModuleResult, to_jsonable
|
from netsleuth.models import Finding, ModuleResult, to_jsonable
|
||||||
|
|
||||||
|
|
@ -88,17 +89,15 @@ def egress_asn(report: dict[str, Any]) -> str | None:
|
||||||
return ((data.get("egress_v4") or {}) if isinstance(data, dict) else {}).get("asn")
|
return ((data.get("egress_v4") or {}) if isinstance(data, dict) else {}).get("asn")
|
||||||
|
|
||||||
|
|
||||||
def write_report(
|
def write_report(report: dict[str, Any], artifacts: dict[str, str], logs_dir: Path) -> list[Path]:
|
||||||
report: dict[str, Any], markdown: str, markdown_ru: str, logs_dir: Path
|
|
||||||
) -> tuple[Path, Path, Path]:
|
|
||||||
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)
|
||||||
json_path = atomic_write(base / report_filename(asn, started, "json"), dump_json(report))
|
return [
|
||||||
md_path = atomic_write(base / report_filename(asn, started, "md"), markdown)
|
atomic_write(base / report_filename(asn, started, FORMAT_EXTENSIONS[fmt]), text)
|
||||||
ru_md_path = atomic_write(base / report_filename(asn, started, "ru.md"), markdown_ru)
|
for fmt, text in artifacts.items()
|
||||||
return json_path, md_path, ru_md_path
|
]
|
||||||
|
|
||||||
|
|
||||||
from netsleuth.interpret import bufferbloat_consequence
|
from netsleuth.interpret import bufferbloat_consequence
|
||||||
|
|
|
||||||
85
tests/test_cli_formats.py
Normal file
85
tests/test_cli_formats.py
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from netsleuth.cli import format_siblings, parse_formats
|
||||||
|
|
||||||
|
DEFAULT = frozenset({"md"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_flags_returns_the_default():
|
||||||
|
assert parse_formats([], ru=False, json_flag=False, default=DEFAULT) == DEFAULT
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_shortcut_selects_json_only():
|
||||||
|
assert parse_formats([], ru=False, json_flag=True, default=DEFAULT) == frozenset({"json"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_ru_shortcut_selects_ru_md_only():
|
||||||
|
assert parse_formats([], ru=True, json_flag=False, default=DEFAULT) == frozenset({"ru-md"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_and_format_md_stack():
|
||||||
|
assert parse_formats(["md"], ru=False, json_flag=True, default=DEFAULT) == frozenset({"md", "json"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeated_format_flags_union():
|
||||||
|
assert parse_formats(["md", "json"], ru=False, json_flag=False, default=DEFAULT) == frozenset(
|
||||||
|
{"md", "json"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_comma_separated_single_flag():
|
||||||
|
assert parse_formats(["md,json"], ru=False, json_flag=False, default=DEFAULT) == frozenset(
|
||||||
|
{"md", "json"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_expands_to_every_known_format():
|
||||||
|
assert parse_formats(["all"], ru=False, json_flag=False, default=DEFAULT) == frozenset(
|
||||||
|
{"md", "ru-md", "json"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_alone_means_write_nothing():
|
||||||
|
assert parse_formats(["none"], ru=False, json_flag=False, default=DEFAULT) == frozenset()
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_combined_with_another_format_is_an_error():
|
||||||
|
with pytest.raises(typer.BadParameter):
|
||||||
|
parse_formats(["none,md"], ru=False, json_flag=False, default=DEFAULT)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_format_is_an_error():
|
||||||
|
with pytest.raises(typer.BadParameter):
|
||||||
|
parse_formats(["pdf"], ru=False, json_flag=False, default=DEFAULT)
|
||||||
|
|
||||||
|
|
||||||
|
def test_case_and_whitespace_are_normalized():
|
||||||
|
assert parse_formats([" MD ", "JSON"], ru=False, json_flag=False, default=DEFAULT) == frozenset(
|
||||||
|
{"md", "json"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_of_flags_never_changes_the_result():
|
||||||
|
a = parse_formats(["json", "md"], ru=False, json_flag=False, default=DEFAULT)
|
||||||
|
b = parse_formats(["md", "json"], ru=False, json_flag=False, default=DEFAULT)
|
||||||
|
assert a == b
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_siblings_cross_link_when_both_languages_selected():
|
||||||
|
result = format_siblings(frozenset({"md", "ru-md"}), "report.md", "report.ru.md")
|
||||||
|
assert result == ("report.ru.md", "report.md")
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_siblings_are_none_when_only_english_selected():
|
||||||
|
assert format_siblings(frozenset({"md"}), "report.md", "report.ru.md") == (None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_siblings_are_none_when_only_russian_selected():
|
||||||
|
assert format_siblings(frozenset({"ru-md"}), "report.md", "report.ru.md") == (None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_siblings_are_none_when_neither_markdown_selected():
|
||||||
|
assert format_siblings(frozenset({"json"}), "report.md", "report.ru.md") == (None, None)
|
||||||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||||
import textwrap
|
import textwrap
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from netsleuth.config import load_settings
|
from netsleuth.config import load_settings
|
||||||
|
|
||||||
|
|
@ -81,3 +82,44 @@ def test_secrets_load_from_dotenv_without_the_nested_prefix(tmp_path, monkeypatc
|
||||||
def test_missing_yaml_is_not_an_error(tmp_path):
|
def test_missing_yaml_is_not_an_error(tmp_path):
|
||||||
s = load_settings(config_path=tmp_path / "nope.yaml", env_file=tmp_path / "nope.env")
|
s = load_settings(config_path=tmp_path / "nope.yaml", env_file=tmp_path / "nope.env")
|
||||||
assert s.probing.ping_count == 20
|
assert s.probing.ping_count == 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_output_formats_defaults_to_english_markdown_only(tmp_path):
|
||||||
|
s = load_settings(config_path=tmp_path / "nope.yaml", env_file=tmp_path / "nope.env")
|
||||||
|
assert s.output.formats == ["md"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_output_formats_yaml_override(tmp_path):
|
||||||
|
path = tmp_path / "config.yaml"
|
||||||
|
path.write_text(
|
||||||
|
textwrap.dedent(
|
||||||
|
"""
|
||||||
|
output:
|
||||||
|
formats: ["md", "json"]
|
||||||
|
"""
|
||||||
|
).strip(),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
s = load_settings(config_path=path, env_file=tmp_path / "nope.env")
|
||||||
|
assert s.output.formats == ["md", "json"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_output_formats_all_expands_to_every_known_format(tmp_path):
|
||||||
|
path = tmp_path / "config.yaml"
|
||||||
|
path.write_text('output:\n formats: ["all"]\n', encoding="utf-8")
|
||||||
|
s = load_settings(config_path=path, env_file=tmp_path / "nope.env")
|
||||||
|
assert s.output.formats == ["md", "ru-md", "json"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_output_formats_dedupes_and_lowercases(tmp_path):
|
||||||
|
path = tmp_path / "config.yaml"
|
||||||
|
path.write_text('output:\n formats: ["MD", "md", "JSON"]\n', encoding="utf-8")
|
||||||
|
s = load_settings(config_path=path, env_file=tmp_path / "nope.env")
|
||||||
|
assert s.output.formats == ["md", "json"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_output_formats_rejects_unknown_value(tmp_path):
|
||||||
|
path = tmp_path / "config.yaml"
|
||||||
|
path.write_text('output:\n formats: ["pdf"]\n', encoding="utf-8")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
load_settings(config_path=path, env_file=tmp_path / "nope.env")
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from pathlib import Path
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from netsleuth.exporter import (
|
from netsleuth.exporter import (
|
||||||
|
FORMAT_EXTENSIONS,
|
||||||
SCHEMA_VERSION,
|
SCHEMA_VERSION,
|
||||||
SECTION_ORDER,
|
SECTION_ORDER,
|
||||||
atomic_write,
|
atomic_write,
|
||||||
|
|
@ -195,12 +196,15 @@ def test_egress_asn_is_read_out_of_the_assembled_report():
|
||||||
|
|
||||||
def test_write_report_names_the_file_after_the_target_in_target_mode(tmp_path: Path):
|
def test_write_report_names_the_file_after_the_target_in_target_mode(tmp_path: Path):
|
||||||
report = build_report({**meta(), "mode": "target", "target": "AS15169"}, modules(), [], {})
|
report = build_report({**meta(), "mode": "target", "target": "AS15169"}, modules(), [], {})
|
||||||
json_path, md_path, ru_md_path = write_report(
|
paths = write_report(
|
||||||
report, "# netsleuth report\n", "# netsleuth report\n", tmp_path
|
report,
|
||||||
|
{"json": "{}", "md": "# netsleuth report\n", "ru-md": "# netsleuth report\n"},
|
||||||
|
tmp_path,
|
||||||
)
|
)
|
||||||
assert json_path.name == "report_AS15169_20260808T191200Z.json"
|
names = {p.name for p in paths}
|
||||||
assert md_path.name == "report_AS15169_20260808T191200Z.md"
|
assert "report_AS15169_20260808T191200Z.json" in names
|
||||||
assert ru_md_path.name == "report_AS15169_20260808T191200Z.ru.md"
|
assert "report_AS15169_20260808T191200Z.md" in names
|
||||||
|
assert "report_AS15169_20260808T191200Z.ru.md" in names
|
||||||
|
|
||||||
|
|
||||||
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):
|
||||||
|
|
@ -218,17 +222,45 @@ def test_atomic_write_replaces_an_existing_file(tmp_path: Path):
|
||||||
assert not list(tmp_path.glob("*.tmp"))
|
assert not list(tmp_path.glob("*.tmp"))
|
||||||
|
|
||||||
|
|
||||||
def test_write_report_emits_both_artifacts_with_matching_names(tmp_path: Path):
|
def test_write_report_emits_all_requested_artifacts_with_matching_names(tmp_path: Path):
|
||||||
report = build_report(meta(), modules(), [], {})
|
report = build_report(meta(), modules(), [], {})
|
||||||
json_path, md_path, ru_md_path = write_report(
|
paths = write_report(
|
||||||
report, "# netsleuth report\n", "# netsleuth report\n", tmp_path
|
report,
|
||||||
|
{"json": dump_json(report), "md": "# netsleuth report\n", "ru-md": "# netsleuth report\n"},
|
||||||
|
tmp_path,
|
||||||
)
|
)
|
||||||
assert json_path.name == "report_AS64500_20260808T191200Z.json"
|
by_ext = {p.name.rsplit(".", 1)[-1] if not p.name.endswith(".ru.md") else "ru.md": p for p in paths}
|
||||||
assert md_path.name == "report_AS64500_20260808T191200Z.md"
|
assert by_ext["json"].name == "report_AS64500_20260808T191200Z.json"
|
||||||
assert ru_md_path.name == "report_AS64500_20260808T191200Z.ru.md"
|
assert by_ext["md"].name == "report_AS64500_20260808T191200Z.md"
|
||||||
assert json.loads(json_path.read_text(encoding="utf-8"))["schema_version"] == SCHEMA_VERSION
|
assert by_ext["ru.md"].name == "report_AS64500_20260808T191200Z.ru.md"
|
||||||
assert md_path.read_text(encoding="utf-8").startswith("# netsleuth report")
|
assert json.loads(by_ext["json"].read_text(encoding="utf-8"))["schema_version"] == SCHEMA_VERSION
|
||||||
assert ru_md_path.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")
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_report_writes_only_the_requested_formats(tmp_path: Path):
|
||||||
|
report = build_report(meta(), modules(), [], {})
|
||||||
|
paths = write_report(report, {"md": "# netsleuth report\n"}, tmp_path)
|
||||||
|
assert [p.name for p in paths] == ["report_AS64500_20260808T191200Z.md"]
|
||||||
|
assert list(tmp_path.iterdir()) == paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_report_with_empty_mapping_writes_nothing(tmp_path: Path):
|
||||||
|
report = build_report(meta(), modules(), [], {})
|
||||||
|
paths = write_report(report, {}, tmp_path)
|
||||||
|
assert paths == []
|
||||||
|
assert list(tmp_path.iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_report_returns_paths_in_input_order(tmp_path: Path):
|
||||||
|
report = build_report(meta(), modules(), [], {})
|
||||||
|
paths = write_report(report, {"json": "{}", "md": "# x\n"}, tmp_path)
|
||||||
|
assert paths[0].name.endswith(".json")
|
||||||
|
assert paths[1].name.endswith(".md")
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_extensions_covers_the_three_known_formats():
|
||||||
|
assert FORMAT_EXTENSIONS == {"md": "md", "ru-md": "ru.md", "json": "json"}
|
||||||
|
|
||||||
|
|
||||||
from netsleuth.exporter import SPARK_CHARS, badge, first_loss_jump, render_markdown, sparkline
|
from netsleuth.exporter import SPARK_CHARS, badge, first_loss_jump, render_markdown, sparkline
|
||||||
|
|
@ -636,10 +668,13 @@ def test_an_all_modules_failed_report_is_still_valid_strict_json():
|
||||||
|
|
||||||
def test_an_all_modules_failed_run_writes_both_artifacts(tmp_path: Path):
|
def test_an_all_modules_failed_run_writes_both_artifacts(tmp_path: Path):
|
||||||
report = dead_report()
|
report = dead_report()
|
||||||
json_path, md_path, ru_md_path = write_report(
|
paths = write_report(
|
||||||
report, render_markdown(report), render_markdown(report, lang="ru"), tmp_path
|
report,
|
||||||
|
{"md": render_markdown(report), "ru-md": render_markdown(report, lang="ru")},
|
||||||
|
tmp_path,
|
||||||
)
|
)
|
||||||
assert json_path.name.startswith("report_unknown_")
|
md_path = next(p for p in paths if p.name.endswith(".md") and not p.name.endswith(".ru.md"))
|
||||||
|
ru_md_path = next(p for p in paths if p.name.endswith(".ru.md"))
|
||||||
assert md_path.name.startswith("report_unknown_")
|
assert md_path.name.startswith("report_unknown_")
|
||||||
assert ru_md_path.name.startswith("report_unknown_")
|
assert ru_md_path.name.startswith("report_unknown_")
|
||||||
assert "## Run diagnostics" in md_path.read_text(encoding="utf-8")
|
assert "## Run diagnostics" in md_path.read_text(encoding="utf-8")
|
||||||
|
|
@ -694,10 +729,11 @@ def test_write_report_creates_three_files_with_the_right_extensions(tmp_path: Pa
|
||||||
ru_name = "sibling.ru.md"
|
ru_name = "sibling.ru.md"
|
||||||
markdown = render_markdown(report, lang="en", sibling=ru_name)
|
markdown = render_markdown(report, lang="en", sibling=ru_name)
|
||||||
markdown_ru = render_markdown(report, lang="ru", sibling=en_name)
|
markdown_ru = render_markdown(report, lang="ru", sibling=en_name)
|
||||||
json_path, md_path, ru_md_path = write_report(report, markdown, markdown_ru, tmp_path)
|
paths = write_report(report, {"json": dump_json(report), "md": markdown, "ru-md": markdown_ru}, tmp_path)
|
||||||
|
json_path = next(p for p in paths if p.suffix == ".json")
|
||||||
|
md_path = next(p for p in paths if p.name.endswith(".md") and not p.name.endswith(".ru.md"))
|
||||||
|
ru_md_path = next(p for p in paths if p.name.endswith(".ru.md"))
|
||||||
assert json_path.suffix == ".json"
|
assert json_path.suffix == ".json"
|
||||||
assert md_path.name.endswith(".md") and not md_path.name.endswith(".ru.md")
|
|
||||||
assert ru_md_path.name.endswith(".ru.md")
|
|
||||||
|
|
||||||
ru_text = ru_md_path.read_text(encoding="utf-8")
|
ru_text = ru_md_path.read_text(encoding="utf-8")
|
||||||
assert _has_cyrillic(ru_text)
|
assert _has_cyrillic(ru_text)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue