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,469 @@
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
+ """gRPC System-Under-Test implementation.
7
+
8
+ :class:`GrpcSUT` invokes unary and streaming RPC methods against a live gRPC
9
+ service. It implements the same ``execute()`` / ``health_check()`` interface
10
+ as :class:`~mannf.product.integrations.http_sut.HttpApiSUT` so the
11
+ :class:`~mannf.nat_orchestrator.NATOrchestrator` and BDI agent pipeline work
12
+ unchanged.
13
+
14
+ The adapter uses ``grpcio`` when available. When ``grpcio`` is *not*
15
+ installed it falls back to a lightweight HTTP/2 hint-based mode that records a
16
+ skip result, allowing the rest of NAT to function in environments without the
17
+ ``grpcio`` package.
18
+
19
+ Supported RPC types:
20
+
21
+ * **Unary** — single request / single response.
22
+ * **Server-streaming** — single request / multiple responses consumed.
23
+ * **Client-streaming** — multiple requests / single response (sent sequentially).
24
+ * **Bidirectional streaming** — multiple requests / multiple responses.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ import time
31
+ from typing import Any, Dict, Iterator, List, Optional
32
+
33
+ from mannf.core.distributed.endpoint import Endpoint, EndpointRegistry
34
+ from mannf.core.distributed.system_under_test import SystemUnderTest
35
+ from mannf.core.testing.models import TestCase, TestResult
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # gRPC status code constants (mirrors grpc.StatusCode)
41
+ # ---------------------------------------------------------------------------
42
+
43
+ _OK = 0
44
+ _STATUS_NAMES: dict[int, str] = {
45
+ 0: "OK",
46
+ 1: "CANCELLED",
47
+ 2: "UNKNOWN",
48
+ 3: "INVALID_ARGUMENT",
49
+ 4: "DEADLINE_EXCEEDED",
50
+ 5: "NOT_FOUND",
51
+ 6: "ALREADY_EXISTS",
52
+ 7: "PERMISSION_DENIED",
53
+ 8: "RESOURCE_EXHAUSTED",
54
+ 9: "FAILED_PRECONDITION",
55
+ 10: "ABORTED",
56
+ 11: "OUT_OF_RANGE",
57
+ 12: "UNIMPLEMENTED",
58
+ 13: "INTERNAL",
59
+ 14: "UNAVAILABLE",
60
+ 15: "DATA_LOSS",
61
+ 16: "UNAUTHENTICATED",
62
+ }
63
+
64
+
65
+ def _grpc_available() -> bool:
66
+ """Return True if the ``grpcio`` package is installed."""
67
+ try:
68
+ import grpc # noqa: F401
69
+ return True
70
+ except ImportError:
71
+ return False
72
+
73
+
74
+ def _passthrough(x: Any) -> Any:
75
+ """Identity serializer/deserializer — pass raw bytes through unchanged."""
76
+ return x
77
+
78
+
79
+ def _make_stub(channel: Any, service_name: str, method_name: str) -> Any:
80
+ """Create a dynamic gRPC stub method using the generic unary–unary approach."""
81
+ method_path = f"/{service_name}/{method_name}"
82
+ return channel.unary_unary(
83
+ method_path,
84
+ request_serializer=_passthrough,
85
+ response_deserializer=_passthrough,
86
+ )
87
+
88
+
89
+ class GrpcSUT(SystemUnderTest):
90
+ """Execute gRPC test cases against a live gRPC service.
91
+
92
+ Parameters
93
+ ----------
94
+ endpoint:
95
+ gRPC server address in ``host:port`` format
96
+ (e.g. ``"localhost:50051"``).
97
+ timeout:
98
+ Per-call timeout in seconds (default: 10).
99
+ use_tls:
100
+ Use TLS credentials (default: ``False`` — insecure channel).
101
+ metadata:
102
+ Sequence of ``(key, value)`` pairs sent as gRPC call metadata
103
+ (e.g. for authentication tokens).
104
+ max_streaming_responses:
105
+ Maximum number of responses to collect from server-streaming or
106
+ bidi-streaming RPCs before closing the stream (default: 100).
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ endpoint: str,
112
+ timeout: float = 10.0,
113
+ use_tls: bool = False,
114
+ metadata: Optional[List[tuple]] = None,
115
+ max_streaming_responses: int = 100,
116
+ ) -> None:
117
+ self.endpoint = endpoint
118
+ self.timeout = timeout
119
+ self.use_tls = use_tls
120
+ self.call_metadata: List[tuple] = metadata or []
121
+ self.max_streaming_responses = max_streaming_responses
122
+
123
+ self._registry = EndpointRegistry()
124
+ host, _, port_str = endpoint.partition(":")
125
+ try:
126
+ port = int(port_str) if port_str else 50051
127
+ except ValueError:
128
+ port = 50051
129
+ self._registry.register(
130
+ Endpoint(
131
+ name="default",
132
+ host=host or "localhost",
133
+ port=port,
134
+ protocol="grpc",
135
+ )
136
+ )
137
+
138
+ # ------------------------------------------------------------------
139
+ # SystemUnderTest interface
140
+ # ------------------------------------------------------------------
141
+
142
+ @property
143
+ def registry(self) -> EndpointRegistry:
144
+ return self._registry
145
+
146
+ async def execute(self, test_case: TestCase) -> TestResult:
147
+ """Invoke the gRPC method described by *test_case* and return the result.
148
+
149
+ ``test_case.target`` is treated as the full gRPC method path
150
+ (e.g. ``"/helloworld.Greeter/SayHello"``).
151
+
152
+ ``test_case.inputs`` may contain:
153
+
154
+ * ``request`` — dict payload serialised to JSON-encoded bytes for the
155
+ request message.
156
+ * ``rpc_type`` — one of ``"unary"``, ``"server_streaming"``,
157
+ ``"client_streaming"``, ``"bidi_streaming"`` (default: ``"unary"``).
158
+ * ``requests`` — list of request dicts for streaming RPCs.
159
+ * ``expected_status_codes`` — list of acceptable gRPC status codes
160
+ (ints). Defaults to ``[0]`` (OK).
161
+ """
162
+ if not _grpc_available():
163
+ return TestResult(
164
+ test_case_id=test_case.id,
165
+ passed=False,
166
+ actual_output=None,
167
+ execution_time_ms=0.0,
168
+ error=(
169
+ "grpcio package is not installed. "
170
+ "Install it with: pip install grpcio"
171
+ ),
172
+ )
173
+
174
+ rpc_type: str = str(test_case.inputs.get("rpc_type", "unary")).lower()
175
+ method_path: str = test_case.target
176
+ request_data: dict = test_case.inputs.get("request") or {}
177
+ expected_codes = frozenset(
178
+ test_case.inputs.get("expected_status_codes") or [_OK]
179
+ )
180
+
181
+ if rpc_type == "unary":
182
+ return await self._execute_unary(
183
+ test_case, method_path, request_data, expected_codes
184
+ )
185
+ elif rpc_type == "server_streaming":
186
+ return await self._execute_server_streaming(
187
+ test_case, method_path, request_data, expected_codes
188
+ )
189
+ elif rpc_type == "client_streaming":
190
+ requests = test_case.inputs.get("requests") or [request_data]
191
+ return await self._execute_client_streaming(
192
+ test_case, method_path, requests, expected_codes
193
+ )
194
+ elif rpc_type == "bidi_streaming":
195
+ requests = test_case.inputs.get("requests") or [request_data]
196
+ return await self._execute_bidi_streaming(
197
+ test_case, method_path, requests, expected_codes
198
+ )
199
+ else:
200
+ return TestResult(
201
+ test_case_id=test_case.id,
202
+ passed=False,
203
+ actual_output=None,
204
+ execution_time_ms=0.0,
205
+ error=f"Unknown rpc_type: {rpc_type!r}",
206
+ )
207
+
208
+ async def health_check(self) -> bool:
209
+ """Check that the gRPC server is reachable via a channel connectivity check."""
210
+ if not _grpc_available():
211
+ return False
212
+ try:
213
+ import grpc
214
+
215
+ if self.use_tls:
216
+ credentials = grpc.ssl_channel_credentials()
217
+ channel = grpc.secure_channel(self.endpoint, credentials)
218
+ else:
219
+ channel = grpc.insecure_channel(self.endpoint)
220
+
221
+ grpc.channel_ready_future(channel).result(timeout=min(self.timeout, 5.0))
222
+ channel.close()
223
+ return True
224
+ except Exception: # noqa: BLE001
225
+ return False
226
+
227
+ # ------------------------------------------------------------------
228
+ # RPC-type-specific execution helpers
229
+ # ------------------------------------------------------------------
230
+
231
+ def _make_channel(self) -> Any:
232
+ """Create an insecure or TLS gRPC channel."""
233
+ import grpc
234
+
235
+ if self.use_tls:
236
+ credentials = grpc.ssl_channel_credentials()
237
+ return grpc.secure_channel(self.endpoint, credentials)
238
+ return grpc.insecure_channel(self.endpoint)
239
+
240
+ def _encode_request(self, request_data: dict) -> bytes:
241
+ """Encode *request_data* as raw bytes (JSON encoding for dynamic stub)."""
242
+ import json as _json
243
+ return _json.dumps(request_data).encode("utf-8")
244
+
245
+ def _parse_rpc_path(self, method_path: str) -> tuple[str, str]:
246
+ """Return (service_path, method_name) from a full method path."""
247
+ # e.g. "/helloworld.Greeter/SayHello" → ("/helloworld.Greeter", "SayHello")
248
+ path = method_path.lstrip("/")
249
+ if "/" in path:
250
+ svc, _, method = path.rpartition("/")
251
+ return svc, method
252
+ return path, ""
253
+
254
+ async def _execute_unary(
255
+ self,
256
+ test_case: TestCase,
257
+ method_path: str,
258
+ request_data: dict,
259
+ expected_codes: frozenset,
260
+ ) -> TestResult:
261
+ """Execute a unary RPC call."""
262
+ import grpc
263
+
264
+ start = time.monotonic()
265
+ try:
266
+ channel = self._make_channel()
267
+ request_bytes = self._encode_request(request_data)
268
+
269
+ svc_path, method_name = self._parse_rpc_path(method_path)
270
+ stub = channel.unary_unary(
271
+ method_path,
272
+ request_serializer=_passthrough,
273
+ response_deserializer=_passthrough,
274
+ )
275
+
276
+ response = stub(request_bytes, timeout=self.timeout, metadata=self.call_metadata)
277
+ elapsed_ms = (time.monotonic() - start) * 1000.0
278
+
279
+ # Dynamic stub returns raw bytes on success
280
+ passed = _OK in expected_codes
281
+ channel.close()
282
+
283
+ return TestResult(
284
+ test_case_id=test_case.id,
285
+ passed=passed,
286
+ actual_output={"raw_bytes": len(response) if response else 0},
287
+ execution_time_ms=elapsed_ms,
288
+ error=None if passed else f"Unexpected gRPC status; expected one of {expected_codes}",
289
+ )
290
+
291
+ except Exception as exc: # noqa: BLE001
292
+ elapsed_ms = (time.monotonic() - start) * 1000.0
293
+ status_code = _extract_grpc_status_code(exc)
294
+ passed = status_code in expected_codes if status_code is not None else False
295
+
296
+ error_msg: Optional[str] = None
297
+ if not passed:
298
+ status_name = _STATUS_NAMES.get(status_code or 2, "UNKNOWN")
299
+ error_msg = f"gRPC {status_name} ({status_code}): {exc}"
300
+
301
+ return TestResult(
302
+ test_case_id=test_case.id,
303
+ passed=passed,
304
+ actual_output=None,
305
+ execution_time_ms=elapsed_ms,
306
+ error=error_msg,
307
+ )
308
+
309
+ async def _execute_server_streaming(
310
+ self,
311
+ test_case: TestCase,
312
+ method_path: str,
313
+ request_data: dict,
314
+ expected_codes: frozenset,
315
+ ) -> TestResult:
316
+ """Execute a server-streaming RPC call, collecting up to N responses."""
317
+ start = time.monotonic()
318
+ try:
319
+ channel = self._make_channel()
320
+ request_bytes = self._encode_request(request_data)
321
+
322
+ stub = channel.unary_stream(
323
+ method_path,
324
+ request_serializer=_passthrough,
325
+ response_deserializer=_passthrough,
326
+ )
327
+
328
+ responses: list[Any] = []
329
+ for resp in stub(request_bytes, timeout=self.timeout, metadata=self.call_metadata):
330
+ responses.append(resp)
331
+ if len(responses) >= self.max_streaming_responses:
332
+ break
333
+
334
+ elapsed_ms = (time.monotonic() - start) * 1000.0
335
+ passed = _OK in expected_codes
336
+ channel.close()
337
+
338
+ return TestResult(
339
+ test_case_id=test_case.id,
340
+ passed=passed,
341
+ actual_output={"response_count": len(responses)},
342
+ execution_time_ms=elapsed_ms,
343
+ error=None,
344
+ )
345
+
346
+ except Exception as exc: # noqa: BLE001
347
+ elapsed_ms = (time.monotonic() - start) * 1000.0
348
+ status_code = _extract_grpc_status_code(exc)
349
+ passed = status_code in expected_codes if status_code is not None else False
350
+ status_name = _STATUS_NAMES.get(status_code or 2, "UNKNOWN")
351
+ return TestResult(
352
+ test_case_id=test_case.id,
353
+ passed=passed,
354
+ actual_output=None,
355
+ execution_time_ms=elapsed_ms,
356
+ error=f"gRPC {status_name}: {exc}" if not passed else None,
357
+ )
358
+
359
+ async def _execute_client_streaming(
360
+ self,
361
+ test_case: TestCase,
362
+ method_path: str,
363
+ requests: list[dict],
364
+ expected_codes: frozenset,
365
+ ) -> TestResult:
366
+ """Execute a client-streaming RPC call."""
367
+ start = time.monotonic()
368
+ try:
369
+ channel = self._make_channel()
370
+
371
+ def _request_iter() -> Iterator[bytes]:
372
+ for req in requests:
373
+ yield self._encode_request(req)
374
+
375
+ stub = channel.stream_unary(
376
+ method_path,
377
+ request_serializer=_passthrough,
378
+ response_deserializer=_passthrough,
379
+ )
380
+
381
+ response = stub(_request_iter(), timeout=self.timeout, metadata=self.call_metadata)
382
+ elapsed_ms = (time.monotonic() - start) * 1000.0
383
+ passed = _OK in expected_codes
384
+ channel.close()
385
+
386
+ return TestResult(
387
+ test_case_id=test_case.id,
388
+ passed=passed,
389
+ actual_output={"raw_bytes": len(response) if response else 0},
390
+ execution_time_ms=elapsed_ms,
391
+ error=None if passed else "Unexpected gRPC status",
392
+ )
393
+
394
+ except Exception as exc: # noqa: BLE001
395
+ elapsed_ms = (time.monotonic() - start) * 1000.0
396
+ status_code = _extract_grpc_status_code(exc)
397
+ passed = status_code in expected_codes if status_code is not None else False
398
+ status_name = _STATUS_NAMES.get(status_code or 2, "UNKNOWN")
399
+ return TestResult(
400
+ test_case_id=test_case.id,
401
+ passed=passed,
402
+ actual_output=None,
403
+ execution_time_ms=elapsed_ms,
404
+ error=f"gRPC {status_name}: {exc}" if not passed else None,
405
+ )
406
+
407
+ async def _execute_bidi_streaming(
408
+ self,
409
+ test_case: TestCase,
410
+ method_path: str,
411
+ requests: list[dict],
412
+ expected_codes: frozenset,
413
+ ) -> TestResult:
414
+ """Execute a bidirectional streaming RPC call."""
415
+ start = time.monotonic()
416
+ try:
417
+ channel = self._make_channel()
418
+
419
+ def _request_iter() -> Iterator[bytes]:
420
+ for req in requests:
421
+ yield self._encode_request(req)
422
+
423
+ stub = channel.stream_stream(
424
+ method_path,
425
+ request_serializer=_passthrough,
426
+ response_deserializer=_passthrough,
427
+ )
428
+
429
+ responses: list[Any] = []
430
+ for resp in stub(_request_iter(), timeout=self.timeout, metadata=self.call_metadata):
431
+ responses.append(resp)
432
+ if len(responses) >= self.max_streaming_responses:
433
+ break
434
+
435
+ elapsed_ms = (time.monotonic() - start) * 1000.0
436
+ passed = _OK in expected_codes
437
+ channel.close()
438
+
439
+ return TestResult(
440
+ test_case_id=test_case.id,
441
+ passed=passed,
442
+ actual_output={"response_count": len(responses)},
443
+ execution_time_ms=elapsed_ms,
444
+ error=None,
445
+ )
446
+
447
+ except Exception as exc: # noqa: BLE001
448
+ elapsed_ms = (time.monotonic() - start) * 1000.0
449
+ status_code = _extract_grpc_status_code(exc)
450
+ passed = status_code in expected_codes if status_code is not None else False
451
+ status_name = _STATUS_NAMES.get(status_code or 2, "UNKNOWN")
452
+ return TestResult(
453
+ test_case_id=test_case.id,
454
+ passed=passed,
455
+ actual_output=None,
456
+ execution_time_ms=elapsed_ms,
457
+ error=f"gRPC {status_name}: {exc}" if not passed else None,
458
+ )
459
+
460
+
461
+ def _extract_grpc_status_code(exc: Exception) -> Optional[int]:
462
+ """Extract a numeric gRPC status code from an RpcError exception."""
463
+ try:
464
+ # grpc.RpcError has a .code() method
465
+ code_obj = exc.code() # type: ignore[attr-defined]
466
+ # grpc.StatusCode enum has .value[0] (the numeric code)
467
+ return int(code_obj.value[0])
468
+ except Exception: # noqa: BLE001
469
+ return None
@@ -0,0 +1,237 @@
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
+ """HTTP System-Under-Test implementation.
7
+
8
+ :class:`HttpApiSUT` is a concrete :class:`~mannf.core.distributed.system_under_test.SystemUnderTest`
9
+ that executes test cases against real HTTP APIs using ``httpx``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import time
15
+ import logging
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ import httpx
19
+
20
+ from mannf.core.distributed.endpoint import Endpoint, EndpointRegistry
21
+ from mannf.core.distributed.system_under_test import SystemUnderTest
22
+ from mannf.product.integrations.auth import AuthStrategy, NoAuth
23
+ from mannf.core.testing.models import TestCase, TestResult
24
+
25
+ # Imported lazily to avoid a hard circular dependency
26
+ _RegressionRecorder = None
27
+
28
+
29
+ def _get_recorder_type():
30
+ global _RegressionRecorder
31
+ if _RegressionRecorder is None:
32
+ from mannf.product.regression.recorder import RegressionRecorder # noqa: PLC0415
33
+ _RegressionRecorder = RegressionRecorder
34
+ return _RegressionRecorder
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ # HTTP methods that send a request body
39
+ _BODY_METHODS = {"POST", "PUT", "PATCH"}
40
+
41
+
42
+ class HttpApiSUT(SystemUnderTest):
43
+ """Execute test cases against a real HTTP API.
44
+
45
+ Parameters
46
+ ----------
47
+ base_url:
48
+ Root URL of the API under test (e.g. ``"http://localhost:8080"``).
49
+ default_headers:
50
+ Headers added to every request (e.g. ``{"Accept": "application/json"}``).
51
+ timeout:
52
+ Default request timeout in seconds.
53
+ auth:
54
+ :class:`~mannf.product.integrations.auth.AuthStrategy` applied before each
55
+ request. Defaults to :class:`~mannf.product.integrations.auth.NoAuth`.
56
+ expected_status_codes:
57
+ Iterable of HTTP status codes considered passing. Defaults to all
58
+ 2xx codes (200–299).
59
+ latency_sla_ms:
60
+ Maximum acceptable response time in milliseconds. A response that
61
+ exceeds this limit is treated as a failure even if the status code is
62
+ otherwise acceptable. ``None`` means no SLA check.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ base_url: str,
68
+ default_headers: Optional[Dict[str, str]] = None,
69
+ timeout: float = 10.0,
70
+ auth: Optional[AuthStrategy] = None,
71
+ expected_status_codes: Optional[List[int]] = None,
72
+ latency_sla_ms: Optional[float] = None,
73
+ recorder: Optional[Any] = None,
74
+ ) -> None:
75
+ self.base_url = base_url.rstrip("/")
76
+ self.default_headers: Dict[str, str] = default_headers or {}
77
+ self.timeout = timeout
78
+ self.auth: AuthStrategy = auth or NoAuth()
79
+ self.latency_sla_ms = latency_sla_ms
80
+ self.recorder = recorder
81
+
82
+ if expected_status_codes is not None:
83
+ self._expected_status_codes: Optional[frozenset] = frozenset(expected_status_codes)
84
+ else:
85
+ self._expected_status_codes = None # 2xx check applied dynamically
86
+
87
+ self._registry = EndpointRegistry()
88
+ # Register a single "default" endpoint representing the base URL
89
+ from urllib.parse import urlparse
90
+ parsed = urlparse(self.base_url)
91
+ host = parsed.hostname or "localhost"
92
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
93
+ self._registry.register(
94
+ Endpoint(
95
+ name="default",
96
+ host=host,
97
+ port=port,
98
+ protocol=parsed.scheme or "http",
99
+ )
100
+ )
101
+
102
+ # ------------------------------------------------------------------
103
+ # SystemUnderTest interface
104
+ # ------------------------------------------------------------------
105
+
106
+ @property
107
+ def registry(self) -> EndpointRegistry:
108
+ return self._registry
109
+
110
+ async def execute(self, test_case: TestCase) -> TestResult:
111
+ """Make an HTTP request described by *test_case* and return the result.
112
+
113
+ ``test_case.target`` is treated as the endpoint path (e.g. ``"/users"``).
114
+ ``test_case.inputs`` may contain:
115
+
116
+ * ``method`` – HTTP verb (default ``"GET"``).
117
+ * ``headers`` – per-request headers merged on top of *default_headers*.
118
+ * ``body`` – raw request body bytes or string.
119
+ * ``json`` – JSON-serialisable dict sent as the request body.
120
+ * ``query_params`` – dict of URL query parameters.
121
+ * ``expected_status_codes`` – per-test override of acceptable status
122
+ codes (list of ints).
123
+ """
124
+ method: str = str(test_case.inputs.get("method", "GET")).upper()
125
+ path: str = test_case.target
126
+ if not path.startswith("/"):
127
+ path = "/" + path
128
+ url = self.base_url + path
129
+
130
+ # Merge headers
131
+ merged_headers: Dict[str, str] = {**self.default_headers}
132
+ extra_headers = test_case.inputs.get("headers") or {}
133
+ merged_headers.update(extra_headers)
134
+
135
+ request_kwargs: Dict[str, Any] = {
136
+ "method": method,
137
+ "url": url,
138
+ "headers": merged_headers,
139
+ "timeout": self.timeout,
140
+ }
141
+
142
+ query_params = test_case.inputs.get("query_params")
143
+ if query_params:
144
+ request_kwargs["params"] = query_params
145
+
146
+ if method in _BODY_METHODS:
147
+ if "json" in test_case.inputs:
148
+ request_kwargs["json"] = test_case.inputs["json"]
149
+ elif "body" in test_case.inputs:
150
+ request_kwargs["content"] = test_case.inputs["body"]
151
+
152
+ # Apply auth strategy
153
+ request_kwargs = await self.auth.apply(request_kwargs)
154
+
155
+ # Determine per-test expected status codes
156
+ per_test_codes = test_case.inputs.get("expected_status_codes")
157
+ if per_test_codes is not None:
158
+ expected_codes: Optional[frozenset] = frozenset(per_test_codes)
159
+ else:
160
+ expected_codes = self._expected_status_codes
161
+
162
+ # Execute the request
163
+ start = time.monotonic()
164
+ try:
165
+ async with httpx.AsyncClient() as client:
166
+ response = await client.request(**request_kwargs)
167
+ elapsed_ms = (time.monotonic() - start) * 1000.0
168
+
169
+ # Determine pass/fail
170
+ if expected_codes is not None:
171
+ status_ok = response.status_code in expected_codes
172
+ else:
173
+ status_ok = 200 <= response.status_code < 300
174
+
175
+ latency_ok = (
176
+ self.latency_sla_ms is None or elapsed_ms <= self.latency_sla_ms
177
+ )
178
+ passed = status_ok and latency_ok
179
+
180
+ error_msg: Optional[str] = None
181
+ if not status_ok:
182
+ error_msg = f"Unexpected status {response.status_code}"
183
+ elif not latency_ok:
184
+ error_msg = (
185
+ f"Latency {elapsed_ms:.1f}ms exceeds SLA {self.latency_sla_ms}ms"
186
+ )
187
+
188
+ # Try to parse response body as JSON, fall back to text
189
+ try:
190
+ actual_output: Any = response.json()
191
+ except Exception:
192
+ actual_output = response.text
193
+
194
+ if self.recorder is not None:
195
+ self.recorder.record(
196
+ test_case_id=test_case.id,
197
+ method=method,
198
+ url=url,
199
+ request_headers=merged_headers,
200
+ request_body=request_kwargs.get("json") or request_kwargs.get("content"),
201
+ query_params=query_params,
202
+ status_code=response.status_code,
203
+ response_headers=dict(response.headers),
204
+ response_body=actual_output,
205
+ latency_ms=elapsed_ms,
206
+ passed=passed,
207
+ error=error_msg,
208
+ )
209
+
210
+ return TestResult(
211
+ test_case_id=test_case.id,
212
+ passed=passed,
213
+ actual_output=actual_output,
214
+ execution_time_ms=elapsed_ms,
215
+ error=error_msg,
216
+ )
217
+
218
+ except httpx.TimeoutException as exc:
219
+ elapsed_ms = (time.monotonic() - start) * 1000.0
220
+ logger.warning("HttpApiSUT: timeout for %s %s — %s", method, url, exc)
221
+ return TestResult(
222
+ test_case_id=test_case.id,
223
+ passed=False,
224
+ actual_output=None,
225
+ execution_time_ms=elapsed_ms,
226
+ error=f"Request timed out: {exc}",
227
+ )
228
+ except httpx.RequestError as exc:
229
+ elapsed_ms = (time.monotonic() - start) * 1000.0
230
+ logger.warning("HttpApiSUT: connection error for %s %s — %s", method, url, exc)
231
+ return TestResult(
232
+ test_case_id=test_case.id,
233
+ passed=False,
234
+ actual_output=None,
235
+ execution_time_ms=elapsed_ms,
236
+ error=f"Connection error: {exc}",
237
+ )