reconax 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
reconax/__init__.py ADDED
@@ -0,0 +1,98 @@
1
+ """ReconAx - lightweight, developer-friendly website analyzer."""
2
+
3
+ from .core import ReconAx
4
+ from .models import (
5
+ ReconReport,
6
+ HTTPResponse,
7
+ HeaderAnalysis,
8
+ CookieInfo,
9
+ CookieAnalysis,
10
+ HTMLAnalysis,
11
+ RobotsAnalysis,
12
+ DNSAnalysis,
13
+ TLSAnalysis,
14
+ Technology,
15
+ TechAnalysis,
16
+ SitemapAnalysis,
17
+ CORSAnalysis,
18
+ CSPAnalysis,
19
+ SRIAnalysis,
20
+ SecurityTxtAnalysis,
21
+ MetadataAnalysis,
22
+ ResourceInfo,
23
+ ResourceAnalysis,
24
+ EndpointInfo,
25
+ EndpointAnalysis,
26
+ AttackSurfaceAnalysis,
27
+ ScoreItem,
28
+ ScoreReport,
29
+ )
30
+ from .shortcuts import (
31
+ analyze,
32
+ headers,
33
+ cookies,
34
+ html,
35
+ robots,
36
+ dns,
37
+ tls,
38
+ tech,
39
+ sitemap,
40
+ cors,
41
+ csp,
42
+ sri,
43
+ security_txt,
44
+ metadata,
45
+ resources,
46
+ endpoints,
47
+ attack_surface,
48
+ score,
49
+ )
50
+
51
+ __version__ = "0.2.0"
52
+
53
+ __all__ = [
54
+ "ReconAx",
55
+ "ReconReport",
56
+ "HTTPResponse",
57
+ "HeaderAnalysis",
58
+ "CookieInfo",
59
+ "CookieAnalysis",
60
+ "HTMLAnalysis",
61
+ "RobotsAnalysis",
62
+ "DNSAnalysis",
63
+ "TLSAnalysis",
64
+ "Technology",
65
+ "TechAnalysis",
66
+ "SitemapAnalysis",
67
+ "CORSAnalysis",
68
+ "CSPAnalysis",
69
+ "SRIAnalysis",
70
+ "SecurityTxtAnalysis",
71
+ "MetadataAnalysis",
72
+ "ResourceInfo",
73
+ "ResourceAnalysis",
74
+ "EndpointInfo",
75
+ "EndpointAnalysis",
76
+ "AttackSurfaceAnalysis",
77
+ "ScoreItem",
78
+ "ScoreReport",
79
+ "analyze",
80
+ "headers",
81
+ "cookies",
82
+ "html",
83
+ "robots",
84
+ "dns",
85
+ "tls",
86
+ "tech",
87
+ "sitemap",
88
+ "cors",
89
+ "csp",
90
+ "sri",
91
+ "security_txt",
92
+ "metadata",
93
+ "resources",
94
+ "endpoints",
95
+ "attack_surface",
96
+ "score",
97
+ "__version__",
98
+ ]
reconax/cli.py ADDED
@@ -0,0 +1,213 @@
1
+ """Command-line interface for ReconAx."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from pathlib import Path
8
+ from typing import Any, Callable
9
+
10
+ # Ensure UTF-8 encoding on Windows to prevent charmap errors with Rich Unicode
11
+ if sys.stdout.encoding != "UTF-8":
12
+ sys.stdout.reconfigure(encoding="utf-8")
13
+ if sys.stderr.encoding != "UTF-8":
14
+ sys.stderr.reconfigure(encoding="utf-8")
15
+
16
+ import typer
17
+
18
+ from .core import ReconAx
19
+ from .formatters.json_out import report_to_json, result_to_json
20
+ from .formatters.rich_out import render_report, render_result
21
+
22
+ app = typer.Typer(
23
+ name="reconax",
24
+ help="⚡ ReconAx - lightweight, developer-friendly website analyzer.",
25
+ no_args_is_help=True,
26
+ add_completion=True,
27
+ )
28
+
29
+
30
+ def _write_output(content: str, output_file: str | None) -> None:
31
+ if output_file:
32
+ Path(output_file).write_text(content, encoding="utf-8")
33
+ typer.echo(f"Report written to {output_file}")
34
+ else:
35
+ typer.echo(content)
36
+
37
+
38
+ def _run_module(
39
+ url: str,
40
+ method: str,
41
+ *,
42
+ as_json: bool = False,
43
+ explain: bool = False,
44
+ output_file: str | None = None,
45
+ timeout: float = 10.0,
46
+ ) -> None:
47
+ recon = ReconAx(url, timeout=timeout)
48
+ try:
49
+ result = getattr(recon, method)()
50
+ if as_json:
51
+ _write_output(result_to_json(result), output_file)
52
+ elif output_file:
53
+ raise typer.BadParameter("Use --json with -o/--output.")
54
+ else:
55
+ render_result(result, explain=explain)
56
+ except ValueError as exc:
57
+ typer.echo(f"Error: {exc}", err=True)
58
+ raise typer.Exit(code=1)
59
+ except typer.Exit:
60
+ raise
61
+ except Exception as exc:
62
+ typer.echo(f"ReconAx error: {exc}", err=True)
63
+ raise typer.Exit(code=1)
64
+ finally:
65
+ recon.close()
66
+
67
+
68
+ def _module_command(
69
+ url: str,
70
+ method: str,
71
+ explain: bool,
72
+ json_output: bool,
73
+ timeout: float,
74
+ ) -> None:
75
+ _run_module(
76
+ url,
77
+ method,
78
+ explain=explain,
79
+ as_json=json_output,
80
+ timeout=timeout,
81
+ )
82
+
83
+
84
+ @app.command("analyze")
85
+ def analyze_command(
86
+ url: str = typer.Argument(..., help="Target HTTP/HTTPS URL."),
87
+ json_output: bool = typer.Option(False, "--json", help="Output JSON."),
88
+ explain: bool = typer.Option(False, "--explain", help="Show explanations."),
89
+ no_dns: bool = typer.Option(False, "--no-dns", help="Skip DNS analysis."),
90
+ timeout: float = typer.Option(10.0, "--timeout", min=0.1),
91
+ output: str | None = typer.Option(None, "-o", "--output", help="Write JSON to a file."),
92
+ ) -> None:
93
+ recon = ReconAx(url, timeout=timeout)
94
+ try:
95
+ report = recon.analyze(include_dns=not no_dns)
96
+ if json_output:
97
+ _write_output(report_to_json(report), output)
98
+ elif output:
99
+ raise typer.BadParameter("Use --json with -o/--output.")
100
+ else:
101
+ render_report(report, explain=explain)
102
+ except ValueError as exc:
103
+ typer.echo(f"Error: {exc}", err=True)
104
+ raise typer.Exit(code=1)
105
+ except typer.Exit:
106
+ raise
107
+ except Exception as exc:
108
+ typer.echo(f"ReconAx error: {exc}", err=True)
109
+ raise typer.Exit(code=1)
110
+ finally:
111
+ recon.close()
112
+
113
+
114
+ def _standard_options():
115
+ return
116
+
117
+
118
+ @app.command("headers")
119
+ def headers_command(
120
+ url: str = typer.Argument(...),
121
+ explain: bool = typer.Option(False, "--explain"),
122
+ json_output: bool = typer.Option(False, "--json"),
123
+ timeout: float = typer.Option(10.0, "--timeout", min=0.1),
124
+ ) -> None:
125
+ _module_command(url, "headers", explain, json_output, timeout)
126
+
127
+
128
+ @app.command("cookies")
129
+ def cookies_command(url: str = typer.Argument(...), explain: bool = typer.Option(False, "--explain"), json_output: bool = typer.Option(False, "--json"), timeout: float = typer.Option(10.0, "--timeout", min=0.1)) -> None:
130
+ _module_command(url, "cookies", explain, json_output, timeout)
131
+
132
+
133
+ @app.command("html")
134
+ def html_command(url: str = typer.Argument(...), explain: bool = typer.Option(False, "--explain"), json_output: bool = typer.Option(False, "--json"), timeout: float = typer.Option(10.0, "--timeout", min=0.1)) -> None:
135
+ _module_command(url, "html", explain, json_output, timeout)
136
+
137
+
138
+ @app.command("robots")
139
+ def robots_command(url: str = typer.Argument(...), explain: bool = typer.Option(False, "--explain"), json_output: bool = typer.Option(False, "--json"), timeout: float = typer.Option(10.0, "--timeout", min=0.1)) -> None:
140
+ _module_command(url, "robots", explain, json_output, timeout)
141
+
142
+
143
+ @app.command("dns")
144
+ def dns_command(url: str = typer.Argument(...), explain: bool = typer.Option(False, "--explain"), json_output: bool = typer.Option(False, "--json"), timeout: float = typer.Option(10.0, "--timeout", min=0.1)) -> None:
145
+ _module_command(url, "dns", explain, json_output, timeout)
146
+
147
+
148
+ @app.command("tls")
149
+ def tls_command(url: str = typer.Argument(...), explain: bool = typer.Option(False, "--explain"), json_output: bool = typer.Option(False, "--json"), timeout: float = typer.Option(10.0, "--timeout", min=0.1)) -> None:
150
+ _module_command(url, "tls", explain, json_output, timeout)
151
+
152
+
153
+ @app.command("sitemap")
154
+ def sitemap_command(url: str = typer.Argument(...), explain: bool = typer.Option(False, "--explain"), json_output: bool = typer.Option(False, "--json"), timeout: float = typer.Option(10.0, "--timeout", min=0.1)) -> None:
155
+ _module_command(url, "sitemap", explain, json_output, timeout)
156
+
157
+
158
+ @app.command("tech")
159
+ def tech_command(url: str = typer.Argument(...), explain: bool = typer.Option(False, "--explain"), json_output: bool = typer.Option(False, "--json"), timeout: float = typer.Option(10.0, "--timeout", min=0.1)) -> None:
160
+ _module_command(url, "tech", explain, json_output, timeout)
161
+
162
+
163
+ @app.command("cors")
164
+ def cors_command(url: str = typer.Argument(...), explain: bool = typer.Option(False, "--explain"), json_output: bool = typer.Option(False, "--json"), timeout: float = typer.Option(10.0, "--timeout", min=0.1)) -> None:
165
+ _module_command(url, "cors", explain, json_output, timeout)
166
+
167
+
168
+ @app.command("csp")
169
+ def csp_command(url: str = typer.Argument(...), explain: bool = typer.Option(False, "--explain"), json_output: bool = typer.Option(False, "--json"), timeout: float = typer.Option(10.0, "--timeout", min=0.1)) -> None:
170
+ _module_command(url, "csp", explain, json_output, timeout)
171
+
172
+
173
+ @app.command("sri")
174
+ def sri_command(url: str = typer.Argument(...), explain: bool = typer.Option(False, "--explain"), json_output: bool = typer.Option(False, "--json"), timeout: float = typer.Option(10.0, "--timeout", min=0.1)) -> None:
175
+ _module_command(url, "sri", explain, json_output, timeout)
176
+
177
+
178
+ @app.command("security-txt")
179
+ def security_txt_command(url: str = typer.Argument(...), explain: bool = typer.Option(False, "--explain"), json_output: bool = typer.Option(False, "--json"), timeout: float = typer.Option(10.0, "--timeout", min=0.1)) -> None:
180
+ _module_command(url, "security_txt", explain, json_output, timeout)
181
+
182
+
183
+ @app.command("metadata")
184
+ def metadata_command(url: str = typer.Argument(...), explain: bool = typer.Option(False, "--explain"), json_output: bool = typer.Option(False, "--json"), timeout: float = typer.Option(10.0, "--timeout", min=0.1)) -> None:
185
+ _module_command(url, "metadata", explain, json_output, timeout)
186
+
187
+
188
+ @app.command("resources")
189
+ def resources_command(url: str = typer.Argument(...), explain: bool = typer.Option(False, "--explain"), json_output: bool = typer.Option(False, "--json"), timeout: float = typer.Option(10.0, "--timeout", min=0.1)) -> None:
190
+ _module_command(url, "resources", explain, json_output, timeout)
191
+
192
+
193
+ @app.command("endpoints")
194
+ def endpoints_command(url: str = typer.Argument(...), explain: bool = typer.Option(False, "--explain"), json_output: bool = typer.Option(False, "--json"), timeout: float = typer.Option(10.0, "--timeout", min=0.1)) -> None:
195
+ _module_command(url, "endpoints", explain, json_output, timeout)
196
+
197
+
198
+ @app.command("attack-surface")
199
+ def attack_surface_command(url: str = typer.Argument(...), explain: bool = typer.Option(False, "--explain"), json_output: bool = typer.Option(False, "--json"), timeout: float = typer.Option(10.0, "--timeout", min=0.1)) -> None:
200
+ _module_command(url, "attack_surface", explain, json_output, timeout)
201
+
202
+
203
+ @app.command("score")
204
+ def score_command(url: str = typer.Argument(...), explain: bool = typer.Option(False, "--explain"), json_output: bool = typer.Option(False, "--json"), timeout: float = typer.Option(10.0, "--timeout", min=0.1)) -> None:
205
+ _module_command(url, "score", explain, json_output, timeout)
206
+
207
+
208
+ def main() -> None:
209
+ app()
210
+
211
+
212
+ if __name__ == "__main__":
213
+ main()
reconax/context.py ADDED
@@ -0,0 +1,163 @@
1
+ """Shared analysis context for ReconAx."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+ from urllib.parse import urlparse
8
+
9
+ from bs4 import BeautifulSoup
10
+
11
+ from .http_client import HTTPClient
12
+
13
+
14
+ @dataclass
15
+ class AnalysisContext:
16
+ """Shared state and lazy caches for one ReconAx target."""
17
+
18
+ url: str
19
+ timeout: float = 10.0
20
+ verify_ssl: bool = True
21
+
22
+ def __post_init__(self) -> None:
23
+ self._client = HTTPClient(
24
+ timeout=self.timeout,
25
+ verify_ssl=self.verify_ssl,
26
+ )
27
+ self._response: Any = None
28
+ self._soup: BeautifulSoup | None = None
29
+ self._results: dict[str, Any] = {}
30
+
31
+ @property
32
+ def normalized_url(self) -> str:
33
+ return self._client.normalize_url(self.url)
34
+
35
+ @property
36
+ def origin(self) -> str:
37
+ parsed = urlparse(self.url)
38
+ return f"{parsed.scheme}://{parsed.netloc}"
39
+
40
+ def response(self, refresh: bool = False):
41
+ """Return the cached primary HTTP response."""
42
+ if self._response is None or refresh:
43
+ self._response = self._client.fetch(self.normalized_url)
44
+ self._soup = None
45
+ self._results.clear()
46
+ return self._response
47
+
48
+ def html(self) -> BeautifulSoup:
49
+ """Return the lazily parsed primary HTML document."""
50
+ if self._soup is None:
51
+ self._soup = BeautifulSoup(self.response().content or "", "lxml")
52
+ return self._soup
53
+
54
+ @property
55
+ def body(self) -> str:
56
+ return self.response().content or ""
57
+
58
+ @property
59
+ def response_headers(self) -> dict[str, str]:
60
+ return self.response().headers
61
+
62
+ @property
63
+ def final_url(self) -> str:
64
+ return self.response().final_url
65
+
66
+ @property
67
+ def status_code(self) -> int:
68
+ return self.response().status_code
69
+
70
+ def get(self, url: str, **kwargs: Any):
71
+ """Perform a raw GET for secondary public resources."""
72
+ return self._client.raw_get(url, **kwargs)
73
+
74
+ def _cached_module(self, key: str, module_class: type):
75
+ if key not in self._results:
76
+ self._results[key] = module_class(self).analyze()
77
+ return self._results[key]
78
+
79
+ def http_result(self):
80
+ from .modules.http import HTTPModule
81
+ return self._cached_module("http", HTTPModule)
82
+
83
+ def headers_result(self):
84
+ from .modules.headers import HeadersModule
85
+ return self._cached_module("headers", HeadersModule)
86
+
87
+ def cookies_result(self):
88
+ from .modules.cookies import CookiesModule
89
+ return self._cached_module("cookies", CookiesModule)
90
+
91
+ def html_result(self):
92
+ from .modules.html import HTMLModule
93
+ return self._cached_module("html", HTMLModule)
94
+
95
+ def robots_result(self):
96
+ from .modules.robots import RobotsModule
97
+ return self._cached_module("robots", RobotsModule)
98
+
99
+ def dns_result(self):
100
+ from .modules.dns import DNSModule
101
+ return self._cached_module("dns", DNSModule)
102
+
103
+ def tls_result(self):
104
+ from .modules.tls import TLSModule
105
+ return self._cached_module("tls", TLSModule)
106
+
107
+ def tech_result(self):
108
+ from .modules.tech import TechModule
109
+ return self._cached_module("tech", TechModule)
110
+
111
+ def sitemap_result(self):
112
+ from .modules.sitemap import SitemapModule
113
+ return self._cached_module("sitemap", SitemapModule)
114
+
115
+ def cors_result(self):
116
+ from .modules.cors import CORSModule
117
+ return self._cached_module("cors", CORSModule)
118
+
119
+ def csp_result(self):
120
+ from .modules.csp import CSPModule
121
+ return self._cached_module("csp", CSPModule)
122
+
123
+ def sri_result(self):
124
+ from .modules.sri import SRIModule
125
+ return self._cached_module("sri", SRIModule)
126
+
127
+ def security_txt_result(self):
128
+ from .modules.security_txt import SecurityTxtModule
129
+ return self._cached_module("security_txt", SecurityTxtModule)
130
+
131
+ def metadata_result(self):
132
+ from .modules.metadata import MetadataModule
133
+ return self._cached_module("metadata", MetadataModule)
134
+
135
+ def resources_result(self):
136
+ from .modules.resources import ResourcesModule
137
+ return self._cached_module("resources", ResourcesModule)
138
+
139
+ def endpoints_result(self):
140
+ from .modules.endpoints import EndpointsModule
141
+ return self._cached_module("endpoints", EndpointsModule)
142
+
143
+ def attack_surface_result(self):
144
+ from .modules.attack_surface import AttackSurfaceModule
145
+ return self._cached_module("attack_surface", AttackSurfaceModule)
146
+
147
+ def score_result(self):
148
+ from .modules.score import ScoreModule
149
+ return self._cached_module("score", ScoreModule)
150
+
151
+ def clear_cache(self) -> None:
152
+ self._response = None
153
+ self._soup = None
154
+ self._results.clear()
155
+
156
+ def close(self) -> None:
157
+ self._client.close()
158
+
159
+ def __enter__(self) -> "AnalysisContext":
160
+ return self
161
+
162
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
163
+ self.close()
reconax/core.py ADDED
@@ -0,0 +1,120 @@
1
+ """High-level ReconAx API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .context import AnalysisContext
6
+ from .models import ReconReport
7
+
8
+
9
+ class ReconAx:
10
+ """Main public interface for ReconAx analysis."""
11
+
12
+ def __init__(
13
+ self,
14
+ url: str,
15
+ *,
16
+ timeout: float = 10.0,
17
+ verify_ssl: bool = True,
18
+ ) -> None:
19
+ self.context = AnalysisContext(
20
+ url=url,
21
+ timeout=timeout,
22
+ verify_ssl=verify_ssl,
23
+ )
24
+
25
+ @property
26
+ def url(self) -> str:
27
+ return self.context.url
28
+
29
+ @property
30
+ def normalized_url(self) -> str:
31
+ return self.context.normalized_url
32
+
33
+ def http(self):
34
+ return self.context.http_result()
35
+
36
+ def headers(self):
37
+ return self.context.headers_result()
38
+
39
+ def cookies(self):
40
+ return self.context.cookies_result()
41
+
42
+ def html(self):
43
+ return self.context.html_result()
44
+
45
+ def robots(self):
46
+ return self.context.robots_result()
47
+
48
+ def dns(self):
49
+ return self.context.dns_result()
50
+
51
+ def tls(self):
52
+ return self.context.tls_result()
53
+
54
+ def tech(self):
55
+ return self.context.tech_result()
56
+
57
+ def sitemap(self):
58
+ return self.context.sitemap_result()
59
+
60
+ def cors(self):
61
+ return self.context.cors_result()
62
+
63
+ def csp(self):
64
+ return self.context.csp_result()
65
+
66
+ def sri(self):
67
+ return self.context.sri_result()
68
+
69
+ def security_txt(self):
70
+ return self.context.security_txt_result()
71
+
72
+ def metadata(self):
73
+ return self.context.metadata_result()
74
+
75
+ def resources(self):
76
+ return self.context.resources_result()
77
+
78
+ def endpoints(self):
79
+ return self.context.endpoints_result()
80
+
81
+ def attack_surface(self):
82
+ return self.context.attack_surface_result()
83
+
84
+ def score(self):
85
+ return self.context.score_result()
86
+
87
+ def analyze(self, *, include_dns: bool = True) -> ReconReport:
88
+ """Run the complete passive analysis using shared module caches."""
89
+ report = ReconReport(
90
+ target=self.url,
91
+ normalized_url=self.normalized_url,
92
+ http=self.http(),
93
+ headers=self.headers(),
94
+ cookies=self.cookies(),
95
+ html=self.html(),
96
+ robots=self.robots(),
97
+ dns=self.dns() if include_dns else None,
98
+ tls=self.tls(),
99
+ tech=self.tech(),
100
+ sitemap=self.sitemap(),
101
+ cors=self.cors(),
102
+ csp=self.csp(),
103
+ sri=self.sri(),
104
+ security_txt=self.security_txt(),
105
+ metadata=self.metadata(),
106
+ resources=self.resources(),
107
+ endpoints=self.endpoints(),
108
+ attack_surface=self.attack_surface(),
109
+ score=self.score(),
110
+ )
111
+ return report
112
+
113
+ def close(self) -> None:
114
+ self.context.close()
115
+
116
+ def __enter__(self) -> "ReconAx":
117
+ return self
118
+
119
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
120
+ self.close()
@@ -0,0 +1,27 @@
1
+ """
2
+ Output formatters for ReconAx.
3
+
4
+ Formatters convert structured ReconAx results into human-friendly
5
+ terminal output or machine-readable JSON.
6
+ """
7
+
8
+ from .json_out import (
9
+ report_to_dict,
10
+ report_to_json,
11
+ result_to_dict,
12
+ result_to_json,
13
+ )
14
+
15
+ from .rich_out import (
16
+ render_report,
17
+ render_result,
18
+ )
19
+
20
+ __all__ = [
21
+ "report_to_dict",
22
+ "report_to_json",
23
+ "result_to_dict",
24
+ "result_to_json",
25
+ "render_report",
26
+ "render_result",
27
+ ]
@@ -0,0 +1,69 @@
1
+ """JSON output formatter for ReconAx."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict, is_dataclass
7
+ from typing import Any
8
+
9
+
10
+ def _serialize(value: Any) -> Any:
11
+ """Recursively convert ReconAx objects into JSON-compatible values."""
12
+ if hasattr(value, "to_dict") and callable(value.to_dict):
13
+ return _serialize(value.to_dict())
14
+
15
+ if is_dataclass(value):
16
+ return {
17
+ key: _serialize(item)
18
+ for key, item in asdict(value).items()
19
+ }
20
+
21
+ if isinstance(value, dict):
22
+ return {str(key): _serialize(item) for key, item in value.items()}
23
+
24
+ if isinstance(value, (list, tuple, set)):
25
+ return [_serialize(item) for item in value]
26
+
27
+ if hasattr(value, "value"):
28
+ try:
29
+ return value.value
30
+ except Exception:
31
+ pass
32
+
33
+ return value
34
+
35
+
36
+ def result_to_dict(result: Any) -> dict[str, Any]:
37
+ serialized = _serialize(result)
38
+ return serialized if isinstance(serialized, dict) else {"result": serialized}
39
+
40
+
41
+ def result_to_json(result: Any, *, indent: int = 2) -> str:
42
+ return json.dumps(
43
+ result_to_dict(result),
44
+ indent=indent,
45
+ ensure_ascii=False,
46
+ sort_keys=False,
47
+ default=str,
48
+ )
49
+
50
+
51
+ def report_to_dict(report: Any) -> dict[str, Any]:
52
+ return result_to_dict(report)
53
+
54
+
55
+ def report_to_json(report: Any, *, indent: int = 2) -> str:
56
+ return json.dumps(
57
+ report_to_dict(report),
58
+ indent=indent,
59
+ ensure_ascii=False,
60
+ sort_keys=False,
61
+ default=str,
62
+ )
63
+
64
+
65
+ def write_json(data: Any, filename: str, *, indent: int = 2) -> None:
66
+ payload = _serialize(data)
67
+ with open(filename, "w", encoding="utf-8") as file:
68
+ json.dump(payload, file, indent=indent, ensure_ascii=False, default=str)
69
+ file.write("\n")