Skip to content

Commit 06df0a9

Browse files
committed
refactor: harmonization pass 2 — keyword-only assess, float timeout, print_full_report, ruff clean
- assessor: enforce keyword-only params with *,; timeout int→float; Optional→X|None - cli: --version -v → -V; print_report→print_full_report; add info groups subcommand - models/tls_utils: replace all Optional[X] with X|None - reporter: print_report→print_full_report (deprecated alias kept); save_report raises ValueError - Add pytest-mock to dev deps - ruff: F401 cleanup in tests
1 parent 4e30f83 commit 06df0a9

11 files changed

Lines changed: 96 additions & 55 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ quantumvalidator/
3434
cli.py Typer CLI: check command; --json, --output, --version flags
3535
constants.py PQC_GROUPS, SAFE_GROUPS, PROBE_GROUPS, check_openssl()
3636
models.py Verdict, Status, CheckResult, QuantumReport dataclasses
37-
reporter.py print_report() — Rich terminal renderer (no I/O of its own)
37+
reporter.py print_full_report() — Rich terminal renderer (no I/O of its own)
3838
tls_utils.py probe_tls() — subprocess openssl s_client; sole I/O boundary
3939
verdict.py determine_verdict() / build_checks() — pure logic
4040
tests/
@@ -54,7 +54,7 @@ Request lifecycle:
5454
openssl; otherwise uses raw TLS; parses `Protocol version:` and
5555
`Negotiated TLS1.3 group:` from combined stdout+stderr
5656
5. `assess()` calls `build_checks()` and `determine_verdict()` from `verdict.py`; derives `QuantumReport`
57-
6. Returns `QuantumReport`; CLI calls `print_report()` from `reporter.py`
57+
6. Returns `QuantumReport`; CLI calls `print_full_report()` from `reporter.py`
5858

5959
**Single I/O boundary**: all network calls go through `tls_utils.probe_tls`.
6060
Two mock targets in tests:
@@ -67,7 +67,7 @@ Two mock targets in tests:
6767
- **Fixture factory**: `make_probe_result()` in `conftest.py` — builds `TLSProbeResult` with defaults
6868
- **Module-level fixtures**: `SAFE_PROBE`, `UNSAFE_PROBE_CLASSICAL`, `UNSAFE_PROBE_TLS12`, `ERROR_PROBE`
6969
- **Assessor mocking**: patch `"quantumvalidator.assessor.check_tls"` (imported at module top)
70-
- **CLI mocking**: patch `"quantumvalidator.assessor.assess"` and `"quantumvalidator.reporter.print_report"` (both are lazy imports inside `check()`)
70+
- **CLI mocking**: patch `"quantumvalidator.assessor.assess"` and `"quantumvalidator.reporter.print_full_report"` (both are lazy imports inside `check()`)
7171
- **Reporter tests**: use `Console(file=StringIO(), no_color=True, width=200)` to capture output; pass console as trailing positional arg — private helpers use `con`, not `console`
7272
- **Test class naming**: `class TestFeatureName:`, snake_case methods, AAA structure
7373
- **Coverage target**: 100% — enforced via `pyproject.toml` addopts

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ where = ["."]
5959
include = ["quantumvalidator*"]
6060

6161
[project.optional-dependencies]
62-
dev = ["pytest>=8", "pytest-cov>=5"]
62+
dev = ["pytest>=8", "pytest-cov>=5", "pytest-mock>=3.12"]
6363

6464
[tool.pytest.ini_options]
6565
pythonpath = ["."]

quantumvalidator/assessor.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from __future__ import annotations
1717

1818
import logging
19-
from typing import Callable, Optional
19+
from collections.abc import Callable
2020

2121
from quantumvalidator.checker import check_tls
2222
from quantumvalidator.constants import DEFAULT_PORT_HTTPS
@@ -28,9 +28,10 @@
2828

