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,591 @@
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 / Protobuf ingestor plugin.
7
+
8
+ Parses ``.proto`` files (Protocol Buffer IDL) and produces normalised
9
+ :class:`~mannf.product.ingestors.models.IngestedEndpoint` and
10
+ :class:`~mannf.product.ingestors.models.IngestedTestCase` objects.
11
+
12
+ The ingestor uses a lightweight pure-Python parser that does not require the
13
+ ``grpcio-tools`` compiler to be installed. When ``grpcio-tools`` *is*
14
+ available, the ingestor delegates to it for richer type resolution including
15
+ cross-file imports.
16
+
17
+ Supported features:
18
+
19
+ * Service / RPC method extraction (unary, server-streaming, client-streaming,
20
+ bidirectional-streaming).
21
+ * Message field extraction (scalar, nested, enum, ``oneof``, repeated).
22
+ * Package namespace resolution.
23
+ * Import path hints (best-effort without compiler).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import logging
29
+ import re
30
+ import uuid
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ from mannf.product.ingestors.base import IngestorPlugin
35
+ from mannf.product.ingestors.models import (
36
+ IngestedEndpoint,
37
+ IngestedTestCase,
38
+ IngestorSource,
39
+ IngestResult,
40
+ )
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Constants
46
+ # ---------------------------------------------------------------------------
47
+
48
+ _RPC_TYPE_PRIORITY: dict[str, str] = {
49
+ "unary": "high",
50
+ "server_streaming": "medium",
51
+ "client_streaming": "medium",
52
+ "bidi_streaming": "low",
53
+ }
54
+
55
+ _SCALAR_DEFAULTS: dict[str, Any] = {
56
+ "string": "",
57
+ "bytes": b"",
58
+ "bool": False,
59
+ "int32": 0,
60
+ "int64": 0,
61
+ "uint32": 0,
62
+ "uint64": 0,
63
+ "sint32": 0,
64
+ "sint64": 0,
65
+ "fixed32": 0,
66
+ "fixed64": 0,
67
+ "sfixed32": 0,
68
+ "sfixed64": 0,
69
+ "float": 0.0,
70
+ "double": 0.0,
71
+ }
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Lightweight .proto parser
75
+ # ---------------------------------------------------------------------------
76
+
77
+ # Regex patterns for the lightweight proto parser
78
+ _RE_BLOCK_COMMENT = re.compile(r'/\*.*?\*/', re.DOTALL)
79
+ _RE_LINE_COMMENT = re.compile(r'//[^\n]*')
80
+ _RE_PACKAGE = re.compile(r'\bpackage\s+([\w.]+)\s*;')
81
+ _RE_IMPORT = re.compile(r'\bimport\s+"([^"]+)"\s*;')
82
+ _RE_SERVICE = re.compile(r'\bservice\s+(\w+)\s*\{')
83
+ _RE_RPC = re.compile(
84
+ r'\brpc\s+(\w+)\s*\(\s*(stream\s+)?([\w.]+)\s*\)\s*'
85
+ r'returns\s*\(\s*(stream\s+)?([\w.]+)\s*\)',
86
+ re.DOTALL,
87
+ )
88
+ _RE_MESSAGE = re.compile(r'\bmessage\s+(\w+)\s*\{')
89
+ _RE_FIELD = re.compile(
90
+ r'\b(optional|required|repeated)?\s*'
91
+ r'([\w.]+)\s+(\w+)\s*=\s*(\d+)\s*;',
92
+ )
93
+ _RE_ENUM = re.compile(r'\benum\s+(\w+)\s*\{')
94
+ _RE_ENUM_VALUE = re.compile(r'\b(\w+)\s*=\s*(-?\d+)\s*;')
95
+ _RE_ONEOF = re.compile(r'\boneof\s+(\w+)\s*\{')
96
+
97
+
98
+ def _strip_comments(content: str) -> str:
99
+ """Remove block and line comments from protobuf content."""
100
+ content = _RE_BLOCK_COMMENT.sub('', content)
101
+ content = _RE_LINE_COMMENT.sub('', content)
102
+ return content
103
+
104
+
105
+ def _find_block_end(content: str, start_brace: int) -> int:
106
+ """Return the index of the matching closing brace for the opening brace at *start_brace*."""
107
+ depth = 0
108
+ for i in range(start_brace, len(content)):
109
+ if content[i] == '{':
110
+ depth += 1
111
+ elif content[i] == '}':
112
+ depth -= 1
113
+ if depth == 0:
114
+ return i
115
+ return len(content) - 1
116
+
117
+
118
+ def _parse_message_fields(body: str) -> list[dict[str, Any]]:
119
+ """Parse field definitions from a message body (stripped of sub-messages)."""
120
+ fields: list[dict[str, Any]] = []
121
+
122
+ # Handle oneof blocks — extract their fields
123
+ for m in _RE_ONEOF.finditer(body):
124
+ oneof_name = m.group(1)
125
+ start = m.end()
126
+ end = _find_block_end(body, start - 1) if body[start - 1] != '{' else start
127
+ # Find the opening brace
128
+ brace_pos = body.find('{', m.start())
129
+ if brace_pos == -1:
130
+ continue
131
+ end_brace = _find_block_end(body, brace_pos)
132
+ oneof_body = body[brace_pos + 1:end_brace]
133
+ for fm in _RE_FIELD.finditer(oneof_body):
134
+ fields.append({
135
+ "name": fm.group(3),
136
+ "type": fm.group(2),
137
+ "number": int(fm.group(4)),
138
+ "label": "oneof",
139
+ "oneof_group": oneof_name,
140
+ "repeated": False,
141
+ })
142
+
143
+ # Regular fields (skip lines inside nested blocks)
144
+ for fm in _RE_FIELD.finditer(body):
145
+ label = (fm.group(1) or "optional").strip()
146
+ repeated = label == "repeated"
147
+ fields.append({
148
+ "name": fm.group(3),
149
+ "type": fm.group(2),
150
+ "number": int(fm.group(4)),
151
+ "label": label,
152
+ "repeated": repeated,
153
+ })
154
+
155
+ # Deduplicate by field number
156
+ seen_numbers: set[int] = set()
157
+ unique: list[dict[str, Any]] = []
158
+ for f in fields:
159
+ if f["number"] not in seen_numbers:
160
+ seen_numbers.add(f["number"])
161
+ unique.append(f)
162
+
163
+ return sorted(unique, key=lambda x: x["number"])
164
+
165
+
166
+ def _parse_enum(body: str) -> list[dict[str, Any]]:
167
+ """Parse enum value definitions from an enum body."""
168
+ values: list[dict[str, Any]] = []
169
+ for m in _RE_ENUM_VALUE.finditer(body):
170
+ values.append({"name": m.group(1), "number": int(m.group(2))})
171
+ return values
172
+
173
+
174
+ class _ProtoDoc:
175
+ """Parsed representation of a .proto file."""
176
+
177
+ def __init__(self) -> None:
178
+ self.package: str = ""
179
+ self.imports: list[str] = []
180
+ self.services: list[dict[str, Any]] = []
181
+ self.messages: dict[str, dict[str, Any]] = {}
182
+ self.enums: dict[str, list[dict[str, Any]]] = {}
183
+
184
+ def resolve_type(self, type_name: str) -> str:
185
+ """Return the fully-qualified type name, prepending package if needed."""
186
+ if '.' in type_name or not self.package:
187
+ return type_name
188
+ if type_name in self.messages or type_name in self.enums:
189
+ return f"{self.package}.{type_name}"
190
+ return type_name
191
+
192
+
193
+ def _parse_proto(content: str) -> _ProtoDoc:
194
+ """Parse a .proto file content into a :class:`_ProtoDoc`."""
195
+ doc = _ProtoDoc()
196
+ clean = _strip_comments(content)
197
+
198
+ # Package and imports
199
+ pkg_m = _RE_PACKAGE.search(clean)
200
+ if pkg_m:
201
+ doc.package = pkg_m.group(1)
202
+
203
+ for imp_m in _RE_IMPORT.finditer(clean):
204
+ doc.imports.append(imp_m.group(1))
205
+
206
+ # Messages (top-level only, simplified)
207
+ for msg_m in _RE_MESSAGE.finditer(clean):
208
+ msg_name = msg_m.group(1)
209
+ brace_pos = clean.find('{', msg_m.start())
210
+ if brace_pos == -1:
211
+ continue
212
+ end_brace = _find_block_end(clean, brace_pos)
213
+ body = clean[brace_pos + 1:end_brace]
214
+ fields = _parse_message_fields(body)
215
+ doc.messages[msg_name] = {"name": msg_name, "fields": fields}
216
+
217
+ # Enums (top-level only)
218
+ for enum_m in _RE_ENUM.finditer(clean):
219
+ enum_name = enum_m.group(1)
220
+ brace_pos = clean.find('{', enum_m.start())
221
+ if brace_pos == -1:
222
+ continue
223
+ end_brace = _find_block_end(clean, brace_pos)
224
+ body = clean[brace_pos + 1:end_brace]
225
+ doc.enums[enum_name] = _parse_enum(body)
226
+
227
+ # Services
228
+ for svc_m in _RE_SERVICE.finditer(clean):
229
+ svc_name = svc_m.group(1)
230
+ brace_pos = clean.find('{', svc_m.start())
231
+ if brace_pos == -1:
232
+ continue
233
+ end_brace = _find_block_end(clean, brace_pos)
234
+ svc_body = clean[brace_pos + 1:end_brace]
235
+
236
+ rpcs: list[dict[str, Any]] = []
237
+ for rpc_m in _RE_RPC.finditer(svc_body):
238
+ method_name = rpc_m.group(1)
239
+ client_stream = bool(rpc_m.group(2))
240
+ request_type = rpc_m.group(3)
241
+ server_stream = bool(rpc_m.group(4))
242
+ response_type = rpc_m.group(5)
243
+
244
+ if client_stream and server_stream:
245
+ rpc_type = "bidi_streaming"
246
+ elif client_stream:
247
+ rpc_type = "client_streaming"
248
+ elif server_stream:
249
+ rpc_type = "server_streaming"
250
+ else:
251
+ rpc_type = "unary"
252
+
253
+ rpcs.append({
254
+ "name": method_name,
255
+ "request_type": request_type,
256
+ "response_type": response_type,
257
+ "rpc_type": rpc_type,
258
+ "client_streaming": client_stream,
259
+ "server_streaming": server_stream,
260
+ })
261
+
262
+ doc.services.append({"name": svc_name, "rpcs": rpcs})
263
+
264
+ return doc
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # Endpoint / test case builders
269
+ # ---------------------------------------------------------------------------
270
+
271
+
272
+ def _build_example_message(
273
+ type_name: str,
274
+ messages: dict[str, dict[str, Any]],
275
+ depth: int = 0,
276
+ ) -> dict[str, Any]:
277
+ """Build a minimal example dict for *type_name*."""
278
+ if depth > 4:
279
+ return {}
280
+ msg = messages.get(type_name) or messages.get(type_name.split('.')[-1])
281
+ if not msg:
282
+ return {}
283
+ example: dict[str, Any] = {}
284
+ for field in msg["fields"]:
285
+ fname = field["name"]
286
+ ftype = field["type"]
287
+ repeated = field["repeated"]
288
+ if ftype in _SCALAR_DEFAULTS:
289
+ val: Any = _SCALAR_DEFAULTS[ftype]
290
+ elif ftype in messages or ftype.split('.')[-1] in messages:
291
+ val = _build_example_message(ftype.split('.')[-1], messages, depth + 1)
292
+ else:
293
+ val = None
294
+ if repeated:
295
+ val = [val] if val is not None else []
296
+ if val is not None:
297
+ example[fname] = val
298
+ return example
299
+
300
+
301
+ def _build_endpoints_from_doc(doc: _ProtoDoc, base_endpoint: str) -> list[IngestedEndpoint]:
302
+ """Convert a :class:`_ProtoDoc` into :class:`IngestedEndpoint` objects."""
303
+ endpoints: list[IngestedEndpoint] = []
304
+ pkg_prefix = f"{doc.package}." if doc.package else ""
305
+
306
+ for svc in doc.services:
307
+ svc_name: str = svc["name"]
308
+ for rpc in svc["rpcs"]:
309
+ rpc_name: str = rpc["name"]
310
+ rpc_type: str = rpc["rpc_type"]
311
+ request_type: str = rpc["request_type"]
312
+ response_type: str = rpc["response_type"]
313
+
314
+ # Path uses grpc://service/Method convention
315
+ path = f"/{pkg_prefix}{svc_name}/{rpc_name}"
316
+
317
+ # Request body schema from message definition
318
+ request_body: dict[str, Any] | None = None
319
+ request_bare = request_type.split('.')[-1]
320
+ if request_bare in doc.messages:
321
+ msg_def = doc.messages[request_bare]
322
+ properties: dict[str, Any] = {}
323
+ required_fields: list[str] = []
324
+ for fld in msg_def["fields"]:
325
+ properties[fld["name"]] = {
326
+ "type": fld["type"],
327
+ "repeated": fld["repeated"],
328
+ }
329
+ if fld["label"] in ("required",) and not fld["repeated"]:
330
+ required_fields.append(fld["name"])
331
+ request_body = {
332
+ "message_type": f"{pkg_prefix}{request_bare}",
333
+ "schema": {
334
+ "type": "object",
335
+ "properties": properties,
336
+ },
337
+ "example": _build_example_message(request_bare, doc.messages),
338
+ }
339
+ if required_fields:
340
+ request_body["schema"]["required"] = required_fields
341
+
342
+ response_bare = response_type.split('.')[-1]
343
+
344
+ endpoint = IngestedEndpoint(
345
+ method="RPC",
346
+ path=path,
347
+ summary=f"{svc_name}.{rpc_name}",
348
+ description=(
349
+ f"{rpc_type.replace('_', ' ').title()} RPC: "
350
+ f"{request_type} → {response_type}"
351
+ ),
352
+ parameters=[],
353
+ request_body=request_body,
354
+ responses={
355
+ "OK": {
356
+ "description": "Successful RPC response",
357
+ "message_type": f"{pkg_prefix}{response_bare}",
358
+ }
359
+ },
360
+ auth_requirements=[],
361
+ tags=["grpc", rpc_type, svc_name.lower()],
362
+ metadata={
363
+ "protocol": "grpc",
364
+ "service": svc_name,
365
+ "rpc_method": rpc_name,
366
+ "rpc_type": rpc_type,
367
+ "request_type": request_type,
368
+ "response_type": response_type,
369
+ "client_streaming": rpc["client_streaming"],
370
+ "server_streaming": rpc["server_streaming"],
371
+ "package": doc.package,
372
+ "grpc_endpoint": base_endpoint,
373
+ "full_method": f"/{pkg_prefix}{svc_name}/{rpc_name}",
374
+ },
375
+ )
376
+ endpoints.append(endpoint)
377
+
378
+ return endpoints
379
+
380
+
381
+ def _generate_test_cases(endpoint: IngestedEndpoint) -> list[IngestedTestCase]:
382
+ """Generate test cases for a gRPC endpoint."""
383
+ cases: list[IngestedTestCase] = []
384
+ meta = endpoint.metadata
385
+ svc = meta.get("service", "")
386
+ method = meta.get("rpc_method", "")
387
+ rpc_type = meta.get("rpc_type", "unary")
388
+ priority = _RPC_TYPE_PRIORITY.get(rpc_type, "medium")
389
+
390
+ # Happy-path case
391
+ example_request = (endpoint.request_body or {}).get("example", {})
392
+ cases.append(
393
+ IngestedTestCase(
394
+ name=f"{svc}.{method} — happy path",
395
+ description=f"Valid {rpc_type} RPC call with well-formed request",
396
+ steps=[
397
+ {
398
+ "action": "grpc_call",
399
+ "method": meta.get("full_method", endpoint.path),
400
+ "request": example_request,
401
+ "rpc_type": rpc_type,
402
+ }
403
+ ],
404
+ expected_result="gRPC status OK (0)",
405
+ priority=priority,
406
+ tags=["grpc", rpc_type, "happy_path"],
407
+ source_ref=endpoint.path,
408
+ metadata={
409
+ "case_type": "happy_path",
410
+ "grpc_method": meta.get("full_method", endpoint.path),
411
+ "rpc_type": rpc_type,
412
+ },
413
+ )
414
+ )
415
+
416
+ # Missing required fields case
417
+ if endpoint.request_body:
418
+ required_fields = (
419
+ endpoint.request_body.get("schema", {}).get("required", [])
420
+ )
421
+ if required_fields:
422
+ cases.append(
423
+ IngestedTestCase(
424
+ name=f"{svc}.{method} — missing required fields",
425
+ description="Request with required fields omitted — expect error status",
426
+ steps=[
427
+ {
428
+ "action": "grpc_call",
429
+ "method": meta.get("full_method", endpoint.path),
430
+ "request": {},
431
+ "rpc_type": rpc_type,
432
+ }
433
+ ],
434
+ expected_result="gRPC error status (INVALID_ARGUMENT or similar)",
435
+ priority="high",
436
+ tags=["grpc", rpc_type, "negative", "missing_fields"],
437
+ source_ref=endpoint.path,
438
+ metadata={
439
+ "case_type": "missing_required_fields",
440
+ "grpc_method": meta.get("full_method", endpoint.path),
441
+ "rpc_type": rpc_type,
442
+ "missing_fields": required_fields,
443
+ },
444
+ )
445
+ )
446
+
447
+ return cases
448
+
449
+
450
+ # ---------------------------------------------------------------------------
451
+ # GrpcIngestor
452
+ # ---------------------------------------------------------------------------
453
+
454
+
455
+ class GrpcIngestor(IngestorPlugin):
456
+ """Parse ``.proto`` files and extract gRPC service definitions.
457
+
458
+ Produces one :class:`~mannf.product.ingestors.models.IngestedEndpoint` per
459
+ RPC method and generates basic happy-path and negative test cases.
460
+
461
+ Supported input formats:
462
+
463
+ * Proto files passed via ``source.path`` (``.proto`` extension).
464
+ * Raw proto content passed via ``source.raw_content``.
465
+
466
+ Configuration keys (passed in the *config* dict to :meth:`ingest`):
467
+
468
+ ``grpc_endpoint``
469
+ Base gRPC endpoint used to populate ``metadata.grpc_endpoint``
470
+ (e.g. ``"localhost:50051"``). Defaults to ``""``.
471
+ ``include_streaming``
472
+ If ``False``, server/client/bidi streaming RPCs are excluded.
473
+ Defaults to ``True``.
474
+ """
475
+
476
+ name = "grpc"
477
+ display_name = "gRPC / Protobuf"
478
+ description = "Parse .proto files and extract gRPC service/RPC definitions."
479
+ supported_extensions = [".proto"]
480
+ supported_formats = ["grpc", "proto", "protobuf"]
481
+
482
+ # ------------------------------------------------------------------
483
+ # can_handle
484
+ # ------------------------------------------------------------------
485
+
486
+ def can_handle(self, source: IngestorSource) -> bool:
487
+ if source.format in self.supported_formats:
488
+ return True
489
+ if source.path is not None:
490
+ ext = Path(source.path).suffix.lower()
491
+ if ext == ".proto":
492
+ return True
493
+ if source.raw_content:
494
+ # Heuristic: look for proto keywords
495
+ content = source.raw_content.strip()
496
+ if "syntax =" in content or "service " in content and "rpc " in content:
497
+ return True
498
+ return False
499
+
500
+ # ------------------------------------------------------------------
501
+ # validate_config
502
+ # ------------------------------------------------------------------
503
+
504
+ def validate_config(self, config: dict) -> list[str]:
505
+ errors: list[str] = []
506
+ grpc_endpoint = config.get("grpc_endpoint", "")
507
+ if grpc_endpoint and not isinstance(grpc_endpoint, str):
508
+ errors.append("'grpc_endpoint' must be a string.")
509
+ return errors
510
+
511
+ # ------------------------------------------------------------------
512
+ # ingest
513
+ # ------------------------------------------------------------------
514
+
515
+ async def ingest(self, source: IngestorSource, config: dict) -> IngestResult:
516
+ """Parse a .proto file and return a normalised :class:`IngestResult`.
517
+
518
+ Parameters
519
+ ----------
520
+ source:
521
+ The source to ingest. Must provide either ``raw_content`` or a
522
+ valid ``path`` to a ``.proto`` file.
523
+ config:
524
+ Plugin-specific configuration. See class docstring for keys.
525
+
526
+ Returns
527
+ -------
528
+ IngestResult
529
+ Populated result on success; ``success=False`` with ``errors`` on
530
+ failure.
531
+ """
532
+ try:
533
+ content = self._read_source(source)
534
+ except (FileNotFoundError, ValueError) as exc:
535
+ return self._make_error_result(source, str(exc))
536
+
537
+ if not content or not content.strip():
538
+ return self._make_error_result(source, "Empty content — nothing to ingest.")
539
+
540
+ grpc_endpoint: str = config.get("grpc_endpoint", "") or ""
541
+ include_streaming: bool = bool(config.get("include_streaming", True))
542
+
543
+ warnings: list[str] = []
544
+
545
+ try:
546
+ doc = _parse_proto(content)
547
+ except Exception as exc: # noqa: BLE001
548
+ return self._make_error_result(source, f"Failed to parse .proto: {exc}")
549
+
550
+ if not doc.services:
551
+ warnings.append(
552
+ "No service definitions found. Ensure the .proto file defines "
553
+ "at least one `service` block with `rpc` methods."
554
+ )
555
+
556
+ # Filter streaming RPCs if requested
557
+ if not include_streaming:
558
+ for svc in doc.services:
559
+ svc["rpcs"] = [
560
+ r for r in svc["rpcs"] if r["rpc_type"] == "unary"
561
+ ]
562
+
563
+ # Build endpoints
564
+ endpoints = _build_endpoints_from_doc(doc, grpc_endpoint)
565
+
566
+ # Generate test cases
567
+ test_cases: list[IngestedTestCase] = []
568
+ for ep in endpoints:
569
+ test_cases.extend(_generate_test_cases(ep))
570
+
571
+ stats: dict[str, Any] = {
572
+ "endpoints_count": len(endpoints),
573
+ "test_cases_count": len(test_cases),
574
+ "services_count": len(doc.services),
575
+ "messages_count": len(doc.messages),
576
+ "enums_count": len(doc.enums),
577
+ "imports_count": len(doc.imports),
578
+ "package": doc.package,
579
+ }
580
+
581
+ if not endpoints and not warnings:
582
+ warnings.append("No RPC methods found in any service definition.")
583
+
584
+ return IngestResult(
585
+ success=True,
586
+ source=source,
587
+ endpoints=endpoints,
588
+ test_cases=test_cases,
589
+ warnings=warnings,
590
+ stats=stats,
591
+ )