nat-engine 1__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.
- mannf/__init__.py +33 -0
- mannf/__main__.py +10 -0
- mannf/_version.py +8 -0
- mannf/agents/__init__.py +7 -0
- mannf/agents/analyzer_agent.py +9 -0
- mannf/agents/base.py +9 -0
- mannf/agents/bdi_agent.py +9 -0
- mannf/agents/belief_state.py +9 -0
- mannf/agents/coordinator_agent.py +9 -0
- mannf/agents/executor_agent.py +9 -0
- mannf/agents/monitor_agent.py +9 -0
- mannf/agents/oracle_agent.py +9 -0
- mannf/agents/planner_agent.py +9 -0
- mannf/agents/test_agent.py +9 -0
- mannf/anomaly/__init__.py +7 -0
- mannf/anomaly/enhanced_detector.py +9 -0
- mannf/cli.py +9 -0
- mannf/core/__init__.py +26 -0
- mannf/core/agents/__init__.py +52 -0
- mannf/core/agents/accessibility_scanner_agent.py +245 -0
- mannf/core/agents/analyzer_agent.py +224 -0
- mannf/core/agents/autonomous_loop_agent.py +1086 -0
- mannf/core/agents/autonomous_loop_models.py +62 -0
- mannf/core/agents/autonomous_run_differ.py +427 -0
- mannf/core/agents/base.py +128 -0
- mannf/core/agents/bdi_agent.py +330 -0
- mannf/core/agents/belief_state.py +202 -0
- mannf/core/agents/browser_coordinator_agent.py +224 -0
- mannf/core/agents/browser_executor_agent.py +410 -0
- mannf/core/agents/coordinator_agent.py +262 -0
- mannf/core/agents/executor_agent.py +222 -0
- mannf/core/agents/monitor_agent.py +188 -0
- mannf/core/agents/oracle_agent.py +150 -0
- mannf/core/agents/performance_testing_agent.py +279 -0
- mannf/core/agents/planner_agent.py +128 -0
- mannf/core/agents/test_agent.py +249 -0
- mannf/core/agents/visual_regression_agent.py +311 -0
- mannf/core/agents/web_crawler_agent.py +510 -0
- mannf/core/agents/worker_pool.py +366 -0
- mannf/core/anomaly/__init__.py +14 -0
- mannf/core/anomaly/enhanced_detector.py +541 -0
- mannf/core/browser/__init__.py +63 -0
- mannf/core/browser/accessibility_scanner.py +424 -0
- mannf/core/browser/discovery_model.py +178 -0
- mannf/core/browser/dom_snapshot.py +349 -0
- mannf/core/browser/ingestor_bridge.py +371 -0
- mannf/core/browser/performance_metrics.py +217 -0
- mannf/core/browser/reflection_analyzer.py +442 -0
- mannf/core/browser/scenario_generator.py +1100 -0
- mannf/core/browser/security_scenario_generator.py +695 -0
- mannf/core/browser/visual_comparer.py +159 -0
- mannf/core/diagnostics/__init__.py +28 -0
- mannf/core/diagnostics/failure_clusterer.py +211 -0
- mannf/core/diagnostics/flake_detector.py +233 -0
- mannf/core/diagnostics/root_cause_analyzer.py +273 -0
- mannf/core/distributed/__init__.py +16 -0
- mannf/core/distributed/endpoint.py +139 -0
- mannf/core/distributed/system_under_test.py +207 -0
- mannf/core/functional_orchestrator.py +428 -0
- mannf/core/messaging/__init__.py +11 -0
- mannf/core/messaging/bus.py +113 -0
- mannf/core/messaging/messages.py +89 -0
- mannf/core/nat_orchestrator.py +342 -0
- mannf/core/neural/__init__.py +183 -0
- mannf/core/orchestrator.py +272 -0
- mannf/core/prioritization/__init__.py +17 -0
- mannf/core/prioritization/adaptive_controller.py +509 -0
- mannf/core/prioritization/belief_prioritizer.py +231 -0
- mannf/core/prioritization/risk_scorer.py +430 -0
- mannf/core/reporting/__init__.py +12 -0
- mannf/core/reporting/unified_report.py +664 -0
- mannf/core/testing/__init__.py +17 -0
- mannf/core/testing/adaptive_controller.py +149 -0
- mannf/core/testing/models.py +179 -0
- mannf/core/validation/__init__.py +10 -0
- mannf/core/validation/self_validation_runner.py +180 -0
- mannf/dashboard/__init__.py +7 -0
- mannf/dashboard/app.py +9 -0
- mannf/dashboard/models.py +9 -0
- mannf/dashboard/static/index.html +2538 -0
- mannf/dashboard/telemetry.py +9 -0
- mannf/distributed/__init__.py +7 -0
- mannf/distributed/endpoint.py +9 -0
- mannf/distributed/system_under_test.py +9 -0
- mannf/healing/__init__.py +7 -0
- mannf/healing/graphql_schema_diff.py +9 -0
- mannf/healing/healer.py +9 -0
- mannf/healing/models.py +9 -0
- mannf/healing/schema_diff.py +9 -0
- mannf/integrations/__init__.py +7 -0
- mannf/integrations/auth.py +9 -0
- mannf/integrations/graphql_parser.py +9 -0
- mannf/integrations/graphql_sut.py +9 -0
- mannf/integrations/http_sut.py +9 -0
- mannf/integrations/openapi_parser.py +9 -0
- mannf/integrations/postman_parser.py +9 -0
- mannf/llm/__init__.py +7 -0
- mannf/llm/anthropic_provider.py +9 -0
- mannf/llm/base.py +9 -0
- mannf/llm/config.py +9 -0
- mannf/llm/factory.py +9 -0
- mannf/llm/openai_provider.py +9 -0
- mannf/llm/prompts.py +9 -0
- mannf/messaging/__init__.py +7 -0
- mannf/messaging/bus.py +9 -0
- mannf/messaging/messages.py +9 -0
- mannf/nat_orchestrator.py +9 -0
- mannf/neural/__init__.py +7 -0
- mannf/orchestrator.py +9 -0
- mannf/prioritization/__init__.py +7 -0
- mannf/prioritization/adaptive_controller.py +9 -0
- mannf/prioritization/belief_prioritizer.py +9 -0
- mannf/prioritization/risk_scorer.py +9 -0
- mannf/product/__init__.py +29 -0
- mannf/product/admin/__init__.py +3 -0
- mannf/product/admin/routes.py +514 -0
- mannf/product/auth/__init__.py +5 -0
- mannf/product/auth/saml.py +212 -0
- mannf/product/billing/__init__.py +5 -0
- mannf/product/billing/audit.py +160 -0
- mannf/product/billing/feature_gates.py +180 -0
- mannf/product/billing/metering.py +179 -0
- mannf/product/billing/notifications.py +181 -0
- mannf/product/billing/plans.py +133 -0
- mannf/product/billing/rate_limits.py +35 -0
- mannf/product/billing/stripe_billing.py +906 -0
- mannf/product/billing/tenant_auth.py +233 -0
- mannf/product/billing/tenant_manager.py +873 -0
- mannf/product/cli.py +3900 -0
- mannf/product/cli_admin.py +408 -0
- mannf/product/dashboard/__init__.py +61 -0
- mannf/product/dashboard/app.py +3567 -0
- mannf/product/dashboard/models.py +460 -0
- mannf/product/dashboard/static/index.html +6347 -0
- mannf/product/dashboard/static/manifest.json +25 -0
- mannf/product/dashboard/static/pwa-icon-192.png +0 -0
- mannf/product/dashboard/static/pwa-icon-512.png +0 -0
- mannf/product/dashboard/static/sw.js +64 -0
- mannf/product/dashboard/telemetry.py +547 -0
- mannf/product/database.py +145 -0
- mannf/product/demo.py +844 -0
- mannf/product/doctor.py +509 -0
- mannf/product/exporters/__init__.py +65 -0
- mannf/product/exporters/azuredevops_exporter.py +257 -0
- mannf/product/exporters/base.py +307 -0
- mannf/product/exporters/bugzilla_exporter.py +200 -0
- mannf/product/exporters/dedup.py +275 -0
- mannf/product/exporters/finding_adapter.py +216 -0
- mannf/product/exporters/github_exporter.py +197 -0
- mannf/product/exporters/gitlab_exporter.py +215 -0
- mannf/product/exporters/jira_exporter.py +180 -0
- mannf/product/exporters/linear_exporter.py +195 -0
- mannf/product/exporters/loader.py +233 -0
- mannf/product/exporters/pagerduty_exporter.py +363 -0
- mannf/product/exporters/sentry_exporter.py +322 -0
- mannf/product/exporters/servicenow_exporter.py +240 -0
- mannf/product/exporters/shortcut_exporter.py +231 -0
- mannf/product/exporters/webhook_exporter.py +383 -0
- mannf/product/formatters/__init__.py +18 -0
- mannf/product/formatters/allure_formatter.py +161 -0
- mannf/product/formatters/ctrf_formatter.py +149 -0
- mannf/product/healing/__init__.py +30 -0
- mannf/product/healing/graphql_schema_diff.py +152 -0
- mannf/product/healing/healer.py +141 -0
- mannf/product/healing/models.py +175 -0
- mannf/product/healing/schema_diff.py +251 -0
- mannf/product/ingestors/__init__.py +77 -0
- mannf/product/ingestors/base.py +256 -0
- mannf/product/ingestors/bgstm_ingestor.py +764 -0
- mannf/product/ingestors/curl_ingestor.py +1019 -0
- mannf/product/ingestors/cypress_ingestor.py +487 -0
- mannf/product/ingestors/gherkin_ingestor.py +967 -0
- mannf/product/ingestors/graphql_ingestor.py +845 -0
- mannf/product/ingestors/grpc_ingestor.py +591 -0
- mannf/product/ingestors/har_ingestor.py +976 -0
- mannf/product/ingestors/loader.py +284 -0
- mannf/product/ingestors/models.py +146 -0
- mannf/product/ingestors/openapi_ingestor.py +606 -0
- mannf/product/ingestors/playwright_ingestor.py +449 -0
- mannf/product/ingestors/postman_ingestor.py +631 -0
- mannf/product/ingestors/traffic_ingestor.py +679 -0
- mannf/product/ingestors/websocket_ingestor.py +526 -0
- mannf/product/integrations/__init__.py +21 -0
- mannf/product/integrations/auth.py +190 -0
- mannf/product/integrations/graphql_parser.py +436 -0
- mannf/product/integrations/graphql_sut.py +247 -0
- mannf/product/integrations/grpc_sut.py +469 -0
- mannf/product/integrations/http_sut.py +237 -0
- mannf/product/integrations/kafka_adapter.py +342 -0
- mannf/product/integrations/openapi_parser.py +513 -0
- mannf/product/integrations/postman_parser.py +467 -0
- mannf/product/integrations/webhook_receiver.py +344 -0
- mannf/product/integrations/websocket_sut.py +434 -0
- mannf/product/llm/__init__.py +25 -0
- mannf/product/llm/anthropic_provider.py +94 -0
- mannf/product/llm/base.py +267 -0
- mannf/product/llm/config.py +48 -0
- mannf/product/llm/factory.py +42 -0
- mannf/product/llm/openai_provider.py +93 -0
- mannf/product/llm/prompts.py +403 -0
- mannf/product/llm/root_cause_service.py +311 -0
- mannf/product/llm/test_plan_models.py +78 -0
- mannf/product/metrics.py +149 -0
- mannf/product/middleware/__init__.py +3 -0
- mannf/product/middleware/audit_middleware.py +112 -0
- mannf/product/middleware/tenant_isolation.py +114 -0
- mannf/product/models.py +347 -0
- mannf/product/notifications/__init__.py +24 -0
- mannf/product/notifications/dispatcher.py +411 -0
- mannf/product/onboarding.py +190 -0
- mannf/product/orchestration/__init__.py +39 -0
- mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
- mannf/product/orchestration/pipeline.py +401 -0
- mannf/product/orchestrator.py +987 -0
- mannf/product/orchestrator_models.py +269 -0
- mannf/product/regression/__init__.py +36 -0
- mannf/product/regression/differ.py +172 -0
- mannf/product/regression/masking.py +100 -0
- mannf/product/regression/models.py +232 -0
- mannf/product/regression/recorder.py +124 -0
- mannf/product/regression/replayer.py +168 -0
- mannf/product/reports/__init__.py +10 -0
- mannf/product/reports/pdf.py +132 -0
- mannf/product/scheduling/__init__.py +57 -0
- mannf/product/scheduling/cron_utils.py +251 -0
- mannf/product/scheduling/engine.py +473 -0
- mannf/product/scheduling/models.py +86 -0
- mannf/product/scheduling/queue.py +894 -0
- mannf/product/scheduling/store.py +235 -0
- mannf/product/security/__init__.py +21 -0
- mannf/product/security/belief_guided.py +143 -0
- mannf/product/security/checks/__init__.py +55 -0
- mannf/product/security/checks/base.py +69 -0
- mannf/product/security/checks/bfla.py +77 -0
- mannf/product/security/checks/bola.py +77 -0
- mannf/product/security/checks/bopla.py +80 -0
- mannf/product/security/checks/broken_auth.py +86 -0
- mannf/product/security/checks/graphql_security.py +299 -0
- mannf/product/security/checks/inventory.py +70 -0
- mannf/product/security/checks/misconfig.py +158 -0
- mannf/product/security/checks/resource_consumption.py +70 -0
- mannf/product/security/checks/sensitive_flows.py +80 -0
- mannf/product/security/checks/ssrf.py +101 -0
- mannf/product/security/checks/unsafe_consumption.py +120 -0
- mannf/product/security/models.py +92 -0
- mannf/product/security/plugin_loader.py +182 -0
- mannf/product/security/reporter.py +92 -0
- mannf/product/security/scanner.py +183 -0
- mannf/product/server.py +6220 -0
- mannf/product/setup_wizard.py +873 -0
- mannf/product/status.py +404 -0
- mannf/product/storage/__init__.py +10 -0
- mannf/product/storage/artifact_store.py +343 -0
- mannf/product/telemetry.py +300 -0
- mannf/product/uninstall.py +169 -0
- mannf/product/upgrade.py +139 -0
- mannf/product/weights/__init__.py +13 -0
- mannf/product/weights/blob_store.py +299 -0
- mannf/product/weights/factory.py +42 -0
- mannf/product/weights/registry.py +159 -0
- mannf/product/weights/store.py +210 -0
- mannf/regression/__init__.py +7 -0
- mannf/regression/differ.py +9 -0
- mannf/regression/masking.py +9 -0
- mannf/regression/models.py +9 -0
- mannf/regression/recorder.py +9 -0
- mannf/regression/replayer.py +9 -0
- mannf/security/__init__.py +7 -0
- mannf/security/belief_guided.py +9 -0
- mannf/security/checks/__init__.py +7 -0
- mannf/security/checks/base.py +9 -0
- mannf/security/checks/bfla.py +9 -0
- mannf/security/checks/bola.py +9 -0
- mannf/security/checks/bopla.py +9 -0
- mannf/security/checks/broken_auth.py +9 -0
- mannf/security/checks/graphql_security.py +9 -0
- mannf/security/checks/inventory.py +9 -0
- mannf/security/checks/misconfig.py +9 -0
- mannf/security/checks/resource_consumption.py +9 -0
- mannf/security/checks/sensitive_flows.py +9 -0
- mannf/security/checks/ssrf.py +9 -0
- mannf/security/checks/unsafe_consumption.py +9 -0
- mannf/security/models.py +9 -0
- mannf/security/reporter.py +9 -0
- mannf/security/scanner.py +9 -0
- mannf/server.py +9 -0
- mannf/testing/__init__.py +7 -0
- mannf/testing/adaptive_controller.py +9 -0
- mannf/testing/models.py +9 -0
- mannf/weights/__init__.py +7 -0
- mannf/weights/registry.py +9 -0
- mannf/weights/store.py +9 -0
- nat_engine-1.dist-info/METADATA +555 -0
- nat_engine-1.dist-info/RECORD +299 -0
- nat_engine-1.dist-info/WHEEL +5 -0
- nat_engine-1.dist-info/entry_points.txt +4 -0
- nat_engine-1.dist-info/licenses/LICENSE +651 -0
- nat_engine-1.dist-info/licenses/NOTICE +178 -0
- nat_engine-1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# Copyright (C) 2026 Brad Guider
|
|
2
|
+
# This file is part of NAT (Neural Agent Testing Framework).
|
|
3
|
+
# Licensed under the AGPL-3.0. See LICENSE for details.
|
|
4
|
+
# Commercial licensing available — see COMMERCIAL_LICENSE.md.
|
|
5
|
+
|
|
6
|
+
"""Data models for the regression record/replay system."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class RequestRecord:
|
|
17
|
+
"""Full details of a captured HTTP request."""
|
|
18
|
+
|
|
19
|
+
method: str
|
|
20
|
+
url: str
|
|
21
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
22
|
+
body: Any = None
|
|
23
|
+
query_params: Optional[Dict[str, Any]] = None
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
26
|
+
return {
|
|
27
|
+
"method": self.method,
|
|
28
|
+
"url": self.url,
|
|
29
|
+
"headers": self.headers,
|
|
30
|
+
"body": self.body,
|
|
31
|
+
"query_params": self.query_params,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_dict(cls, data: Dict[str, Any]) -> "RequestRecord":
|
|
36
|
+
return cls(
|
|
37
|
+
method=data["method"],
|
|
38
|
+
url=data["url"],
|
|
39
|
+
headers=data.get("headers") or {},
|
|
40
|
+
body=data.get("body"),
|
|
41
|
+
query_params=data.get("query_params"),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class ResponseRecord:
|
|
47
|
+
"""Full details of a captured HTTP response."""
|
|
48
|
+
|
|
49
|
+
status_code: int
|
|
50
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
51
|
+
body: Any = None
|
|
52
|
+
latency_ms: float = 0.0
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
55
|
+
return {
|
|
56
|
+
"status_code": self.status_code,
|
|
57
|
+
"headers": self.headers,
|
|
58
|
+
"body": self.body,
|
|
59
|
+
"latency_ms": self.latency_ms,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_dict(cls, data: Dict[str, Any]) -> "ResponseRecord":
|
|
64
|
+
return cls(
|
|
65
|
+
status_code=data["status_code"],
|
|
66
|
+
headers=data.get("headers") or {},
|
|
67
|
+
body=data.get("body"),
|
|
68
|
+
latency_ms=data.get("latency_ms", 0.0),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class RegressionRecord:
|
|
74
|
+
"""A single recorded request/response pair linked to a test case."""
|
|
75
|
+
|
|
76
|
+
test_case_id: str
|
|
77
|
+
request: RequestRecord
|
|
78
|
+
response: ResponseRecord
|
|
79
|
+
passed: bool
|
|
80
|
+
error: Optional[str] = None
|
|
81
|
+
|
|
82
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
83
|
+
return {
|
|
84
|
+
"test_case_id": self.test_case_id,
|
|
85
|
+
"request": self.request.to_dict(),
|
|
86
|
+
"response": self.response.to_dict(),
|
|
87
|
+
"passed": self.passed,
|
|
88
|
+
"error": self.error,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_dict(cls, data: Dict[str, Any]) -> "RegressionRecord":
|
|
93
|
+
return cls(
|
|
94
|
+
test_case_id=data["test_case_id"],
|
|
95
|
+
request=RequestRecord.from_dict(data["request"]),
|
|
96
|
+
response=ResponseRecord.from_dict(data["response"]),
|
|
97
|
+
passed=data["passed"],
|
|
98
|
+
error=data.get("error"),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class RegressionBaseline:
|
|
104
|
+
"""A portable regression baseline file containing all recorded request/response pairs.
|
|
105
|
+
|
|
106
|
+
Serialises to/from the standard NAT regression JSON format::
|
|
107
|
+
|
|
108
|
+
{
|
|
109
|
+
"version": "1.0",
|
|
110
|
+
"recorded_at": "2026-03-21T12:00:00Z",
|
|
111
|
+
"spec": "openapi.yaml",
|
|
112
|
+
"base_url": "https://api.example.com",
|
|
113
|
+
"records": [...]
|
|
114
|
+
}
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
version: str = "1.0"
|
|
118
|
+
recorded_at: str = ""
|
|
119
|
+
spec: Optional[str] = None
|
|
120
|
+
base_url: str = ""
|
|
121
|
+
records: List[RegressionRecord] = field(default_factory=list)
|
|
122
|
+
|
|
123
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
124
|
+
return {
|
|
125
|
+
"version": self.version,
|
|
126
|
+
"recorded_at": self.recorded_at,
|
|
127
|
+
"spec": self.spec,
|
|
128
|
+
"base_url": self.base_url,
|
|
129
|
+
"records": [r.to_dict() for r in self.records],
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
def save(self, path: str) -> None:
|
|
133
|
+
"""Serialise the baseline to a JSON file at *path*."""
|
|
134
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
135
|
+
json.dump(self.to_dict(), fh, indent=2)
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def from_dict(cls, data: Dict[str, Any]) -> "RegressionBaseline":
|
|
139
|
+
return cls(
|
|
140
|
+
version=data.get("version", "1.0"),
|
|
141
|
+
recorded_at=data.get("recorded_at", ""),
|
|
142
|
+
spec=data.get("spec"),
|
|
143
|
+
base_url=data.get("base_url", ""),
|
|
144
|
+
records=[RegressionRecord.from_dict(r) for r in data.get("records", [])],
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
def load(cls, path: str) -> "RegressionBaseline":
|
|
149
|
+
"""Load a baseline from a JSON file at *path*."""
|
|
150
|
+
with open(path, encoding="utf-8") as fh:
|
|
151
|
+
data = json.load(fh)
|
|
152
|
+
return cls.from_dict(data)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class FieldChange:
|
|
157
|
+
"""A single changed field between baseline and replay."""
|
|
158
|
+
|
|
159
|
+
field: str
|
|
160
|
+
baseline: Any
|
|
161
|
+
replay: Any
|
|
162
|
+
|
|
163
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
164
|
+
return {
|
|
165
|
+
"field": self.field,
|
|
166
|
+
"baseline": self.baseline,
|
|
167
|
+
"replay": self.replay,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass
|
|
172
|
+
class RecordDiff:
|
|
173
|
+
"""Diff result for a single test case."""
|
|
174
|
+
|
|
175
|
+
test_case_id: str
|
|
176
|
+
endpoint: str
|
|
177
|
+
changes: List[FieldChange] = field(default_factory=list)
|
|
178
|
+
|
|
179
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
180
|
+
return {
|
|
181
|
+
"test_case_id": self.test_case_id,
|
|
182
|
+
"endpoint": self.endpoint,
|
|
183
|
+
"changes": [c.to_dict() for c in self.changes],
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass
|
|
188
|
+
class RegressionDiff:
|
|
189
|
+
"""Full diff report comparing a baseline to a replay run.
|
|
190
|
+
|
|
191
|
+
Serialises to the standard NAT regression diff JSON format::
|
|
192
|
+
|
|
193
|
+
{
|
|
194
|
+
"summary": {"total": 10, "unchanged": 8, "changed": 2, "new": 0, "removed": 0},
|
|
195
|
+
"changes": [...]
|
|
196
|
+
}
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
summary: Dict[str, int] = field(default_factory=dict)
|
|
200
|
+
changes: List[RecordDiff] = field(default_factory=list)
|
|
201
|
+
|
|
202
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
203
|
+
return {
|
|
204
|
+
"summary": self.summary,
|
|
205
|
+
"changes": [c.to_dict() for c in self.changes],
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
def save(self, path: str) -> None:
|
|
209
|
+
"""Write the diff report to a JSON file at *path*."""
|
|
210
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
211
|
+
json.dump(self.to_dict(), fh, indent=2)
|
|
212
|
+
|
|
213
|
+
def human_readable(self) -> str:
|
|
214
|
+
"""Return a human-readable diff summary string."""
|
|
215
|
+
lines = []
|
|
216
|
+
s = self.summary
|
|
217
|
+
lines.append("=" * 60)
|
|
218
|
+
lines.append(" Regression Diff Report")
|
|
219
|
+
lines.append("=" * 60)
|
|
220
|
+
lines.append(f" Total: {s.get('total', 0)}")
|
|
221
|
+
lines.append(f" Unchanged: {s.get('unchanged', 0)}")
|
|
222
|
+
lines.append(f" Changed: {s.get('changed', 0)}")
|
|
223
|
+
lines.append(f" New: {s.get('new', 0)}")
|
|
224
|
+
lines.append(f" Removed: {s.get('removed', 0)}")
|
|
225
|
+
lines.append("=" * 60)
|
|
226
|
+
for diff in self.changes:
|
|
227
|
+
lines.append(f"\n [{diff.test_case_id}] {diff.endpoint}")
|
|
228
|
+
for chg in diff.changes:
|
|
229
|
+
lines.append(f" {chg.field}:")
|
|
230
|
+
lines.append(f" baseline: {chg.baseline!r}")
|
|
231
|
+
lines.append(f" replay: {chg.replay!r}")
|
|
232
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Copyright (C) 2026 Brad Guider
|
|
2
|
+
# This file is part of NAT (Neural Agent Testing Framework).
|
|
3
|
+
# Licensed under the AGPL-3.0. See LICENSE for details.
|
|
4
|
+
# Commercial licensing available — see COMMERCIAL_LICENSE.md.
|
|
5
|
+
|
|
6
|
+
"""RegressionRecorder — hooks into SUT execute methods to capture request/response pairs."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
from mannf.product.regression.masking import mask_sensitive
|
|
14
|
+
from mannf.product.regression.models import (
|
|
15
|
+
RegressionBaseline,
|
|
16
|
+
RegressionRecord,
|
|
17
|
+
RequestRecord,
|
|
18
|
+
ResponseRecord,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RegressionRecorder:
|
|
23
|
+
"""Collects :class:`~mannf.product.regression.models.RegressionRecord` objects during a test run.
|
|
24
|
+
|
|
25
|
+
Pass an instance to :class:`~mannf.product.integrations.http_sut.HttpApiSUT` or
|
|
26
|
+
:class:`~mannf.product.integrations.graphql_sut.GraphQLSUT` via the ``recorder``
|
|
27
|
+
constructor parameter. After the run call :meth:`baseline` to obtain the
|
|
28
|
+
completed :class:`~mannf.product.regression.models.RegressionBaseline`.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
spec:
|
|
33
|
+
Path or identifier of the spec file used in this run.
|
|
34
|
+
base_url:
|
|
35
|
+
Root URL of the API under test.
|
|
36
|
+
mask_sensitive_data:
|
|
37
|
+
When ``True``, auth headers and token values are replaced with
|
|
38
|
+
``***MASKED***`` before being stored.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
spec: Optional[str] = None,
|
|
44
|
+
base_url: str = "",
|
|
45
|
+
mask_sensitive_data: bool = False,
|
|
46
|
+
) -> None:
|
|
47
|
+
self.spec = spec
|
|
48
|
+
self.base_url = base_url
|
|
49
|
+
self.mask_sensitive_data = mask_sensitive_data
|
|
50
|
+
self._records: list[RegressionRecord] = []
|
|
51
|
+
|
|
52
|
+
# ------------------------------------------------------------------
|
|
53
|
+
# Hook called by SUT implementations
|
|
54
|
+
# ------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
def record(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
test_case_id: str,
|
|
60
|
+
method: str,
|
|
61
|
+
url: str,
|
|
62
|
+
request_headers: Dict[str, str],
|
|
63
|
+
request_body: Any,
|
|
64
|
+
query_params: Optional[Dict[str, Any]],
|
|
65
|
+
status_code: int,
|
|
66
|
+
response_headers: Dict[str, str],
|
|
67
|
+
response_body: Any,
|
|
68
|
+
latency_ms: float,
|
|
69
|
+
passed: bool,
|
|
70
|
+
error: Optional[str],
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Append one request/response pair to the in-memory log."""
|
|
73
|
+
if self.mask_sensitive_data:
|
|
74
|
+
request_headers, request_body, query_params = mask_sensitive(
|
|
75
|
+
headers=request_headers,
|
|
76
|
+
body=request_body,
|
|
77
|
+
query_params=query_params,
|
|
78
|
+
)
|
|
79
|
+
response_headers, response_body, _ = mask_sensitive(
|
|
80
|
+
headers=response_headers,
|
|
81
|
+
body=response_body,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
self._records.append(
|
|
85
|
+
RegressionRecord(
|
|
86
|
+
test_case_id=test_case_id,
|
|
87
|
+
request=RequestRecord(
|
|
88
|
+
method=method,
|
|
89
|
+
url=url,
|
|
90
|
+
headers=request_headers,
|
|
91
|
+
body=request_body,
|
|
92
|
+
query_params=query_params,
|
|
93
|
+
),
|
|
94
|
+
response=ResponseRecord(
|
|
95
|
+
status_code=status_code,
|
|
96
|
+
headers=response_headers,
|
|
97
|
+
body=response_body,
|
|
98
|
+
latency_ms=latency_ms,
|
|
99
|
+
),
|
|
100
|
+
passed=passed,
|
|
101
|
+
error=error,
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
# Output
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def baseline(self) -> RegressionBaseline:
|
|
110
|
+
"""Return a :class:`~mannf.product.regression.models.RegressionBaseline` for the current run."""
|
|
111
|
+
return RegressionBaseline(
|
|
112
|
+
version="1.0",
|
|
113
|
+
recorded_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
114
|
+
spec=self.spec,
|
|
115
|
+
base_url=self.base_url,
|
|
116
|
+
records=list(self._records),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def save(self, path: str) -> None:
|
|
120
|
+
"""Write the current baseline to *path* as a JSON file."""
|
|
121
|
+
self.baseline().save(path)
|
|
122
|
+
|
|
123
|
+
def __len__(self) -> int:
|
|
124
|
+
return len(self._records)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# Copyright (C) 2026 Brad Guider
|
|
2
|
+
# This file is part of NAT (Neural Agent Testing Framework).
|
|
3
|
+
# Licensed under the AGPL-3.0. See LICENSE for details.
|
|
4
|
+
# Commercial licensing available — see COMMERCIAL_LICENSE.md.
|
|
5
|
+
|
|
6
|
+
"""RegressionReplayer — re-executes a previously recorded baseline against the live API."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from mannf.product.integrations.auth import AuthStrategy, NoAuth
|
|
17
|
+
from mannf.product.regression.models import (
|
|
18
|
+
RegressionBaseline,
|
|
19
|
+
RegressionRecord,
|
|
20
|
+
RequestRecord,
|
|
21
|
+
ResponseRecord,
|
|
22
|
+
)
|
|
23
|
+
from mannf.product.regression.recorder import RegressionRecorder
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RegressionReplayer:
|
|
29
|
+
"""Re-run all requests from a :class:`~mannf.product.regression.models.RegressionBaseline`.
|
|
30
|
+
|
|
31
|
+
For each record in the baseline the replayer constructs an identical HTTP
|
|
32
|
+
request, fires it at the (potentially changed) API, and stores the new
|
|
33
|
+
response. The resulting :class:`~mannf.product.regression.models.RegressionBaseline`
|
|
34
|
+
can then be compared against the original with
|
|
35
|
+
:class:`~mannf.product.regression.differ.RegressionDiffer`.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
baseline:
|
|
40
|
+
The loaded regression baseline to replay.
|
|
41
|
+
auth:
|
|
42
|
+
Optional :class:`~mannf.product.integrations.auth.AuthStrategy` applied to
|
|
43
|
+
every replayed request (uses :class:`~mannf.product.integrations.auth.NoAuth`
|
|
44
|
+
by default).
|
|
45
|
+
timeout:
|
|
46
|
+
HTTP request timeout in seconds (default: 10).
|
|
47
|
+
mask_sensitive_data:
|
|
48
|
+
When ``True``, mask auth headers / token values in the replay
|
|
49
|
+
recording.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
baseline: RegressionBaseline,
|
|
55
|
+
auth: Optional[AuthStrategy] = None,
|
|
56
|
+
timeout: float = 10.0,
|
|
57
|
+
mask_sensitive_data: bool = False,
|
|
58
|
+
) -> None:
|
|
59
|
+
self.baseline = baseline
|
|
60
|
+
self.auth: AuthStrategy = auth or NoAuth()
|
|
61
|
+
self.timeout = timeout
|
|
62
|
+
self.mask_sensitive_data = mask_sensitive_data
|
|
63
|
+
|
|
64
|
+
async def replay(self) -> RegressionBaseline:
|
|
65
|
+
"""Execute all baseline requests and return a new baseline with replay responses.
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
-------
|
|
69
|
+
RegressionBaseline
|
|
70
|
+
A new baseline whose records contain the *replay* responses.
|
|
71
|
+
``recorded_at`` is set to the current time.
|
|
72
|
+
"""
|
|
73
|
+
recorder = RegressionRecorder(
|
|
74
|
+
spec=self.baseline.spec,
|
|
75
|
+
base_url=self.baseline.base_url,
|
|
76
|
+
mask_sensitive_data=self.mask_sensitive_data,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
for record in self.baseline.records:
|
|
80
|
+
await self._replay_one(record, recorder)
|
|
81
|
+
|
|
82
|
+
return recorder.baseline()
|
|
83
|
+
|
|
84
|
+
async def _replay_one(
|
|
85
|
+
self, record: RegressionRecord, recorder: RegressionRecorder
|
|
86
|
+
) -> None:
|
|
87
|
+
req: RequestRecord = record.request
|
|
88
|
+
request_kwargs: Dict[str, Any] = {
|
|
89
|
+
"method": req.method,
|
|
90
|
+
"url": req.url,
|
|
91
|
+
"headers": dict(req.headers),
|
|
92
|
+
"timeout": self.timeout,
|
|
93
|
+
}
|
|
94
|
+
if req.query_params:
|
|
95
|
+
request_kwargs["params"] = req.query_params
|
|
96
|
+
if req.body is not None:
|
|
97
|
+
if isinstance(req.body, (dict, list)):
|
|
98
|
+
request_kwargs["json"] = req.body
|
|
99
|
+
else:
|
|
100
|
+
request_kwargs["content"] = req.body
|
|
101
|
+
|
|
102
|
+
# Apply auth
|
|
103
|
+
request_kwargs = await self.auth.apply(request_kwargs)
|
|
104
|
+
|
|
105
|
+
start = time.monotonic()
|
|
106
|
+
try:
|
|
107
|
+
async with httpx.AsyncClient() as client:
|
|
108
|
+
response = await client.request(**request_kwargs)
|
|
109
|
+
elapsed_ms = (time.monotonic() - start) * 1000.0
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
body: Any = response.json()
|
|
113
|
+
except Exception:
|
|
114
|
+
body = response.text
|
|
115
|
+
|
|
116
|
+
# Re-use original pass criteria: 2xx means pass
|
|
117
|
+
passed = 200 <= response.status_code < 300
|
|
118
|
+
error: Optional[str] = None if passed else f"Unexpected status {response.status_code}"
|
|
119
|
+
|
|
120
|
+
recorder.record(
|
|
121
|
+
test_case_id=record.test_case_id,
|
|
122
|
+
method=req.method,
|
|
123
|
+
url=req.url,
|
|
124
|
+
request_headers=dict(req.headers),
|
|
125
|
+
request_body=req.body,
|
|
126
|
+
query_params=req.query_params,
|
|
127
|
+
status_code=response.status_code,
|
|
128
|
+
response_headers=dict(response.headers),
|
|
129
|
+
response_body=body,
|
|
130
|
+
latency_ms=elapsed_ms,
|
|
131
|
+
passed=passed,
|
|
132
|
+
error=error,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
except httpx.TimeoutException as exc:
|
|
136
|
+
elapsed_ms = (time.monotonic() - start) * 1000.0
|
|
137
|
+
logger.warning("RegressionReplayer: timeout replaying %s %s — %s", req.method, req.url, exc)
|
|
138
|
+
recorder.record(
|
|
139
|
+
test_case_id=record.test_case_id,
|
|
140
|
+
method=req.method,
|
|
141
|
+
url=req.url,
|
|
142
|
+
request_headers=dict(req.headers),
|
|
143
|
+
request_body=req.body,
|
|
144
|
+
query_params=req.query_params,
|
|
145
|
+
status_code=0,
|
|
146
|
+
response_headers={},
|
|
147
|
+
response_body=None,
|
|
148
|
+
latency_ms=elapsed_ms,
|
|
149
|
+
passed=False,
|
|
150
|
+
error=f"Request timed out: {exc}",
|
|
151
|
+
)
|
|
152
|
+
except httpx.RequestError as exc:
|
|
153
|
+
elapsed_ms = (time.monotonic() - start) * 1000.0
|
|
154
|
+
logger.warning("RegressionReplayer: connection error for %s %s — %s", req.method, req.url, exc)
|
|
155
|
+
recorder.record(
|
|
156
|
+
test_case_id=record.test_case_id,
|
|
157
|
+
method=req.method,
|
|
158
|
+
url=req.url,
|
|
159
|
+
request_headers=dict(req.headers),
|
|
160
|
+
request_body=req.body,
|
|
161
|
+
query_params=req.query_params,
|
|
162
|
+
status_code=0,
|
|
163
|
+
response_headers={},
|
|
164
|
+
response_body=None,
|
|
165
|
+
latency_ms=elapsed_ms,
|
|
166
|
+
passed=False,
|
|
167
|
+
error=f"Connection error: {exc}",
|
|
168
|
+
)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Copyright (C) 2026 Brad Guider
|
|
2
|
+
# This file is part of NAT (Neural Agent Testing Framework).
|
|
3
|
+
# Licensed under the AGPL-3.0. See LICENSE for details.
|
|
4
|
+
# Commercial licensing available — see COMMERCIAL_LICENSE.md.
|
|
5
|
+
|
|
6
|
+
"""NAT report generators — PDF and other exportable formats."""
|
|
7
|
+
|
|
8
|
+
from mannf.product.reports.pdf import PDFReportGenerator
|
|
9
|
+
|
|
10
|
+
__all__ = ["PDFReportGenerator"]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Copyright (C) 2026 Brad Guider
|
|
2
|
+
# This file is part of NAT (Neural Agent Testing Framework).
|
|
3
|
+
# Licensed under the AGPL-3.0. See LICENSE for details.
|
|
4
|
+
# Commercial licensing available — see COMMERCIAL_LICENSE.md.
|
|
5
|
+
|
|
6
|
+
"""PDF report generator for NAT scan exports.
|
|
7
|
+
|
|
8
|
+
Uses Playwright's ``page.pdf()`` to render the existing unified HTML report
|
|
9
|
+
as a PDF document. Playwright is already a declared dependency of the NAT
|
|
10
|
+
framework (used by browser-based test agents), so no additional package is
|
|
11
|
+
required.
|
|
12
|
+
|
|
13
|
+
Enterprise white-label option:
|
|
14
|
+
When *whitelabel=True* is passed to :meth:`PDFReportGenerator.generate`,
|
|
15
|
+
all occurrences of the NAT brand strings are stripped from the HTML before
|
|
16
|
+
rendering, producing a clean, unbranded PDF suitable for enterprise
|
|
17
|
+
stakeholder distribution.
|
|
18
|
+
|
|
19
|
+
Usage::
|
|
20
|
+
|
|
21
|
+
from mannf.product.reports.pdf import PDFReportGenerator
|
|
22
|
+
|
|
23
|
+
generator = PDFReportGenerator()
|
|
24
|
+
pdf_bytes = await generator.generate(html_content)
|
|
25
|
+
|
|
26
|
+
# Enterprise white-label (strip NAT branding):
|
|
27
|
+
pdf_bytes = await generator.generate(html_content, whitelabel=True)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import re
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
# NAT brand strings to strip when producing white-label PDFs.
|
|
36
|
+
_WHITELABEL_REPLACEMENTS: list[tuple[str, str]] = [
|
|
37
|
+
# Footer / title references
|
|
38
|
+
(r"Generated by NAT Framework\s*—\s*Neural Agent Testing\s*—\s*", "Generated "),
|
|
39
|
+
(r"NAT Unified Test Report", "Test Report"),
|
|
40
|
+
(r"NAT Scan Report", "Scan Report"),
|
|
41
|
+
(r"NeuroAgentTest\s*\([^)]*\)", ""),
|
|
42
|
+
(r"Neural Agent Testing Framework", ""),
|
|
43
|
+
(r"NAT Framework", ""),
|
|
44
|
+
# HTML <title> tag content
|
|
45
|
+
(r"(<title>)NAT\s+", r"\1"),
|
|
46
|
+
# Header h1 with "NAT" prefix
|
|
47
|
+
(r"<h1>NAT\s+", "<h1>"),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PDFReportGenerator:
|
|
52
|
+
"""Convert an HTML scan report to a PDF document using Playwright.
|
|
53
|
+
|
|
54
|
+
This class wraps Playwright's ``page.pdf()`` method to produce a
|
|
55
|
+
print-quality PDF from the same self-contained HTML report that is
|
|
56
|
+
served by ``GET /api/v1/scan/{scan_id}/export?format=html``.
|
|
57
|
+
|
|
58
|
+
Enterprise tenants may request white-label output (NAT branding stripped)
|
|
59
|
+
by passing ``whitelabel=True`` to :meth:`generate`.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
RuntimeError: If Playwright is not installed or the browser binary
|
|
63
|
+
has not been installed via ``playwright install``.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
# ------------------------------------------------------------------
|
|
67
|
+
# Public API
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
async def generate(self, html: str, whitelabel: bool = False) -> bytes:
|
|
71
|
+
"""Render *html* as a PDF document and return the raw PDF bytes.
|
|
72
|
+
|
|
73
|
+
Parameters
|
|
74
|
+
----------
|
|
75
|
+
html:
|
|
76
|
+
A self-contained HTML document (no external dependencies) to
|
|
77
|
+
render. Typically the output of
|
|
78
|
+
:func:`~mannf.product.dashboard.app._render_export_html` or
|
|
79
|
+
:meth:`~mannf.core.reporting.unified_report.UnifiedReportGenerator.generate_html`.
|
|
80
|
+
whitelabel:
|
|
81
|
+
When ``True``, NAT brand strings are stripped from the HTML
|
|
82
|
+
before rendering. Intended for Enterprise plan tenants.
|
|
83
|
+
|
|
84
|
+
Returns
|
|
85
|
+
-------
|
|
86
|
+
bytes
|
|
87
|
+
Raw PDF bytes suitable for returning as an HTTP response or
|
|
88
|
+
writing to disk.
|
|
89
|
+
|
|
90
|
+
Raises
|
|
91
|
+
------
|
|
92
|
+
RuntimeError
|
|
93
|
+
If the ``playwright`` package is not importable or the required
|
|
94
|
+
browser binary is missing.
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
from playwright.async_api import async_playwright # noqa: PLC0415
|
|
98
|
+
except ImportError as exc:
|
|
99
|
+
raise RuntimeError(
|
|
100
|
+
"Playwright is required for PDF export. "
|
|
101
|
+
"Install it with: pip install playwright && playwright install chromium"
|
|
102
|
+
) from exc
|
|
103
|
+
|
|
104
|
+
if whitelabel:
|
|
105
|
+
html = self._strip_branding(html)
|
|
106
|
+
|
|
107
|
+
async with async_playwright() as pw:
|
|
108
|
+
browser = await pw.chromium.launch()
|
|
109
|
+
try:
|
|
110
|
+
page = await browser.new_page()
|
|
111
|
+
await page.set_content(html, wait_until="networkidle")
|
|
112
|
+
pdf_bytes: bytes = await page.pdf(
|
|
113
|
+
format="A4",
|
|
114
|
+
print_background=True,
|
|
115
|
+
margin={"top": "20mm", "bottom": "20mm", "left": "15mm", "right": "15mm"},
|
|
116
|
+
)
|
|
117
|
+
finally:
|
|
118
|
+
await browser.close()
|
|
119
|
+
|
|
120
|
+
return pdf_bytes
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
# Internal helpers
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def _strip_branding(html: str) -> str:
|
|
128
|
+
"""Remove NAT brand strings from *html* for white-label output."""
|
|
129
|
+
result = html
|
|
130
|
+
for pattern, replacement in _WHITELABEL_REPLACEMENTS:
|
|
131
|
+
result = re.sub(pattern, replacement, result)
|
|
132
|
+
return result
|