2929
def assess(
3030
target: str,
31-
port: Optional[int] = None,
32-
timeout: int = 10,
33-
progress_cb: Optional[Callable[[str], None]] = None,
31+
*,
32+
port: int | None = None,
33+
timeout: float = 10.0,
34+
progress_cb: Callable[[str], None] | None = None,
3435
) -> QuantumReport:
3536
"""Probe *target* and assess its post-quantum TLS key exchange readiness.
3637
@@ -53,7 +54,7 @@ def assess(
5354

5455
logger.info("Starting assessment for %s:%d", target, effective_port)
5556

56-
result = check_tls(target, effective_port, timeout=timeout)
57+
result = check_tls(target, effective_port, timeout=int(timeout))
5758

5859
if not result.ok:
5960
checks: list[CheckResult] = [

quantumvalidator/cli.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,8 @@ def check(
7575
if json_output:
7676
_print_json(report)
7777
else:
78-
from quantumvalidator.reporter import print_report
79-
print_report(report)
78+
from quantumvalidator.reporter import print_full_report
79+
print_full_report(report)
8080

8181
if output:
8282
from quantumvalidator.reporter import save_report
@@ -93,10 +93,48 @@ def check(
9393
raise typer.Exit(code=0 if report.is_safe else 1)
9494

9595

96+
info_app = typer.Typer(name="info", help="Show reference tables.")
97+
app.add_typer(info_app, name="info")
98+
99+
100+
@info_app.command("groups")
101+
def cmd_info_groups() -> None:
102+
"""List all quantum-safe key exchange groups supported."""
103+
from quantumvalidator.constants import PQC_GROUPS, SSH_PQC_GROUPS
104+
from rich.table import Table
105+
from rich.panel import Panel
106+
tbl = Table(show_header=True, header_style="bold blue", padding=(0, 1))
107+
tbl.add_column("Name", style="bold")
108+
tbl.add_column("IANA Codepoint", style="dim")
109+
tbl.add_column("Standard")
110+
tbl.add_column("Safe", justify="center")
111+
for name, info in PQC_GROUPS.items():
112+
tbl.add_row(
113+
name,
114+
info.iana_codepoint or "—",
115+
info.standard,
116+
"[green]✔[/green]" if info.safe else "[red]✘[/red]",
117+
)
118+
console.print(Panel("[bold]TLS Quantum-Safe KEX Groups[/bold]", style="blue"))
119+
console.print(tbl)
120+
ssh_tbl = Table(show_header=True, header_style="bold blue", padding=(0, 1))
121+
ssh_tbl.add_column("Name", style="bold")
122+
ssh_tbl.add_column("Standard")
123+
ssh_tbl.add_column("Safe", justify="center")
124+
for name, info in SSH_PQC_GROUPS.items():
125+
ssh_tbl.add_row(
126+
name,
127+
info.standard,
128+
"[green]✔[/green]" if info.safe else "[red]✘[/red]",
129+
)
130+
console.print(Panel("[bold]SSH Quantum-Safe KEX Groups[/bold]", style="blue"))
131+
console.print(ssh_tbl)
132+
133+
96134
@app.callback(invoke_without_command=True)
97135
def main(
98136
version: bool = typer.Option(
99-
False, "--version", "-v", is_eager=True, help="Show version and exit."
137+
False, "--version", "-V", is_eager=True, help="Show version and exit."
100138
),
101139
) -> None:
102140
if version:

quantumvalidator/models.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99

1010
from dataclasses import dataclass, field
1111
from enum import Enum
12-
from typing import Optional
1312

1413

1514
class Verdict(str, Enum):
@@ -38,13 +37,13 @@ class CheckResult:
3837
status: Status
3938
"""Pass / Fail / Info / Error."""
4039

41-
value: Optional[str]
40+
value: str | None
4241
"""Observed value (e.g. 'TLSv1.3', 'X25519MLKEM768'), or None."""
4342

4443
reason: str
4544
"""Human-readable explanation of the outcome."""
4645

47-
standard: Optional[str] = None
46+
standard: str | None = None
4847
"""Reference standard (e.g. 'CNSA 2.0, BSI TR-02102-2')."""
4948

5049
@property
@@ -63,16 +62,16 @@ class QuantumReport:
6362
target: str
6463
"""Hostname or IP that was probed."""
6564

66-
detected_starttls: Optional[str]
65+
detected_starttls: str | None
6766
"""STARTTLS mode detected from server banner (e.g. ``'smtp'``/``'ftp'``/``'nntp'``/``'ssh'``), or ``None`` for raw TLS."""
6867

6968
port: int
7069
"""TCP port used for the probe."""
7170

72-
tls_version: Optional[str]
71+
tls_version: str | None
7372
"""Negotiated TLS version string (e.g. 'TLSv1.3'), or None on error."""
7473

75-
negotiated_group: Optional[str]
74+
negotiated_group: str | None
7675
"""Negotiated key exchange name (e.g. 'X25519MLKEM768'), or None."""
7776

7877
verdict: Verdict

quantumvalidator/reporter.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
}
3838

3939

40-
def print_report(report: QuantumReport, console: Console | None = None) -> None:
40+
def print_full_report(report: QuantumReport, console: Console | None = None) -> None:
4141
"""Render a colour-coded terminal report for *report*.
4242
4343
:param report: Assessment report from assess().
@@ -172,10 +172,17 @@ def save_report(path: str) -> None:
172172
:raises OSError: If the file cannot be written.
173173
"""
174174
ext = os.path.splitext(path)[1].lower()
175-
fmt = _FORMAT_BY_EXT.get(ext, "text")
175+
fmt = _FORMAT_BY_EXT.get(ext)
176+
if fmt is None:
177+
raise ValueError(
178+
f"Unsupported file extension {ext!r}. Use .txt, .svg, or .html."
179+
)
176180
if fmt == "svg":
177181
console.save_svg(path, clear=False)
178182
elif fmt == "html":
179183
console.save_html(path, clear=False)
180184
else:
181185
console.save_text(path, clear=False)
186+
187+
188+
print_report = print_full_report # deprecated

quantumvalidator/tls_utils.py

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
import socket
1515
import subprocess
1616
from dataclasses import dataclass, replace
17-
from typing import Optional
1817

1918
from quantumvalidator.constants import (
2019
OPENSSL_BINARY,
@@ -59,13 +58,13 @@ class TLSProbeResult:
5958

6059
host: str
6160
port: int
62-
tls_version: Optional[str]
61+
tls_version: str | None
6362
"""Negotiated TLS version string (e.g. 'TLSv1.3'), or None on error."""
64-
negotiated_group: Optional[str]
63+
negotiated_group: str | None
6564
"""Negotiated key-exchange group name (e.g. 'X25519MLKEM768'), or None."""
66-
error: Optional[str] = None
65+
error: str | None = None
6766
"""Error message if the probe failed, None on success."""
68-
detected_starttls: Optional[str] = None
67+
detected_starttls: str | None = None
6968
"""STARTTLS mode detected from server banner (e.g. ``'smtp'``/``'ftp'``/``'nntp'``/``'ssh'``), or ``None``."""
7069

7170
@property
@@ -127,7 +126,7 @@ def probe_tls(
127126
return result
128127

129128

130-
def _build_cmd(host: str, port: int, starttls: Optional[str]) -> list[str]:
129+
def _build_cmd(host: str, port: int, starttls: str | None) -> list[str]:
131130
"""Build the openssl s_client command list.
132131
133132
:param host: Target hostname or IP.
@@ -157,7 +156,7 @@ def _build_cmd(host: str, port: int, starttls: Optional[str]) -> list[str]:
157156
def _run_openssl(
158157
host: str,
159158
port: int,
160-
starttls: Optional[str],
159+
starttls: str | None,
161160
timeout: int,
162161
) -> TLSProbeResult:
163162
"""Run a single ``openssl s_client`` probe and return the result.
@@ -424,7 +423,7 @@ def _probe_ftp(host: str, port: int, timeout: int) -> TLSProbeResult:
424423
return replace(_run_openssl(host, port, starttls="ftp", timeout=timeout), detected_starttls="ftp")
425424

426425

427-
def _fingerprint_banner(output: str) -> Optional[str]:
426+
def _fingerprint_banner(output: str) -> str | None:
428427
"""Detect STARTTLS mode from a server's opening banner.
429428
430429
Scans all lines for known application-protocol banner prefixes that
@@ -466,7 +465,7 @@ def _fingerprint_banner(output: str) -> Optional[str]:
466465
return None
467466

