nat-engine 1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mannf/__init__.py +33 -0
- mannf/__main__.py +10 -0
- mannf/_version.py +8 -0
- mannf/agents/__init__.py +7 -0
- mannf/agents/analyzer_agent.py +9 -0
- mannf/agents/base.py +9 -0
- mannf/agents/bdi_agent.py +9 -0
- mannf/agents/belief_state.py +9 -0
- mannf/agents/coordinator_agent.py +9 -0
- mannf/agents/executor_agent.py +9 -0
- mannf/agents/monitor_agent.py +9 -0
- mannf/agents/oracle_agent.py +9 -0
- mannf/agents/planner_agent.py +9 -0
- mannf/agents/test_agent.py +9 -0
- mannf/anomaly/__init__.py +7 -0
- mannf/anomaly/enhanced_detector.py +9 -0
- mannf/cli.py +9 -0
- mannf/core/__init__.py +26 -0
- mannf/core/agents/__init__.py +52 -0
- mannf/core/agents/accessibility_scanner_agent.py +245 -0
- mannf/core/agents/analyzer_agent.py +224 -0
- mannf/core/agents/autonomous_loop_agent.py +1086 -0
- mannf/core/agents/autonomous_loop_models.py +62 -0
- mannf/core/agents/autonomous_run_differ.py +427 -0
- mannf/core/agents/base.py +128 -0
- mannf/core/agents/bdi_agent.py +330 -0
- mannf/core/agents/belief_state.py +202 -0
- mannf/core/agents/browser_coordinator_agent.py +224 -0
- mannf/core/agents/browser_executor_agent.py +410 -0
- mannf/core/agents/coordinator_agent.py +262 -0
- mannf/core/agents/executor_agent.py +222 -0
- mannf/core/agents/monitor_agent.py +188 -0
- mannf/core/agents/oracle_agent.py +150 -0
- mannf/core/agents/performance_testing_agent.py +279 -0
- mannf/core/agents/planner_agent.py +128 -0
- mannf/core/agents/test_agent.py +249 -0
- mannf/core/agents/visual_regression_agent.py +311 -0
- mannf/core/agents/web_crawler_agent.py +510 -0
- mannf/core/agents/worker_pool.py +366 -0
- mannf/core/anomaly/__init__.py +14 -0
- mannf/core/anomaly/enhanced_detector.py +541 -0
- mannf/core/browser/__init__.py +63 -0
- mannf/core/browser/accessibility_scanner.py +424 -0
- mannf/core/browser/discovery_model.py +178 -0
- mannf/core/browser/dom_snapshot.py +349 -0
- mannf/core/browser/ingestor_bridge.py +371 -0
- mannf/core/browser/performance_metrics.py +217 -0
- mannf/core/browser/reflection_analyzer.py +442 -0
- mannf/core/browser/scenario_generator.py +1100 -0
- mannf/core/browser/security_scenario_generator.py +695 -0
- mannf/core/browser/visual_comparer.py +159 -0
- mannf/core/diagnostics/__init__.py +28 -0
- mannf/core/diagnostics/failure_clusterer.py +211 -0
- mannf/core/diagnostics/flake_detector.py +233 -0
- mannf/core/diagnostics/root_cause_analyzer.py +273 -0
- mannf/core/distributed/__init__.py +16 -0
- mannf/core/distributed/endpoint.py +139 -0
- mannf/core/distributed/system_under_test.py +207 -0
- mannf/core/functional_orchestrator.py +428 -0
- mannf/core/messaging/__init__.py +11 -0
- mannf/core/messaging/bus.py +113 -0
- mannf/core/messaging/messages.py +89 -0
- mannf/core/nat_orchestrator.py +342 -0
- mannf/core/neural/__init__.py +183 -0
- mannf/core/orchestrator.py +272 -0
- mannf/core/prioritization/__init__.py +17 -0
- mannf/core/prioritization/adaptive_controller.py +509 -0
- mannf/core/prioritization/belief_prioritizer.py +231 -0
- mannf/core/prioritization/risk_scorer.py +430 -0
- mannf/core/reporting/__init__.py +12 -0
- mannf/core/reporting/unified_report.py +664 -0
- mannf/core/testing/__init__.py +17 -0
- mannf/core/testing/adaptive_controller.py +149 -0
- mannf/core/testing/models.py +179 -0
- mannf/core/validation/__init__.py +10 -0
- mannf/core/validation/self_validation_runner.py +180 -0
- mannf/dashboard/__init__.py +7 -0
- mannf/dashboard/app.py +9 -0
- mannf/dashboard/models.py +9 -0
- mannf/dashboard/static/index.html +2538 -0
- mannf/dashboard/telemetry.py +9 -0
- mannf/distributed/__init__.py +7 -0
- mannf/distributed/endpoint.py +9 -0
- mannf/distributed/system_under_test.py +9 -0
- mannf/healing/__init__.py +7 -0
- mannf/healing/graphql_schema_diff.py +9 -0
- mannf/healing/healer.py +9 -0
- mannf/healing/models.py +9 -0
- mannf/healing/schema_diff.py +9 -0
- mannf/integrations/__init__.py +7 -0
- mannf/integrations/auth.py +9 -0
- mannf/integrations/graphql_parser.py +9 -0
- mannf/integrations/graphql_sut.py +9 -0
- mannf/integrations/http_sut.py +9 -0
- mannf/integrations/openapi_parser.py +9 -0
- mannf/integrations/postman_parser.py +9 -0
- mannf/llm/__init__.py +7 -0
- mannf/llm/anthropic_provider.py +9 -0
- mannf/llm/base.py +9 -0
- mannf/llm/config.py +9 -0
- mannf/llm/factory.py +9 -0
- mannf/llm/openai_provider.py +9 -0
- mannf/llm/prompts.py +9 -0
- mannf/messaging/__init__.py +7 -0
- mannf/messaging/bus.py +9 -0
- mannf/messaging/messages.py +9 -0
- mannf/nat_orchestrator.py +9 -0
- mannf/neural/__init__.py +7 -0
- mannf/orchestrator.py +9 -0
- mannf/prioritization/__init__.py +7 -0
- mannf/prioritization/adaptive_controller.py +9 -0
- mannf/prioritization/belief_prioritizer.py +9 -0
- mannf/prioritization/risk_scorer.py +9 -0
- mannf/product/__init__.py +29 -0
- mannf/product/admin/__init__.py +3 -0
- mannf/product/admin/routes.py +514 -0
- mannf/product/auth/__init__.py +5 -0
- mannf/product/auth/saml.py +212 -0
- mannf/product/billing/__init__.py +5 -0
- mannf/product/billing/audit.py +160 -0
- mannf/product/billing/feature_gates.py +180 -0
- mannf/product/billing/metering.py +179 -0
- mannf/product/billing/notifications.py +181 -0
- mannf/product/billing/plans.py +133 -0
- mannf/product/billing/rate_limits.py +35 -0
- mannf/product/billing/stripe_billing.py +906 -0
- mannf/product/billing/tenant_auth.py +233 -0
- mannf/product/billing/tenant_manager.py +873 -0
- mannf/product/cli.py +3900 -0
- mannf/product/cli_admin.py +408 -0
- mannf/product/dashboard/__init__.py +61 -0
- mannf/product/dashboard/app.py +3567 -0
- mannf/product/dashboard/models.py +460 -0
- mannf/product/dashboard/static/index.html +6347 -0
- mannf/product/dashboard/static/manifest.json +25 -0
- mannf/product/dashboard/static/pwa-icon-192.png +0 -0
- mannf/product/dashboard/static/pwa-icon-512.png +0 -0
- mannf/product/dashboard/static/sw.js +64 -0
- mannf/product/dashboard/telemetry.py +547 -0
- mannf/product/database.py +145 -0
- mannf/product/demo.py +844 -0
- mannf/product/doctor.py +509 -0
- mannf/product/exporters/__init__.py +65 -0
- mannf/product/exporters/azuredevops_exporter.py +257 -0
- mannf/product/exporters/base.py +307 -0
- mannf/product/exporters/bugzilla_exporter.py +200 -0
- mannf/product/exporters/dedup.py +275 -0
- mannf/product/exporters/finding_adapter.py +216 -0
- mannf/product/exporters/github_exporter.py +197 -0
- mannf/product/exporters/gitlab_exporter.py +215 -0
- mannf/product/exporters/jira_exporter.py +180 -0
- mannf/product/exporters/linear_exporter.py +195 -0
- mannf/product/exporters/loader.py +233 -0
- mannf/product/exporters/pagerduty_exporter.py +363 -0
- mannf/product/exporters/sentry_exporter.py +322 -0
- mannf/product/exporters/servicenow_exporter.py +240 -0
- mannf/product/exporters/shortcut_exporter.py +231 -0
- mannf/product/exporters/webhook_exporter.py +383 -0
- mannf/product/formatters/__init__.py +18 -0
- mannf/product/formatters/allure_formatter.py +161 -0
- mannf/product/formatters/ctrf_formatter.py +149 -0
- mannf/product/healing/__init__.py +30 -0
- mannf/product/healing/graphql_schema_diff.py +152 -0
- mannf/product/healing/healer.py +141 -0
- mannf/product/healing/models.py +175 -0
- mannf/product/healing/schema_diff.py +251 -0
- mannf/product/ingestors/__init__.py +77 -0
- mannf/product/ingestors/base.py +256 -0
- mannf/product/ingestors/bgstm_ingestor.py +764 -0
- mannf/product/ingestors/curl_ingestor.py +1019 -0
- mannf/product/ingestors/cypress_ingestor.py +487 -0
- mannf/product/ingestors/gherkin_ingestor.py +967 -0
- mannf/product/ingestors/graphql_ingestor.py +845 -0
- mannf/product/ingestors/grpc_ingestor.py +591 -0
- mannf/product/ingestors/har_ingestor.py +976 -0
- mannf/product/ingestors/loader.py +284 -0
- mannf/product/ingestors/models.py +146 -0
- mannf/product/ingestors/openapi_ingestor.py +606 -0
- mannf/product/ingestors/playwright_ingestor.py +449 -0
- mannf/product/ingestors/postman_ingestor.py +631 -0
- mannf/product/ingestors/traffic_ingestor.py +679 -0
- mannf/product/ingestors/websocket_ingestor.py +526 -0
- mannf/product/integrations/__init__.py +21 -0
- mannf/product/integrations/auth.py +190 -0
- mannf/product/integrations/graphql_parser.py +436 -0
- mannf/product/integrations/graphql_sut.py +247 -0
- mannf/product/integrations/grpc_sut.py +469 -0
- mannf/product/integrations/http_sut.py +237 -0
- mannf/product/integrations/kafka_adapter.py +342 -0
- mannf/product/integrations/openapi_parser.py +513 -0
- mannf/product/integrations/postman_parser.py +467 -0
- mannf/product/integrations/webhook_receiver.py +344 -0
- mannf/product/integrations/websocket_sut.py +434 -0
- mannf/product/llm/__init__.py +25 -0
- mannf/product/llm/anthropic_provider.py +94 -0
- mannf/product/llm/base.py +267 -0
- mannf/product/llm/config.py +48 -0
- mannf/product/llm/factory.py +42 -0
- mannf/product/llm/openai_provider.py +93 -0
- mannf/product/llm/prompts.py +403 -0
- mannf/product/llm/root_cause_service.py +311 -0
- mannf/product/llm/test_plan_models.py +78 -0
- mannf/product/metrics.py +149 -0
- mannf/product/middleware/__init__.py +3 -0
- mannf/product/middleware/audit_middleware.py +112 -0
- mannf/product/middleware/tenant_isolation.py +114 -0
- mannf/product/models.py +347 -0
- mannf/product/notifications/__init__.py +24 -0
- mannf/product/notifications/dispatcher.py +411 -0
- mannf/product/onboarding.py +190 -0
- mannf/product/orchestration/__init__.py +39 -0
- mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
- mannf/product/orchestration/pipeline.py +401 -0
- mannf/product/orchestrator.py +987 -0
- mannf/product/orchestrator_models.py +269 -0
- mannf/product/regression/__init__.py +36 -0
- mannf/product/regression/differ.py +172 -0
- mannf/product/regression/masking.py +100 -0
- mannf/product/regression/models.py +232 -0
- mannf/product/regression/recorder.py +124 -0
- mannf/product/regression/replayer.py +168 -0
- mannf/product/reports/__init__.py +10 -0
- mannf/product/reports/pdf.py +132 -0
- mannf/product/scheduling/__init__.py +57 -0
- mannf/product/scheduling/cron_utils.py +251 -0
- mannf/product/scheduling/engine.py +473 -0
- mannf/product/scheduling/models.py +86 -0
- mannf/product/scheduling/queue.py +894 -0
- mannf/product/scheduling/store.py +235 -0
- mannf/product/security/__init__.py +21 -0
- mannf/product/security/belief_guided.py +143 -0
- mannf/product/security/checks/__init__.py +55 -0
- mannf/product/security/checks/base.py +69 -0
- mannf/product/security/checks/bfla.py +77 -0
- mannf/product/security/checks/bola.py +77 -0
- mannf/product/security/checks/bopla.py +80 -0
- mannf/product/security/checks/broken_auth.py +86 -0
- mannf/product/security/checks/graphql_security.py +299 -0
- mannf/product/security/checks/inventory.py +70 -0
- mannf/product/security/checks/misconfig.py +158 -0
- mannf/product/security/checks/resource_consumption.py +70 -0
- mannf/product/security/checks/sensitive_flows.py +80 -0
- mannf/product/security/checks/ssrf.py +101 -0
- mannf/product/security/checks/unsafe_consumption.py +120 -0
- mannf/product/security/models.py +92 -0
- mannf/product/security/plugin_loader.py +182 -0
- mannf/product/security/reporter.py +92 -0
- mannf/product/security/scanner.py +183 -0
- mannf/product/server.py +6220 -0
- mannf/product/setup_wizard.py +873 -0
- mannf/product/status.py +404 -0
- mannf/product/storage/__init__.py +10 -0
- mannf/product/storage/artifact_store.py +343 -0
- mannf/product/telemetry.py +300 -0
- mannf/product/uninstall.py +169 -0
- mannf/product/upgrade.py +139 -0
- mannf/product/weights/__init__.py +13 -0
- mannf/product/weights/blob_store.py +299 -0
- mannf/product/weights/factory.py +42 -0
- mannf/product/weights/registry.py +159 -0
- mannf/product/weights/store.py +210 -0
- mannf/regression/__init__.py +7 -0
- mannf/regression/differ.py +9 -0
- mannf/regression/masking.py +9 -0
- mannf/regression/models.py +9 -0
- mannf/regression/recorder.py +9 -0
- mannf/regression/replayer.py +9 -0
- mannf/security/__init__.py +7 -0
- mannf/security/belief_guided.py +9 -0
- mannf/security/checks/__init__.py +7 -0
- mannf/security/checks/base.py +9 -0
- mannf/security/checks/bfla.py +9 -0
- mannf/security/checks/bola.py +9 -0
- mannf/security/checks/bopla.py +9 -0
- mannf/security/checks/broken_auth.py +9 -0
- mannf/security/checks/graphql_security.py +9 -0
- mannf/security/checks/inventory.py +9 -0
- mannf/security/checks/misconfig.py +9 -0
- mannf/security/checks/resource_consumption.py +9 -0
- mannf/security/checks/sensitive_flows.py +9 -0
- mannf/security/checks/ssrf.py +9 -0
- mannf/security/checks/unsafe_consumption.py +9 -0
- mannf/security/models.py +9 -0
- mannf/security/reporter.py +9 -0
- mannf/security/scanner.py +9 -0
- mannf/server.py +9 -0
- mannf/testing/__init__.py +7 -0
- mannf/testing/adaptive_controller.py +9 -0
- mannf/testing/models.py +9 -0
- mannf/weights/__init__.py +7 -0
- mannf/weights/registry.py +9 -0
- mannf/weights/store.py +9 -0
- nat_engine-1.dist-info/METADATA +555 -0
- nat_engine-1.dist-info/RECORD +299 -0
- nat_engine-1.dist-info/WHEEL +5 -0
- nat_engine-1.dist-info/entry_points.txt +4 -0
- nat_engine-1.dist-info/licenses/LICENSE +651 -0
- nat_engine-1.dist-info/licenses/NOTICE +178 -0
- nat_engine-1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|
+
)
|