Skip to content

Commit f41d05d

Browse files
committed
refactor: harmonise with platform conventions
- __init__: use PackageNotFoundError instead of bare Exception - reporter: expose public console (record=True) and save_report(path) supporting .txt/.svg/.html (unknown ext falls back to plain text) - cli: import console/save_report from reporter; remove _save_report() - tests: update version-fallback test; replace JSON save test with Rich-output save test matching new save_report() contract
1 parent ebfcfd7 commit f41d05d

5 files changed

Lines changed: 73 additions & 53 deletions

File tree

subdomainenum/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22

33
from __future__ import annotations
44

5-
try:
6-
from importlib.metadata import version
5+
from importlib.metadata import PackageNotFoundError, version
76

7+
try:
88
__version__ = version("subdomainenum")
9-
except Exception:
9+
except PackageNotFoundError: # pragma: no cover – only when package not installed
1010
__version__ = "0.14.0"
1111

1212
# NullHandler so library users who have not configured logging

subdomainenum/cli.py

Lines changed: 11 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,14 @@
3333
from subdomainenum.constants import ACTIVE_TOOLS, detect_tools, get_install_hint
3434
from subdomainenum.debug_logger import DebugLogger
3535
from subdomainenum.models import EnumMode
36-
from subdomainenum.reporter import print_report, to_dict
36+
from subdomainenum.reporter import console, print_report, save_report, to_dict
3737

3838
app = typer.Typer(
3939
name="subdomainenum",
4040
help="Passive and active subdomain enumeration for a target domain.",
4141
add_completion=False,
4242
)
4343

44-
_console = Console(stderr=False)
4544
_err = Console(stderr=True)
4645

4746

@@ -194,7 +193,7 @@ def _progress_cb(msg: str) -> None:
194193
report = assess(domain, **assess_kwargs)
195194
except Exception as exc:
196195
if json_output:
197-
_console.print(json.dumps({"error": str(exc)}, indent=2))
196+
console.print(json.dumps({"error": str(exc)}, indent=2))
198197
else:
199198
_err.print(f"[red]Error:[/red] {exc}")
200199
raise typer.Exit(code=1)
@@ -208,10 +207,15 @@ def _progress_cb(msg: str) -> None:
208207
_print_json(report)
209208
return
210209

211-
print_report(report, console=_console)
210+
print_report(report, console=console)
212211

213212
if output:
214-
_save_report(report, output)
213+
try:
214+
save_report(output)
215+
console.print(f"[dim]Report saved to[/dim] {output}")
216+
except (ValueError, OSError) as exc:
217+
_err.print(f"[red]Error:[/red] Cannot write to {output!r}: {exc}")
218+
raise typer.Exit(code=1)
215219

216220

217221
# ---------------------------------------------------------------------------
@@ -233,7 +237,7 @@ def info() -> None:
233237
f"[{style}]{'yes' if avail else 'no'}[/{style}]",
234238
get_install_hint(name) if not avail else "",
235239
)
236-
_console.print(table)
240+
console.print(table)
237241

238242

239243
# ---------------------------------------------------------------------------
@@ -246,36 +250,7 @@ def _print_json(report) -> None:
246250
247251
:param report: :class:`~subdomainenum.models.EnumReport` to serialise.
248252
"""
249-
_console.print(json.dumps(to_dict(report), indent=2))
250-
251-
252-
def _save_report(report, path: str) -> None:
253-
"""Render *report* to a file at *path*.
254-
255-
The output format is inferred from the file extension:
256-
``.txt`` → plain text (no ANSI codes), ``.svg`` → SVG image,
257-
``.html`` → self-contained HTML page. Any other extension is
258-
treated as plain text.
259-
260-
:param report: :class:`~subdomainenum.models.EnumReport` to render.
261-
:param path: Destination file path.
262-
"""
263-
from rich.console import Console as RichConsole
264-
265-
ext = Path(path).suffix.lower()
266-
267-
rec_console = RichConsole(record=True, highlight=False, width=120)
268-
print_report(report, console=rec_console)
269-
270-
if ext == ".svg":
271-
content = rec_console.export_svg(title=f"subdomainenum — {report.domain}")
272-
elif ext == ".html":
273-
content = rec_console.export_html(inline_styles=True)
274-
else:
275-
content = rec_console.export_text()
276-
277-
Path(path).write_text(content, encoding="utf-8")
278-
_console.print(f"[dim]Report saved to[/dim] {path}")
253+
console.print(json.dumps(to_dict(report), indent=2))
279254

280255

281256
if __name__ == "__main__": # pragma: no cover

subdomainenum/reporter.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import json
6+
import os
67
from pathlib import Path
78

89
from rich.console import Console, Group
@@ -14,7 +15,11 @@
1415
from subdomainenum.models import EnumMode, EnumReport, Status, ToolResult
1516
from subdomainenum.verdict import build_verdict
1617

17-
_console = Console()
18+
# Module-level console; record=True enables save_report() export.
19+
# The public alias ``console`` is imported by cli.py so that all
20+
# terminal output flows through the same recorded stream.
21+
_console = Console(record=True)
22+
console = _console
1823

1924

2025
_SECTION_PANEL_KWARGS = dict(style="white", padding=(0, 1))
@@ -220,11 +225,38 @@ def print_report(report: EnumReport, *, console: Console | None = None) -> None:
220225
con.rule("[dim]End of Report[/dim]")
221226

222227

223-
def save_report(report: EnumReport, path: str | Path) -> None:
224-
"""Save the report as JSON to *path*.
228+
_FORMAT_BY_EXT: dict[str, str] = {
229+
".txt": "text",
230+
".text": "text",
231+
".svg": "svg",
232+
".html": "html",
233+
".htm": "html",
234+
}
225235