468467

469-
def _parse_openssl_output(output: str) -> tuple[Optional[str], Optional[str]]:
468+
def _parse_openssl_output(output: str) -> tuple[str | None, str | None]:
470469
"""Parse ``openssl s_client -brief`` output.
471470
472471
Confirmed output format (OpenSSL 3.6, 2026-04-28):
@@ -477,8 +476,8 @@ def _parse_openssl_output(output: str) -> tuple[Optional[str], Optional[str]]:
477476
:returns: ``(tls_version, negotiated_group)`` — either may be None.
478477
:rtype: tuple[str | None, str | None]
479478
"""
480-
tls_version: Optional[str] = None
481-
negotiated_group: Optional[str] = None
479+
tls_version: str | None = None
480+
negotiated_group: str | None = None
482481

483482
for line in output.splitlines():
484483
stripped = line.strip()
@@ -492,7 +491,7 @@ def _parse_openssl_output(output: str) -> tuple[Optional[str], Optional[str]]:
492491
return tls_version, negotiated_group
493492

494493

495-
def _extract_connection_error(output: str) -> Optional[str]:
494+
def _extract_connection_error(output: str) -> str | None:
496495
"""Extract a human-readable connection error from openssl output.
497496
498497
:param output: Combined stdout+stderr from a failed probe.

tests/test_cli.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def _patch_assess_error(exc: Exception):
5959

6060

6161
def _patch_print_report():
62-
return patch("quantumvalidator.reporter.print_report")
62+
return patch("quantumvalidator.reporter.print_full_report")
6363

6464

6565
# ---------------------------------------------------------------------------
@@ -284,12 +284,12 @@ def test_output_html_creates_file(self, tmp_path):
284284
assert path.exists()
285285
assert "<html" in path.read_text().lower()
286286

287-
def test_output_unknown_ext_writes_plain_text(self, tmp_path):
287+
def test_output_unknown_ext_exits_2(self, tmp_path):
288288
path = tmp_path / "report.log"
289289
with _patch_assess(_make_safe_report()):
290290
result = runner.invoke(app, ["check", "--output", str(path), "example.com"])
291-
assert result.exit_code == 0
292-
assert path.exists()
291+
assert result.exit_code == 2
292+
assert "Error" in result.output
293293

294294
def test_short_flag_o_accepted(self, tmp_path):
295295
path = tmp_path / "report.txt"

tests/test_constants.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
import subprocess
65

76
from quantumvalidator.constants import OPENSSL_BINARY, check_openssl
87

0 commit comments

Comments
 (0)