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,467 @@
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
+ """Postman Collection v2.1 parser.
7
+
8
+ Reads a Postman Collection JSON file and auto-generates
9
+ :class:`~mannf.core.testing.models.TestCase` objects covering both positive
10
+ and negative test scenarios. Discovered endpoints are also registered into a
11
+ provided :class:`~mannf.core.distributed.endpoint.EndpointRegistry`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import json
18
+ import logging
19
+ import re
20
+ from pathlib import Path
21
+ from typing import Any, Dict, List, Optional, Tuple
22
+ from urllib.parse import urlparse, urlunparse
23
+
24
+ from mannf.core.distributed.endpoint import Endpoint, EndpointRegistry
25
+ from mannf.core.testing.models import TestCase
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Helpers
31
+ # ---------------------------------------------------------------------------
32
+
33
+ _VAR_RE = re.compile(r"\{\{([^}]+)\}\}")
34
+
35
+
36
+ def _resolve_variables(text: str, variables: Dict[str, str]) -> str:
37
+ """Replace ``{{varName}}`` patterns in *text* with values from *variables*."""
38
+ if not text:
39
+ return text
40
+
41
+ def _replace(match: re.Match) -> str: # type: ignore[type-arg]
42
+ return variables.get(match.group(1), match.group(0))
43
+
44
+ return _VAR_RE.sub(_replace, text)
45
+
46
+
47
+ def _path_to_endpoint_name(path: str) -> str:
48
+ """Turn ``/users/:id/orders`` into ``users-id-orders``."""
49
+ name = re.sub(r"[{}:()]", "", path)
50
+ name = re.sub(r"[^a-zA-Z0-9]+", "-", name).strip("-")
51
+ return name or "root"
52
+
53
+
54
+ def _extract_base_url_from_collection(
55
+ collection: Dict[str, Any],
56
+ variables: Dict[str, str],
57
+ ) -> str:
58
+ """Try to derive a base URL from collection variables or the first request."""
59
+ # 1. Check collection-level variable named "baseUrl" (common convention)
60
+ for var in collection.get("variable", []):
61
+ if var.get("key") == "baseUrl" and var.get("value"):
62
+ return str(var["value"]).rstrip("/")
63
+
64
+ # 2. Look for {{baseUrl}} replacement in variables dict
65
+ if "baseUrl" in variables:
66
+ return variables["baseUrl"].rstrip("/")
67
+
68
+ # 3. Fall back to extracting scheme+host from the first request's URL
69
+ first_url = _first_request_url(collection.get("item", []))
70
+ if first_url:
71
+ first_url = _resolve_variables(first_url, variables)
72
+ parsed = urlparse(first_url)
73
+ if parsed.scheme and parsed.netloc:
74
+ return urlunparse((parsed.scheme, parsed.netloc, "", "", "", ""))
75
+
76
+ return ""
77
+
78
+
79
+ def _first_request_url(items: List[Dict[str, Any]]) -> str:
80
+ """Recursively find the URL string from the first leaf request."""
81
+ for item in items:
82
+ if "request" in item:
83
+ url = item["request"].get("url", "")
84
+ if isinstance(url, dict):
85
+ return url.get("raw", "")
86
+ return str(url)
87
+ if "item" in item:
88
+ result = _first_request_url(item["item"])
89
+ if result:
90
+ return result
91
+ return ""
92
+
93
+
94
+ def _collect_collection_variables(collection: Dict[str, Any]) -> Dict[str, str]:
95
+ """Return a dict of collection-level variable definitions."""
96
+ variables: Dict[str, str] = {}
97
+ for var in collection.get("variable", []):
98
+ key = var.get("key") or ""
99
+ value = var.get("value") or ""
100
+ if key:
101
+ variables[key] = str(value)
102
+ return variables
103
+
104
+
105
+ def _build_auth_headers(
106
+ auth_block: Optional[Dict[str, Any]],
107
+ ) -> Dict[str, str]:
108
+ """Convert a Postman ``auth`` block to HTTP header mappings.
109
+
110
+ Supports: bearer, apikey, basic, noauth (and unknown types → empty).
111
+ """
112
+ if not auth_block:
113
+ return {}
114
+
115
+ auth_type = auth_block.get("type", "").lower()
116
+ headers: Dict[str, str] = {}
117
+
118
+ if auth_type == "bearer":
119
+ token = _get_auth_param(auth_block, "bearer", "token") or ""
120
+ if token:
121
+ headers["Authorization"] = f"Bearer {token}"
122
+
123
+ elif auth_type == "apikey":
124
+ key = _get_auth_param(auth_block, "apikey", "key") or "X-API-Key"
125
+ value = _get_auth_param(auth_block, "apikey", "value") or ""
126
+ in_location = (_get_auth_param(auth_block, "apikey", "in") or "header").lower()
127
+ if in_location == "header" and value:
128
+ headers[key] = value
129
+
130
+ elif auth_type == "basic":
131
+ username = _get_auth_param(auth_block, "basic", "username") or ""
132
+ password = _get_auth_param(auth_block, "basic", "password") or ""
133
+ if username or password:
134
+ encoded = base64.b64encode(f"{username}:{password}".encode()).decode()
135
+ headers["Authorization"] = f"Basic {encoded}"
136
+
137
+ return headers
138
+
139
+
140
+ def _get_auth_param(
141
+ auth_block: Dict[str, Any],
142
+ auth_type: str,
143
+ key: str,
144
+ ) -> Optional[str]:
145
+ """Extract a named parameter value from a Postman auth block."""
146
+ params = auth_block.get(auth_type, [])
147
+ if isinstance(params, list):
148
+ for param in params:
149
+ if param.get("key") == key:
150
+ return param.get("value")
151
+ elif isinstance(params, dict):
152
+ return params.get(key)
153
+ return None
154
+
155
+
156
+ def _parse_url(
157
+ url_obj: Any,
158
+ variables: Dict[str, str],
159
+ ) -> Tuple[str, Dict[str, str]]:
160
+ """Return ``(path, query_params)`` from a Postman URL object or string.
161
+
162
+ Postman variables in path segments and query values are resolved.
163
+ """
164
+ query_params: Dict[str, str] = {}
165
+
166
+ if isinstance(url_obj, str):
167
+ raw = _resolve_variables(url_obj, variables)
168
+ parsed = urlparse(raw)
169
+ path = parsed.path or "/"
170
+ if parsed.query:
171
+ for part in parsed.query.split("&"):
172
+ if "=" in part:
173
+ k, _, v = part.partition("=")
174
+ query_params[k] = v
175
+ return path, query_params
176
+
177
+ if not isinstance(url_obj, dict):
178
+ return "/", query_params
179
+
180
+ # Build path from segments array (preferred) or "raw"
181
+ path_segments = url_obj.get("path", [])
182
+ if path_segments and isinstance(path_segments, list):
183
+ parts: List[str] = []
184
+ for seg in path_segments:
185
+ if isinstance(seg, dict):
186
+ seg_val = seg.get("value", "") or ""
187
+ else:
188
+ seg_val = str(seg)
189
+ parts.append(_resolve_variables(seg_val, variables))
190
+ path = "/" + "/".join(parts)
191
+ else:
192
+ raw = _resolve_variables(url_obj.get("raw", ""), variables)
193
+ parsed = urlparse(raw)
194
+ path = parsed.path or "/"
195
+
196
+ # Query parameters
197
+ for q in url_obj.get("query", []):
198
+ if q.get("disabled"):
199
+ continue
200
+ k = q.get("key", "")
201
+ v = _resolve_variables(q.get("value", "") or "", variables)
202
+ if k:
203
+ query_params[k] = v
204
+
205
+ return path, query_params
206
+
207
+
208
+ def _parse_body(
209
+ body_obj: Optional[Dict[str, Any]],
210
+ variables: Dict[str, str],
211
+ ) -> Tuple[Optional[Any], List[str]]:
212
+ """Return ``(json_body, required_fields)`` from a Postman request body.
213
+
214
+ Only ``raw`` mode with a JSON content-type is handled; all other modes
215
+ return ``(None, [])``.
216
+ """
217
+ if not body_obj:
218
+ return None, []
219
+
220
+ mode = body_obj.get("mode", "")
221
+ if mode != "raw":
222
+ return None, []
223
+
224
+ raw = body_obj.get("raw", "") or ""
225
+ raw = _resolve_variables(raw, variables)
226
+ if not raw.strip():
227
+ return None, []
228
+
229
+ try:
230
+ parsed = json.loads(raw)
231
+ except (ValueError, json.JSONDecodeError):
232
+ return None, []
233
+
234
+ if not isinstance(parsed, dict):
235
+ return parsed, []
236
+
237
+ # Heuristic: treat all top-level keys as required for negative generation
238
+ required = list(parsed.keys())
239
+ return parsed, required
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # Public API
244
+ # ---------------------------------------------------------------------------
245
+
246
+
247
+ class PostmanParser:
248
+ """Parse a Postman Collection v2.1 file and generate :class:`TestCase` objects.
249
+
250
+ Parameters
251
+ ----------
252
+ collection_path:
253
+ Path to the Postman Collection JSON file.
254
+ base_url:
255
+ Override the base URL extracted from the collection. When ``None``
256
+ the parser tries ``{{baseUrl}}`` collection variables first, then
257
+ falls back to the first request's origin.
258
+ registry:
259
+ :class:`EndpointRegistry` to populate with discovered endpoints.
260
+ A fresh registry is created when ``None`` is passed.
261
+ max_tests:
262
+ Maximum number of :class:`TestCase` objects to generate. ``None``
263
+ means no limit.
264
+ variables:
265
+ Extra variable mappings to resolve ``{{varName}}`` patterns. These
266
+ take precedence over collection-level variable definitions.
267
+ """
268
+
269
+ def __init__(
270
+ self,
271
+ collection_path: str,
272
+ base_url: Optional[str] = None,
273
+ registry: Optional[EndpointRegistry] = None,
274
+ max_tests: Optional[int] = None,
275
+ variables: Optional[Dict[str, str]] = None,
276
+ ) -> None:
277
+ self.collection_path = collection_path
278
+ self.collection = self._load_collection(collection_path)
279
+ self.registry = registry if registry is not None else EndpointRegistry()
280
+ self.max_tests = max_tests
281
+
282
+ # Merge variable resolution order: caller-supplied > collection-level
283
+ coll_vars = _collect_collection_variables(self.collection)
284
+ self.variables: Dict[str, str] = {**coll_vars, **(variables or {})}
285
+
286
+ self.base_url = (
287
+ base_url
288
+ or _extract_base_url_from_collection(self.collection, self.variables)
289
+ ).rstrip("/")
290
+
291
+ # Collection-level auth (lower priority than request-level)
292
+ self._collection_auth: Optional[Dict[str, Any]] = self.collection.get("auth")
293
+
294
+ # ------------------------------------------------------------------
295
+ # Public interface
296
+ # ------------------------------------------------------------------
297
+
298
+ def parse(self) -> List[TestCase]:
299
+ """Parse the collection and return all generated :class:`TestCase` objects."""
300
+ test_cases: List[TestCase] = []
301
+ items = self.collection.get("item", [])
302
+ request_items = self._flatten_items(items)
303
+
304
+ for item in request_items:
305
+ cases = self._generate_cases_for_item(item)
306
+ test_cases.extend(cases)
307
+
308
+ if self.max_tests is not None and len(test_cases) >= self.max_tests:
309
+ return test_cases[: self.max_tests]
310
+
311
+ return test_cases
312
+
313
+ # ------------------------------------------------------------------
314
+ # Internal helpers
315
+ # ------------------------------------------------------------------
316
+
317
+ @staticmethod
318
+ def _load_collection(path: str) -> Dict[str, Any]:
319
+ """Load and return a Postman Collection JSON from *path*."""
320
+ text = Path(path).read_text(encoding="utf-8")
321
+ data = json.loads(text)
322
+ # Collections are sometimes wrapped under a "collection" top-level key
323
+ if "collection" in data and isinstance(data["collection"], dict):
324
+ return data["collection"]
325
+ return data
326
+
327
+ def _flatten_items(
328
+ self,
329
+ items: List[Dict[str, Any]],
330
+ ) -> List[Dict[str, Any]]:
331
+ """Recursively walk item/folder trees and return only leaf request items."""
332
+ result: List[Dict[str, Any]] = []
333
+ for item in items:
334
+ if "item" in item:
335
+ # This is a folder — recurse
336
+ result.extend(self._flatten_items(item["item"]))
337
+ elif "request" in item:
338
+ result.append(item)
339
+ return result
340
+
341
+ def _generate_cases_for_item(
342
+ self,
343
+ item: Dict[str, Any],
344
+ ) -> List[TestCase]:
345
+ """Return positive + negative test cases for one Postman request item."""
346
+ cases: List[TestCase] = []
347
+ name = item.get("name", "Unnamed request")
348
+ request = item.get("request", {})
349
+
350
+ method = (request.get("method") or "GET").upper()
351
+
352
+ # --- URL --------------------------------------------------------
353
+ url_obj = request.get("url", "")
354
+ path, query_params = _parse_url(url_obj, self.variables)
355
+
356
+ # --- Auth headers -----------------------------------------------
357
+ request_auth = request.get("auth")
358
+ effective_auth = request_auth or self._collection_auth
359
+ auth_headers = _build_auth_headers(effective_auth)
360
+
361
+ # --- Request headers --------------------------------------------
362
+ headers: Dict[str, str] = dict(auth_headers)
363
+ for hdr in request.get("header", []):
364
+ if hdr.get("disabled"):
365
+ continue
366
+ hdr_key = hdr.get("key", "")
367
+ hdr_val = _resolve_variables(hdr.get("value", "") or "", self.variables)
368
+ if hdr_key:
369
+ headers[hdr_key] = hdr_val
370
+
371
+ # --- Request body -----------------------------------------------
372
+ body_obj = request.get("body")
373
+ json_body, required_fields = _parse_body(body_obj, self.variables)
374
+
375
+ # --- Register endpoint -----------------------------------------
376
+ ep_name = _path_to_endpoint_name(path)
377
+ parsed_base = urlparse(self.base_url) if self.base_url else None
378
+ host = (parsed_base.hostname or "localhost") if parsed_base else "localhost"
379
+ port = (
380
+ parsed_base.port
381
+ or (443 if (parsed_base and parsed_base.scheme == "https") else 80)
382
+ ) if parsed_base else 80
383
+ protocol = (parsed_base.scheme or "http") if parsed_base else "http"
384
+
385
+ endpoint = Endpoint(
386
+ name=ep_name,
387
+ host=host,
388
+ port=port,
389
+ protocol=protocol,
390
+ tags={"path": path},
391
+ )
392
+ self.registry.register(endpoint)
393
+
394
+ # ---- Positive test case ----------------------------------------
395
+ pos_inputs: Dict[str, Any] = {"method": method}
396
+ if query_params:
397
+ pos_inputs["query_params"] = query_params
398
+ if headers:
399
+ pos_inputs["headers"] = headers
400
+ if json_body is not None and method in {"POST", "PUT", "PATCH"}:
401
+ pos_inputs["json"] = json_body
402
+
403
+ cases.append(
404
+ TestCase(
405
+ target=path,
406
+ inputs=pos_inputs,
407
+ expected_behavior=f"[positive] {name}",
408
+ priority=0.5,
409
+ metadata={
410
+ "spec_path": path,
411
+ "method": method,
412
+ "test_type": "positive",
413
+ "generator": "postman",
414
+ },
415
+ )
416
+ )
417
+
418
+ # ---- Negative test cases ----------------------------------------
419
+ # 1. Missing required body field (if JSON body with fields present)
420
+ if json_body is not None and required_fields and method in {"POST", "PUT", "PATCH"}:
421
+ if isinstance(json_body, dict):
422
+ field_to_drop = required_fields[0]
423
+ missing_body = {k: v for k, v in json_body.items() if k != field_to_drop}
424
+ neg_inputs = {
425
+ **pos_inputs,
426
+ "json": missing_body,
427
+ "expected_status_codes": [400, 422],
428
+ }
429
+ cases.append(
430
+ TestCase(
431
+ target=path,
432
+ inputs=neg_inputs,
433
+ expected_behavior=(
434
+ f"[negative] {name}: missing required field '{field_to_drop}'"
435
+ ),
436
+ priority=0.6,
437
+ metadata={
438
+ "spec_path": path,
439
+ "method": method,
440
+ "test_type": "negative",
441
+ "generator": "postman",
442
+ "neg_type": "missing_required_field",
443
+ "missing_field": field_to_drop,
444
+ },
445
+ )
446
+ )
447
+
448
+ # 2. Empty body for requests that have a body defined
449
+ if json_body is not None and method in {"POST", "PUT", "PATCH"}:
450
+ empty_inputs = {**pos_inputs, "json": {}, "expected_status_codes": [400, 422]}
451
+ cases.append(
452
+ TestCase(
453
+ target=path,
454
+ inputs=empty_inputs,
455
+ expected_behavior=f"[negative] {name}: empty body",
456
+ priority=0.6,
457
+ metadata={
458
+ "spec_path": path,
459
+ "method": method,
460
+ "test_type": "negative",
461
+ "generator": "postman",
462
+ "neg_type": "empty_body",
463
+ },
464
+ )
465
+ )
466
+
467
+ return cases