226-
:param report: Completed enumeration report.
227-
:param path: Destination file path.
236+
237+
def save_report(path: str) -> None:
238+
"""Save the recorded console output to *path*.
239+
240+
The export format is inferred from the file extension:
241+
242+
* ``.txt`` / ``.text`` → plain text (no ANSI codes)
243+
* ``.svg`` → SVG image
244+
* ``.html`` / ``.htm`` → self-contained HTML page
245+
246+
Must be called **after** :func:`print_report` because Rich only
247+
captures output when :class:`~rich.console.Console` is created with
248+
``record=True``, which is already set on the module-level
249+
:data:`console` instance.
250+
251+
:param path: Destination file path, e.g. ``"report.svg"``.
252+
:raises ValueError: If the extension is not one of the supported values.
253+
:raises OSError: If the file cannot be written.
228254
"""
229-
data = to_dict(report)
230-
Path(path).write_text(json.dumps(data, indent=2), encoding="utf-8")
255+
ext = os.path.splitext(path)[1].lower()
256+
fmt = _FORMAT_BY_EXT.get(ext, "text")
257+
if fmt == "svg":
258+
console.save_svg(path, clear=False)
259+
elif fmt == "html":
260+
console.save_html(path, clear=False)
261+
else:
262+
console.save_text(path, clear=False)

tests/test_init.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88

99

1010
def test_version_fallback_when_metadata_unavailable() -> None:
11-
"""Cover lines 9-10: the except branch when importlib.metadata.version raises."""
11+
"""Cover the except branch when importlib.metadata.version raises PackageNotFoundError."""
12+
from importlib.metadata import PackageNotFoundError
13+
1214
saved = sys.modules.pop("subdomainenum", None)
1315
try:
14-
with patch("importlib.metadata.version", side_effect=Exception("pkg not found")):
16+
with patch("importlib.metadata.version", side_effect=PackageNotFoundError("subdomainenum")):
1517
fresh = importlib.import_module("subdomainenum")
1618
assert fresh.__version__ == "0.14.0"
1719
finally:

tests/test_reporter.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -225,9 +225,20 @@ def test_sources_timed_out_flag_rendered(self) -> None:
225225

226226

227227
class TestSaveReport:
228-
def test_save_report_writes_json(self, tmp_path) -> None:
229-
out = tmp_path / "report.json"
230-
save_report(_make_report(), out)
228+
def test_save_report_writes_plain_text(self, tmp_path) -> None:
229+
from subdomainenum.reporter import console
230+
231+
out = tmp_path / "report.txt"
232+
# Render into the module-level console so there is recorded content to save.
233+
print_report(_make_report(), console=console)
234+
save_report(str(out))
235+
assert out.exists()
236+
assert "example.com" in out.read_text()
237+
238+
def test_save_report_writes_text_for_unknown_ext(self, tmp_path) -> None:
239+
from subdomainenum.reporter import console
240+
241+
out = tmp_path / "report.log"
242+
print_report(_make_report(), console=console)
243+
save_report(str(out))
231244
assert out.exists()
232-
data = json.loads(out.read_text())
233-
assert data["domain"] == "example.com"

0 commit comments

Comments
 (0)