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.
Files changed (299) hide show
  1. mannf/__init__.py +33 -0
  2. mannf/__main__.py +10 -0
  3. mannf/_version.py +8 -0
  4. mannf/agents/__init__.py +7 -0
  5. mannf/agents/analyzer_agent.py +9 -0
  6. mannf/agents/base.py +9 -0
  7. mannf/agents/bdi_agent.py +9 -0
  8. mannf/agents/belief_state.py +9 -0
  9. mannf/agents/coordinator_agent.py +9 -0
  10. mannf/agents/executor_agent.py +9 -0
  11. mannf/agents/monitor_agent.py +9 -0
  12. mannf/agents/oracle_agent.py +9 -0
  13. mannf/agents/planner_agent.py +9 -0
  14. mannf/agents/test_agent.py +9 -0
  15. mannf/anomaly/__init__.py +7 -0
  16. mannf/anomaly/enhanced_detector.py +9 -0
  17. mannf/cli.py +9 -0
  18. mannf/core/__init__.py +26 -0
  19. mannf/core/agents/__init__.py +52 -0
  20. mannf/core/agents/accessibility_scanner_agent.py +245 -0
  21. mannf/core/agents/analyzer_agent.py +224 -0
  22. mannf/core/agents/autonomous_loop_agent.py +1086 -0
  23. mannf/core/agents/autonomous_loop_models.py +62 -0
  24. mannf/core/agents/autonomous_run_differ.py +427 -0
  25. mannf/core/agents/base.py +128 -0
  26. mannf/core/agents/bdi_agent.py +330 -0
  27. mannf/core/agents/belief_state.py +202 -0
  28. mannf/core/agents/browser_coordinator_agent.py +224 -0
  29. mannf/core/agents/browser_executor_agent.py +410 -0
  30. mannf/core/agents/coordinator_agent.py +262 -0
  31. mannf/core/agents/executor_agent.py +222 -0
  32. mannf/core/agents/monitor_agent.py +188 -0
  33. mannf/core/agents/oracle_agent.py +150 -0
  34. mannf/core/agents/performance_testing_agent.py +279 -0
  35. mannf/core/agents/planner_agent.py +128 -0
  36. mannf/core/agents/test_agent.py +249 -0
  37. mannf/core/agents/visual_regression_agent.py +311 -0
  38. mannf/core/agents/web_crawler_agent.py +510 -0
  39. mannf/core/agents/worker_pool.py +366 -0
  40. mannf/core/anomaly/__init__.py +14 -0
  41. mannf/core/anomaly/enhanced_detector.py +541 -0
  42. mannf/core/browser/__init__.py +63 -0
  43. mannf/core/browser/accessibility_scanner.py +424 -0
  44. mannf/core/browser/discovery_model.py +178 -0
  45. mannf/core/browser/dom_snapshot.py +349 -0
  46. mannf/core/browser/ingestor_bridge.py +371 -0
  47. mannf/core/browser/performance_metrics.py +217 -0
  48. mannf/core/browser/reflection_analyzer.py +442 -0
  49. mannf/core/browser/scenario_generator.py +1100 -0
  50. mannf/core/browser/security_scenario_generator.py +695 -0
  51. mannf/core/browser/visual_comparer.py +159 -0
  52. mannf/core/diagnostics/__init__.py +28 -0
  53. mannf/core/diagnostics/failure_clusterer.py +211 -0
  54. mannf/core/diagnostics/flake_detector.py +233 -0
  55. mannf/core/diagnostics/root_cause_analyzer.py +273 -0
  56. mannf/core/distributed/__init__.py +16 -0
  57. mannf/core/distributed/endpoint.py +139 -0
  58. mannf/core/distributed/system_under_test.py +207 -0
  59. mannf/core/functional_orchestrator.py +428 -0
  60. mannf/core/messaging/__init__.py +11 -0
  61. mannf/core/messaging/bus.py +113 -0
  62. mannf/core/messaging/messages.py +89 -0
  63. mannf/core/nat_orchestrator.py +342 -0
  64. mannf/core/neural/__init__.py +183 -0
  65. mannf/core/orchestrator.py +272 -0
  66. mannf/core/prioritization/__init__.py +17 -0
  67. mannf/core/prioritization/adaptive_controller.py +509 -0
  68. mannf/core/prioritization/belief_prioritizer.py +231 -0
  69. mannf/core/prioritization/risk_scorer.py +430 -0
  70. mannf/core/reporting/__init__.py +12 -0
  71. mannf/core/reporting/unified_report.py +664 -0
  72. mannf/core/testing/__init__.py +17 -0
  73. mannf/core/testing/adaptive_controller.py +149 -0
  74. mannf/core/testing/models.py +179 -0
  75. mannf/core/validation/__init__.py +10 -0
  76. mannf/core/validation/self_validation_runner.py +180 -0
  77. mannf/dashboard/__init__.py +7 -0
  78. mannf/dashboard/app.py +9 -0
  79. mannf/dashboard/models.py +9 -0
  80. mannf/dashboard/static/index.html +2538 -0
  81. mannf/dashboard/telemetry.py +9 -0
  82. mannf/distributed/__init__.py +7 -0
  83. mannf/distributed/endpoint.py +9 -0
  84. mannf/distributed/system_under_test.py +9 -0
  85. mannf/healing/__init__.py +7 -0
  86. mannf/healing/graphql_schema_diff.py +9 -0
  87. mannf/healing/healer.py +9 -0
  88. mannf/healing/models.py +9 -0
  89. mannf/healing/schema_diff.py +9 -0
  90. mannf/integrations/__init__.py +7 -0
  91. mannf/integrations/auth.py +9 -0
  92. mannf/integrations/graphql_parser.py +9 -0
  93. mannf/integrations/graphql_sut.py +9 -0
  94. mannf/integrations/http_sut.py +9 -0
  95. mannf/integrations/openapi_parser.py +9 -0
  96. mannf/integrations/postman_parser.py +9 -0
  97. mannf/llm/__init__.py +7 -0
  98. mannf/llm/anthropic_provider.py +9 -0
  99. mannf/llm/base.py +9 -0
  100. mannf/llm/config.py +9 -0
  101. mannf/llm/factory.py +9 -0
  102. mannf/llm/openai_provider.py +9 -0
  103. mannf/llm/prompts.py +9 -0
  104. mannf/messaging/__init__.py +7 -0
  105. mannf/messaging/bus.py +9 -0
  106. mannf/messaging/messages.py +9 -0
  107. mannf/nat_orchestrator.py +9 -0
  108. mannf/neural/__init__.py +7 -0
  109. mannf/orchestrator.py +9 -0
  110. mannf/prioritization/__init__.py +7 -0
  111. mannf/prioritization/adaptive_controller.py +9 -0
  112. mannf/prioritization/belief_prioritizer.py +9 -0
  113. mannf/prioritization/risk_scorer.py +9 -0
  114. mannf/product/__init__.py +29 -0
  115. mannf/product/admin/__init__.py +3 -0
  116. mannf/product/admin/routes.py +514 -0
  117. mannf/product/auth/__init__.py +5 -0
  118. mannf/product/auth/saml.py +212 -0
  119. mannf/product/billing/__init__.py +5 -0
  120. mannf/product/billing/audit.py +160 -0
  121. mannf/product/billing/feature_gates.py +180 -0
  122. mannf/product/billing/metering.py +179 -0
  123. mannf/product/billing/notifications.py +181 -0
  124. mannf/product/billing/plans.py +133 -0
  125. mannf/product/billing/rate_limits.py +35 -0
  126. mannf/product/billing/stripe_billing.py +906 -0
  127. mannf/product/billing/tenant_auth.py +233 -0
  128. mannf/product/billing/tenant_manager.py +873 -0
  129. mannf/product/cli.py +3900 -0
  130. mannf/product/cli_admin.py +408 -0
  131. mannf/product/dashboard/__init__.py +61 -0
  132. mannf/product/dashboard/app.py +3567 -0
  133. mannf/product/dashboard/models.py +460 -0
  134. mannf/product/dashboard/static/index.html +6347 -0
  135. mannf/product/dashboard/static/manifest.json +25 -0
  136. mannf/product/dashboard/static/pwa-icon-192.png +0 -0
  137. mannf/product/dashboard/static/pwa-icon-512.png +0 -0
  138. mannf/product/dashboard/static/sw.js +64 -0
  139. mannf/product/dashboard/telemetry.py +547 -0
  140. mannf/product/database.py +145 -0
  141. mannf/product/demo.py +844 -0
  142. mannf/product/doctor.py +509 -0
  143. mannf/product/exporters/__init__.py +65 -0
  144. mannf/product/exporters/azuredevops_exporter.py +257 -0
  145. mannf/product/exporters/base.py +307 -0
  146. mannf/product/exporters/bugzilla_exporter.py +200 -0
  147. mannf/product/exporters/dedup.py +275 -0
  148. mannf/product/exporters/finding_adapter.py +216 -0
  149. mannf/product/exporters/github_exporter.py +197 -0
  150. mannf/product/exporters/gitlab_exporter.py +215 -0
  151. mannf/product/exporters/jira_exporter.py +180 -0
  152. mannf/product/exporters/linear_exporter.py +195 -0
  153. mannf/product/exporters/loader.py +233 -0
  154. mannf/product/exporters/pagerduty_exporter.py +363 -0
  155. mannf/product/exporters/sentry_exporter.py +322 -0
  156. mannf/product/exporters/servicenow_exporter.py +240 -0
  157. mannf/product/exporters/shortcut_exporter.py +231 -0
  158. mannf/product/exporters/webhook_exporter.py +383 -0
  159. mannf/product/formatters/__init__.py +18 -0
  160. mannf/product/formatters/allure_formatter.py +161 -0
  161. mannf/product/formatters/ctrf_formatter.py +149 -0
  162. mannf/product/healing/__init__.py +30 -0
  163. mannf/product/healing/graphql_schema_diff.py +152 -0
  164. mannf/product/healing/healer.py +141 -0
  165. mannf/product/healing/models.py +175 -0
  166. mannf/product/healing/schema_diff.py +251 -0
  167. mannf/product/ingestors/__init__.py +77 -0
  168. mannf/product/ingestors/base.py +256 -0
  169. mannf/product/ingestors/bgstm_ingestor.py +764 -0
  170. mannf/product/ingestors/curl_ingestor.py +1019 -0
  171. mannf/product/ingestors/cypress_ingestor.py +487 -0
  172. mannf/product/ingestors/gherkin_ingestor.py +967 -0
  173. mannf/product/ingestors/graphql_ingestor.py +845 -0
  174. mannf/product/ingestors/grpc_ingestor.py +591 -0
  175. mannf/product/ingestors/har_ingestor.py +976 -0
  176. mannf/product/ingestors/loader.py +284 -0
  177. mannf/product/ingestors/models.py +146 -0
  178. mannf/product/ingestors/openapi_ingestor.py +606 -0
  179. mannf/product/ingestors/playwright_ingestor.py +449 -0
  180. mannf/product/ingestors/postman_ingestor.py +631 -0
  181. mannf/product/ingestors/traffic_ingestor.py +679 -0
  182. mannf/product/ingestors/websocket_ingestor.py +526 -0
  183. mannf/product/integrations/__init__.py +21 -0
  184. mannf/product/integrations/auth.py +190 -0
  185. mannf/product/integrations/graphql_parser.py +436 -0
  186. mannf/product/integrations/graphql_sut.py +247 -0
  187. mannf/product/integrations/grpc_sut.py +469 -0
  188. mannf/product/integrations/http_sut.py +237 -0
  189. mannf/product/integrations/kafka_adapter.py +342 -0
  190. mannf/product/integrations/openapi_parser.py +513 -0
  191. mannf/product/integrations/postman_parser.py +467 -0
  192. mannf/product/integrations/webhook_receiver.py +344 -0
  193. mannf/product/integrations/websocket_sut.py +434 -0
  194. mannf/product/llm/__init__.py +25 -0
  195. mannf/product/llm/anthropic_provider.py +94 -0
  196. mannf/product/llm/base.py +267 -0
  197. mannf/product/llm/config.py +48 -0
  198. mannf/product/llm/factory.py +42 -0
  199. mannf/product/llm/openai_provider.py +93 -0
  200. mannf/product/llm/prompts.py +403 -0
  201. mannf/product/llm/root_cause_service.py +311 -0
  202. mannf/product/llm/test_plan_models.py +78 -0
  203. mannf/product/metrics.py +149 -0
  204. mannf/product/middleware/__init__.py +3 -0
  205. mannf/product/middleware/audit_middleware.py +112 -0
  206. mannf/product/middleware/tenant_isolation.py +114 -0
  207. mannf/product/models.py +347 -0
  208. mannf/product/notifications/__init__.py +24 -0
  209. mannf/product/notifications/dispatcher.py +411 -0
  210. mannf/product/onboarding.py +190 -0
  211. mannf/product/orchestration/__init__.py +39 -0
  212. mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
  213. mannf/product/orchestration/pipeline.py +401 -0
  214. mannf/product/orchestrator.py +987 -0
  215. mannf/product/orchestrator_models.py +269 -0
  216. mannf/product/regression/__init__.py +36 -0
  217. mannf/product/regression/differ.py +172 -0
  218. mannf/product/regression/masking.py +100 -0
  219. mannf/product/regression/models.py +232 -0
  220. mannf/product/regression/recorder.py +124 -0
  221. mannf/product/regression/replayer.py +168 -0
  222. mannf/product/reports/__init__.py +10 -0
  223. mannf/product/reports/pdf.py +132 -0
  224. mannf/product/scheduling/__init__.py +57 -0
  225. mannf/product/scheduling/cron_utils.py +251 -0
  226. mannf/product/scheduling/engine.py +473 -0
  227. mannf/product/scheduling/models.py +86 -0
  228. mannf/product/scheduling/queue.py +894 -0
  229. mannf/product/scheduling/store.py +235 -0
  230. mannf/product/security/__init__.py +21 -0
  231. mannf/product/security/belief_guided.py +143 -0
  232. mannf/product/security/checks/__init__.py +55 -0
  233. mannf/product/security/checks/base.py +69 -0
  234. mannf/product/security/checks/bfla.py +77 -0
  235. mannf/product/security/checks/bola.py +77 -0
  236. mannf/product/security/checks/bopla.py +80 -0
  237. mannf/product/security/checks/broken_auth.py +86 -0
  238. mannf/product/security/checks/graphql_security.py +299 -0
  239. mannf/product/security/checks/inventory.py +70 -0
  240. mannf/product/security/checks/misconfig.py +158 -0
  241. mannf/product/security/checks/resource_consumption.py +70 -0
  242. mannf/product/security/checks/sensitive_flows.py +80 -0
  243. mannf/product/security/checks/ssrf.py +101 -0
  244. mannf/product/security/checks/unsafe_consumption.py +120 -0
  245. mannf/product/security/models.py +92 -0
  246. mannf/product/security/plugin_loader.py +182 -0
  247. mannf/product/security/reporter.py +92 -0
  248. mannf/product/security/scanner.py +183 -0
  249. mannf/product/server.py +6220 -0
  250. mannf/product/setup_wizard.py +873 -0
  251. mannf/product/status.py +404 -0
  252. mannf/product/storage/__init__.py +10 -0
  253. mannf/product/storage/artifact_store.py +343 -0
  254. mannf/product/telemetry.py +300 -0
  255. mannf/product/uninstall.py +169 -0
  256. mannf/product/upgrade.py +139 -0
  257. mannf/product/weights/__init__.py +13 -0
  258. mannf/product/weights/blob_store.py +299 -0
  259. mannf/product/weights/factory.py +42 -0
  260. mannf/product/weights/registry.py +159 -0
  261. mannf/product/weights/store.py +210 -0
  262. mannf/regression/__init__.py +7 -0
  263. mannf/regression/differ.py +9 -0
  264. mannf/regression/masking.py +9 -0
  265. mannf/regression/models.py +9 -0
  266. mannf/regression/recorder.py +9 -0
  267. mannf/regression/replayer.py +9 -0
  268. mannf/security/__init__.py +7 -0
  269. mannf/security/belief_guided.py +9 -0
  270. mannf/security/checks/__init__.py +7 -0
  271. mannf/security/checks/base.py +9 -0
  272. mannf/security/checks/bfla.py +9 -0
  273. mannf/security/checks/bola.py +9 -0
  274. mannf/security/checks/bopla.py +9 -0
  275. mannf/security/checks/broken_auth.py +9 -0
  276. mannf/security/checks/graphql_security.py +9 -0
  277. mannf/security/checks/inventory.py +9 -0
  278. mannf/security/checks/misconfig.py +9 -0
  279. mannf/security/checks/resource_consumption.py +9 -0
  280. mannf/security/checks/sensitive_flows.py +9 -0
  281. mannf/security/checks/ssrf.py +9 -0
  282. mannf/security/checks/unsafe_consumption.py +9 -0
  283. mannf/security/models.py +9 -0
  284. mannf/security/reporter.py +9 -0
  285. mannf/security/scanner.py +9 -0
  286. mannf/server.py +9 -0
  287. mannf/testing/__init__.py +7 -0
  288. mannf/testing/adaptive_controller.py +9 -0
  289. mannf/testing/models.py +9 -0
  290. mannf/weights/__init__.py +7 -0
  291. mannf/weights/registry.py +9 -0
  292. mannf/weights/store.py +9 -0
  293. nat_engine-1.dist-info/METADATA +555 -0
  294. nat_engine-1.dist-info/RECORD +299 -0
  295. nat_engine-1.dist-info/WHEEL +5 -0
  296. nat_engine-1.dist-info/entry_points.txt +4 -0
  297. nat_engine-1.dist-info/licenses/LICENSE +651 -0
  298. nat_engine-1.dist-info/licenses/NOTICE +178 -0
  299. nat_engine-1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,343 @@
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
+ """Azure Blob Storage backend for scan artifacts.
7
+
8
+ Blobs are organized as::
9
+
10
+ scans/{scan_id}/results.json
11
+ scans/{scan_id}/spec.yaml
12
+ scans/{scan_id}/<filename>
13
+
14
+ When Azure Blob Storage is not configured (neither
15
+ ``AZURE_STORAGE_CONNECTION_STRING`` nor ``AZURE_STORAGE_ACCOUNT_NAME`` is
16
+ set), all methods become no-ops that return ``None`` so the rest of the
17
+ application is unaffected.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import logging
24
+ import os
25
+ from typing import Any, Dict, Optional
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ _SCANS_CONTAINER = "nat-scans"
30
+
31
+
32
+ class ArtifactStore:
33
+ """Store scan artifacts in Azure Blob Storage.
34
+
35
+ Blobs are organized as: ``scans/{scan_id}/results.json``,
36
+ ``scans/{scan_id}/spec.yaml``, etc.
37
+
38
+ When Azure Blob Storage is not configured, all methods return ``None``
39
+ without raising exceptions so that the application operates normally in
40
+ local / development mode.
41
+
42
+ Parameters
43
+ ----------
44
+ container_name:
45
+ Name of the Azure Blob Storage container. Defaults to
46
+ ``"nat-scans"``.
47
+ """
48
+
49
+ def __init__(self, container_name: str = _SCANS_CONTAINER) -> None:
50
+ self._container_name = container_name
51
+ self._client: Any = None
52
+ if self.is_configured():
53
+ self._client = self._build_client()
54
+
55
+ # ------------------------------------------------------------------
56
+ # Public async API
57
+ # ------------------------------------------------------------------
58
+
59
+ async def store_scan_results(self, scan_id: str, results: Dict[str, Any]) -> Optional[str]:
60
+ """Store scan results blob.
61
+
62
+ Parameters
63
+ ----------
64
+ scan_id:
65
+ Unique scan identifier. Used as part of the blob path.
66
+ results:
67
+ Scan results dict to serialise as JSON.
68
+
69
+ Returns
70
+ -------
71
+ str | None
72
+ The URL of the uploaded blob, or ``None`` if Azure is not
73
+ configured.
74
+ """
75
+ if not self._client:
76
+ return None
77
+
78
+ blob_path = f"scans/{scan_id}/results.json"
79
+ data = json.dumps(results, indent=2, default=str).encode("utf-8")
80
+ return await self._upload(blob_path, data, content_type="application/json")
81
+
82
+ async def retrieve_scan_results(self, scan_id: str) -> Optional[Dict[str, Any]]:
83
+ """Retrieve scan results from blob storage.
84
+
85
+ Parameters
86
+ ----------
87
+ scan_id:
88
+ Unique scan identifier.
89
+
90
+ Returns
91
+ -------
92
+ dict | None
93
+ Parsed scan results, or ``None`` if Azure is not configured or
94
+ the blob does not exist.
95
+ """
96
+ if not self._client:
97
+ return None
98
+
99
+ blob_path = f"scans/{scan_id}/results.json"
100
+ raw = await self._download(blob_path)
101
+ if raw is None:
102
+ return None
103
+ try:
104
+ return json.loads(raw)
105
+ except json.JSONDecodeError:
106
+ logger.warning("ArtifactStore: corrupt results blob for scan %s", scan_id)
107
+ return None
108
+
109
+ async def store_spec(
110
+ self,
111
+ scan_id: str,
112
+ spec_content: bytes,
113
+ filename: str,
114
+ ) -> Optional[str]:
115
+ """Store the uploaded spec file blob.
116
+
117
+ Parameters
118
+ ----------
119
+ scan_id:
120
+ Unique scan identifier.
121
+ spec_content:
122
+ Raw bytes of the spec file.
123
+ filename:
124
+ Original filename (used as the blob name suffix, e.g.
125
+ ``"openapi.yaml"``).
126
+
127
+ Returns
128
+ -------
129
+ str | None
130
+ The URL of the uploaded blob, or ``None`` if Azure is not
131
+ configured.
132
+ """
133
+ if not self._client:
134
+ return None
135
+
136
+ blob_path = f"scans/{scan_id}/{filename}"
137
+ return await self._upload(blob_path, spec_content)
138
+
139
+ async def store_scan_spec(self, scan_id: str, content: bytes, filename: str) -> Optional[str]:
140
+ """Store the OpenAPI spec file used for a scan.
141
+
142
+ Parameters
143
+ ----------
144
+ scan_id:
145
+ Unique scan identifier.
146
+ content:
147
+ Raw bytes of the spec file.
148
+ filename:
149
+ Original filename (e.g. ``"openapi.yaml"``).
150
+
151
+ Returns
152
+ -------
153
+ str | None
154
+ The URL of the uploaded blob, or ``None`` if Azure is not
155
+ configured.
156
+ """
157
+ return await self.store_spec(scan_id, content, filename)
158
+
159
+ async def store_scan_weights(self, scan_id: str, data: Dict[str, Any]) -> Optional[str]:
160
+ """Store belief-state / weight snapshot for a scan.
161
+
162
+ The data is serialised as JSON and stored under
163
+ ``weights/{scan_id}/belief_states.json`` to enable cross-session
164
+ transfer learning when running in cloud mode.
165
+
166
+ Parameters
167
+ ----------
168
+ scan_id:
169
+ Unique scan identifier.
170
+ data:
171
+ Belief state or weight data to persist.
172
+
173
+ Returns
174
+ -------
175
+ str | None
176
+ The URL of the uploaded blob, or ``None`` if Azure is not
177
+ configured.
178
+ """
179
+ if not self._client:
180
+ return None
181
+
182
+ blob_path = f"weights/{scan_id}/belief_states.json"
183
+ raw = json.dumps(data, indent=2, default=str).encode("utf-8")
184
+ return await self._upload(blob_path, raw, content_type="application/json")
185
+
186
+ async def list_scan_artifacts(self, scan_id: str) -> list:
187
+ """List available artifact blob names for a scan.
188
+
189
+ Returns a list of blob names stored under ``scans/{scan_id}/`` and
190
+ ``weights/{scan_id}/``. Returns an empty list when Azure is not
191
+ configured or if no artifacts exist.
192
+
193
+ Parameters
194
+ ----------
195
+ scan_id:
196
+ Unique scan identifier.
197
+
198
+ Returns
199
+ -------
200
+ list[str]
201
+ Blob names relative to the container root.
202
+ """
203
+ if not self._client:
204
+ return []
205
+
206
+ from azure.core.exceptions import AzureError
207
+
208
+ prefixes = [f"scans/{scan_id}/", f"weights/{scan_id}/"]
209
+ names: list = []
210
+ try:
211
+ await self._ensure_container()
212
+ container_client = self._client.get_container_client(self._container_name)
213
+ for prefix in prefixes:
214
+ async for blob in container_client.list_blobs(name_starts_with=prefix):
215
+ names.append(blob.name)
216
+ except AzureError:
217
+ logger.exception("ArtifactStore: failed to list artifacts for scan %s", scan_id)
218
+ return names
219
+
220
+ async def download_artifact(self, blob_name: str) -> Optional[bytes]:
221
+ """Download a specific artifact by its full blob name.
222
+
223
+ Parameters
224
+ ----------
225
+ blob_name:
226
+ Full blob name (as returned by :meth:`list_scan_artifacts`).
227
+
228
+ Returns
229
+ -------
230
+ bytes | None
231
+ Raw bytes of the artifact, or ``None`` if not found / not
232
+ configured.
233
+ """
234
+ if not self._client:
235
+ return None
236
+ return await self._download(blob_name)
237
+
238
+ # ------------------------------------------------------------------
239
+ # Class-level helpers
240
+ # ------------------------------------------------------------------
241
+
242
+ @staticmethod
243
+ def is_configured() -> bool:
244
+ """Return ``True`` when Azure Blob Storage environment variables are set."""
245
+ return bool(
246
+ os.environ.get("AZURE_STORAGE_CONNECTION_STRING")
247
+ or os.environ.get("AZURE_STORAGE_ACCOUNT_NAME")
248
+ )
249
+
250
+ # ------------------------------------------------------------------
251
+ # Internal helpers
252
+ # ------------------------------------------------------------------
253
+
254
+ def _build_client(self): # type: ignore[return]
255
+ """Construct an ``AsyncBlobServiceClient`` from environment variables."""
256
+ try:
257
+ from azure.storage.blob.aio import BlobServiceClient
258
+ except ImportError as exc:
259
+ raise ImportError(
260
+ "azure-storage-blob is required for ArtifactStore. "
261
+ "Install it with: pip install 'mannf[azure-storage]'"
262
+ ) from exc
263
+
264
+ conn_str = os.environ.get("AZURE_STORAGE_CONNECTION_STRING")
265
+ if conn_str:
266
+ return BlobServiceClient.from_connection_string(conn_str)
267
+
268
+ account_name = os.environ.get("AZURE_STORAGE_ACCOUNT_NAME")
269
+ if account_name:
270
+ account_key = os.environ.get("AZURE_STORAGE_ACCOUNT_KEY", "")
271
+ if account_key:
272
+ conn_str = (
273
+ f"DefaultEndpointsProtocol=https;"
274
+ f"AccountName={account_name};"
275
+ f"AccountKey={account_key};"
276
+ f"EndpointSuffix=core.windows.net"
277
+ )
278
+ return BlobServiceClient.from_connection_string(conn_str)
279
+ from azure.identity.aio import DefaultAzureCredential
280
+ credential = DefaultAzureCredential()
281
+ return BlobServiceClient(
282
+ account_url=f"https://{account_name}.blob.core.windows.net",
283
+ credential=credential,
284
+ )
285
+
286
+ raise EnvironmentError(
287
+ "Neither AZURE_STORAGE_CONNECTION_STRING nor "
288
+ "AZURE_STORAGE_ACCOUNT_NAME is set."
289
+ )
290
+
291
+ async def _ensure_container(self) -> None:
292
+ """Create the container if it does not already exist."""
293
+ from azure.core.exceptions import ResourceExistsError
294
+
295
+ container_client = self._client.get_container_client(self._container_name)
296
+ try:
297
+ await container_client.create_container()
298
+ logger.debug("ArtifactStore: created container %r", self._container_name)
299
+ except ResourceExistsError:
300
+ pass
301
+
302
+ async def _upload(
303
+ self,
304
+ blob_path: str,
305
+ data: bytes,
306
+ content_type: str = "application/octet-stream",
307
+ ) -> Optional[str]:
308
+ """Upload *data* to *blob_path*, returning the blob URL."""
309
+ from azure.core.exceptions import AzureError
310
+ from azure.storage.blob import ContentSettings
311
+
312
+ try:
313
+ await self._ensure_container()
314
+ blob_client = self._client.get_blob_client(
315
+ container=self._container_name, blob=blob_path
316
+ )
317
+ await blob_client.upload_blob(
318
+ data,
319
+ overwrite=True,
320
+ content_settings=ContentSettings(content_type=content_type),
321
+ )
322
+ url = blob_client.url
323
+ logger.info("ArtifactStore: uploaded %s", blob_path)
324
+ return url
325
+ except AzureError:
326
+ logger.exception("ArtifactStore: failed to upload %s", blob_path)
327
+ return None
328
+
329
+ async def _download(self, blob_path: str) -> Optional[bytes]:
330
+ """Download *blob_path*, returning raw bytes or ``None`` if not found."""
331
+ from azure.core.exceptions import AzureError, ResourceNotFoundError
332
+
333
+ try:
334
+ blob_client = self._client.get_blob_client(
335
+ container=self._container_name, blob=blob_path
336
+ )
337
+ stream = await blob_client.download_blob()
338
+ return await stream.readall()
339
+ except ResourceNotFoundError:
340
+ return None
341
+ except AzureError:
342
+ logger.exception("ArtifactStore: failed to download %s", blob_path)
343
+ return None
@@ -0,0 +1,300 @@
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
+ """Application telemetry: Azure Monitor OpenTelemetry + structured JSON logging.
7
+
8
+ Call :func:`setup_telemetry` once at startup (before the FastAPI app is
9
+ created). The function is always safe to call:
10
+
11
+ - When ``APPLICATIONINSIGHTS_CONNECTION_STRING`` is **not** set, it configures
12
+ a standard human-readable log format suitable for local development and
13
+ returns immediately.
14
+ - When the connection string **is** set, it additionally:
15
+ 1. Configures Python's root logger to emit structured JSON (useful for any
16
+ cloud log aggregator, not just Azure).
17
+ 2. Calls ``configure_azure_monitor()`` to enable OpenTelemetry
18
+ auto-instrumentation (traces, metrics, logs) and ship telemetry to
19
+ Application Insights.
20
+
21
+ The Azure SDK dependency (``azure-monitor-opentelemetry``) is **optional at
22
+ runtime** — if it is not installed the module falls back gracefully to JSON
23
+ logging only.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import contextvars
29
+ import json
30
+ import logging
31
+ import os
32
+ import traceback
33
+ from datetime import datetime, timezone
34
+ from typing import Any, Dict, Optional
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Per-request log context (tenant / scan)
39
+ # ---------------------------------------------------------------------------
40
+
41
+ _log_context: contextvars.ContextVar[Dict[str, Any]] = contextvars.ContextVar(
42
+ "_log_context", default={}
43
+ )
44
+
45
+
46
+ def set_log_context(**kwargs: Any) -> None:
47
+ """Merge *kwargs* into the current log context for this coroutine/thread.
48
+
49
+ Typical usage::
50
+
51
+ set_log_context(tenant_id="t-123", scan_id="s-456")
52
+
53
+ Call :func:`clear_log_context` when the context is no longer needed (e.g.
54
+ at the end of an HTTP request).
55
+ """
56
+ current = dict(_log_context.get())
57
+ current.update({k: v for k, v in kwargs.items() if v is not None})
58
+ _log_context.set(current)
59
+
60
+
61
+ def clear_log_context() -> None:
62
+ """Reset the log context for this coroutine/thread to an empty dict."""
63
+ _log_context.set({})
64
+
65
+
66
+ class TenantContextFilter(logging.Filter):
67
+ """Inject tenant/scan context fields into every :class:`logging.LogRecord`.
68
+
69
+ Fields are drawn from the :data:`_log_context` ContextVar, which is
70
+ populated by :func:`set_log_context`. If no context has been set the
71
+ fields are omitted so that log entries from background tasks or startup
72
+ code are not polluted with empty values.
73
+ """
74
+
75
+ def filter(self, record: logging.LogRecord) -> bool: # noqa: A003
76
+ ctx = _log_context.get()
77
+ for key, value in ctx.items():
78
+ if not hasattr(record, key):
79
+ setattr(record, key, value)
80
+ return True
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Custom OpenTelemetry metric instruments
85
+ # ---------------------------------------------------------------------------
86
+ # These are initialised lazily in setup_telemetry() and fall back to no-op
87
+ # stubs when OpenTelemetry is not configured so that callers can always use
88
+ # them without checking for None.
89
+
90
+
91
+ class _NoOpCounter:
92
+ """No-op stand-in for opentelemetry.metrics.Counter."""
93
+
94
+ def add(self, amount: float, attributes: Optional[Dict[str, Any]] = None) -> None:
95
+ pass
96
+
97
+
98
+ class _NoOpHistogram:
99
+ """No-op stand-in for opentelemetry.metrics.Histogram."""
100
+
101
+ def record(self, amount: float, attributes: Optional[Dict[str, Any]] = None) -> None:
102
+ pass
103
+
104
+
105
+ class _NoOpGauge:
106
+ """No-op stand-in for an observable gauge (callback-based metric)."""
107
+
108
+ def __init__(self) -> None:
109
+ self._value: float = 0.0
110
+
111
+ def set(self, value: float, attributes: Optional[Dict[str, Any]] = None) -> None:
112
+ self._value = value
113
+
114
+
115
+ # Public metric handles — replaced with real instruments when OTel is available
116
+ scans_started_counter: Any = _NoOpCounter()
117
+ scans_completed_counter: Any = _NoOpCounter()
118
+ scans_duration_histogram: Any = _NoOpHistogram()
119
+ scan_queue_depth_gauge: Any = _NoOpGauge()
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # JSON formatter
124
+ # ---------------------------------------------------------------------------
125
+
126
+
127
+ class _JsonFormatter(logging.Formatter):
128
+ """Emit each log record as a single-line JSON object.
129
+
130
+ Fields included in every record:
131
+ - ``timestamp`` — ISO-8601 UTC
132
+ - ``level`` — e.g. ``"INFO"``
133
+ - ``message`` — formatted log message
134
+ - ``logger`` — logger name
135
+ - ``module`` — Python module (``record.module``)
136
+ - ``funcName`` — calling function name
137
+ - Any *extra* fields attached to the record that are not standard
138
+ :class:`logging.LogRecord` attributes.
139
+ """
140
+
141
+ # Standard LogRecord attributes we do NOT want to re-emit as extra fields.
142
+ _RESERVED: frozenset = frozenset({
143
+ "name", "msg", "args", "created", "filename", "funcName", "levelname",
144
+ "levelno", "lineno", "message", "module", "msecs", "pathname",
145
+ "process", "processName", "relativeCreated", "stack_info", "thread",
146
+ "threadName", "exc_info", "exc_text",
147
+ })
148
+
149
+ def format(self, record: logging.LogRecord) -> str:
150
+ record.message = record.getMessage()
151
+
152
+ payload: Dict[str, Any] = {
153
+ "timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
154
+ "level": record.levelname,
155
+ "message": record.message,
156
+ "logger": record.name,
157
+ "module": record.module,
158
+ "funcName": record.funcName,
159
+ }
160
+
161
+ # Attach any extra fields the caller passed via logging.extra
162
+ for key, value in record.__dict__.items():
163
+ if key not in self._RESERVED and not key.startswith("_"):
164
+ payload[key] = value
165
+
166
+ if record.exc_info:
167
+ payload["exception"] = self.formatException(record.exc_info)
168
+ elif record.exc_text:
169
+ payload["exception"] = record.exc_text
170
+
171
+ return json.dumps(payload, default=str)
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Public API
176
+ # ---------------------------------------------------------------------------
177
+
178
+
179
+ def setup_telemetry() -> None:
180
+ """Configure logging and (optionally) Azure Monitor OpenTelemetry.
181
+
182
+ This function is **idempotent** — calling it multiple times has no
183
+ additional effect beyond the first call.
184
+
185
+ Behaviour
186
+ ---------
187
+ - **Always**: configure the root logger's handler.
188
+ - **When** ``APPLICATIONINSIGHTS_CONNECTION_STRING`` is set:
189
+ - Switch the root logger to :class:`_JsonFormatter` (structured JSON).
190
+ - Attempt to call ``configure_azure_monitor()`` from
191
+ ``azure.monitor.opentelemetry``. If the package is not installed
192
+ or the call fails, log a warning and continue — the app will still
193
+ run with JSON logging.
194
+ - Attempt to initialise custom OpenTelemetry metric instruments
195
+ (``nat.scans.started``, ``nat.scans.completed``,
196
+ ``nat.scans.duration_seconds``, ``nat.scan_queue.depth``). Falls
197
+ back to no-op stubs when the OTel SDK is not available.
198
+ - **Otherwise**: use a standard human-readable format.
199
+ """
200
+ global scans_started_counter, scans_completed_counter
201
+ global scans_duration_histogram, scan_queue_depth_gauge
202
+
203
+ connection_string = os.environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING", "").strip()
204
+
205
+ root_logger = logging.getLogger()
206
+
207
+ # Only add a handler if none exist yet (avoid duplicate output on repeated calls).
208
+ if not root_logger.handlers:
209
+ handler = logging.StreamHandler()
210
+ root_logger.addHandler(handler)
211
+ root_logger.setLevel(logging.INFO)
212
+
213
+ # Register the tenant/scan context filter once (idempotent check).
214
+ _filter_type = TenantContextFilter
215
+ if not any(isinstance(f, _filter_type) for f in root_logger.filters):
216
+ root_logger.addFilter(TenantContextFilter())
217
+
218
+ if connection_string:
219
+ # ── Structured JSON logging ─────────────────────────────────────────
220
+ for handler in root_logger.handlers:
221
+ handler.setFormatter(_JsonFormatter())
222
+
223
+ # ── Azure Monitor OpenTelemetry ─────────────────────────────────────
224
+ try:
225
+ from azure.monitor.opentelemetry import configure_azure_monitor # type: ignore[import]
226
+
227
+ configure_azure_monitor(connection_string=connection_string)
228
+ logging.getLogger(__name__).info(
229
+ "Azure Monitor OpenTelemetry configured",
230
+ extra={"connection_string_present": True},
231
+ )
232
+ except ImportError:
233
+ logging.getLogger(__name__).warning(
234
+ "azure-monitor-opentelemetry is not installed; "
235
+ "Application Insights telemetry will not be sent. "
236
+ "Install it with: pip install azure-monitor-opentelemetry>=1.6"
237
+ )
238
+ except Exception: # noqa: BLE001
239
+ logging.getLogger(__name__).warning(
240
+ "Failed to configure Azure Monitor OpenTelemetry:\n%s",
241
+ traceback.format_exc(),
242
+ )
243
+
244
+ # ── Custom OpenTelemetry metric instruments ─────────────────────────
245
+ try:
246
+ from opentelemetry import metrics as _otel_metrics # type: ignore[import]
247
+
248
+ _meter = _otel_metrics.get_meter("mannf.nat", version="1.0")
249
+ scans_started_counter = _meter.create_counter(
250
+ name="nat.scans.started",
251
+ description="Number of scans started",
252
+ unit="1",
253
+ )
254
+ scans_completed_counter = _meter.create_counter(
255
+ name="nat.scans.completed",
256
+ description="Number of scans completed (use 'status' attribute for outcome)",
257
+ unit="1",
258
+ )
259
+ scans_duration_histogram = _meter.create_histogram(
260
+ name="nat.scans.duration_seconds",
261
+ description="Scan execution duration in seconds",
262
+ unit="s",
263
+ )
264
+
265
+ # Observable gauge for queue depth — use an up-down counter as a
266
+ # simple synchronous proxy; the value is updated externally via the
267
+ # _NoOpGauge.set() interface replaced by a thin wrapper here.
268
+ class _GaugeProxy(_NoOpGauge):
269
+ """Wraps an UpDownCounter to simulate a synchronous gauge."""
270
+
271
+ def __init__(self, counter: Any) -> None:
272
+ super().__init__()
273
+ self._counter = counter
274
+ self._last_value: float = 0.0
275
+
276
+ def set(self, value: float, attributes: Optional[Dict[str, Any]] = None) -> None:
277
+ delta = value - self._last_value
278
+ self._last_value = value
279
+ self._counter.add(delta, attributes or {})
280
+
281
+ _queue_depth_counter = _meter.create_up_down_counter(
282
+ name="nat.scan_queue.depth",
283
+ description="Current scan queue depth (pending + running)",
284
+ unit="1",
285
+ )
286
+ scan_queue_depth_gauge = _GaugeProxy(_queue_depth_counter)
287
+
288
+ except ImportError:
289
+ # OTel SDK not installed — no-op stubs remain
290
+ pass
291
+ except Exception: # noqa: BLE001
292
+ logging.getLogger(__name__).warning(
293
+ "Failed to initialise OpenTelemetry metric instruments:\n%s",
294
+ traceback.format_exc(),
295
+ )
296
+ else:
297
+ # ── Human-readable format for local development ────────────────────
298
+ fmt = "%(asctime)s %(levelname)-8s %(name)s — %(message)s"
299
+ for handler in root_logger.handlers:
300
+ handler.setFormatter(logging.Formatter(fmt))