asap-protocol 0.3.0__py3-none-any.whl → 1.0.0__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.
- asap/__init__.py +1 -1
- asap/cli.py +137 -2
- asap/errors.py +167 -0
- asap/examples/README.md +81 -10
- asap/examples/auth_patterns.py +212 -0
- asap/examples/error_recovery.py +248 -0
- asap/examples/long_running.py +287 -0
- asap/examples/mcp_integration.py +240 -0
- asap/examples/multi_step_workflow.py +134 -0
- asap/examples/orchestration.py +293 -0
- asap/examples/rate_limiting.py +137 -0
- asap/examples/run_demo.py +9 -4
- asap/examples/secure_handler.py +84 -0
- asap/examples/state_migration.py +240 -0
- asap/examples/streaming_response.py +108 -0
- asap/examples/websocket_concept.py +129 -0
- asap/mcp/__init__.py +43 -0
- asap/mcp/client.py +224 -0
- asap/mcp/protocol.py +179 -0
- asap/mcp/server.py +333 -0
- asap/mcp/server_runner.py +40 -0
- asap/models/__init__.py +4 -0
- asap/models/base.py +0 -3
- asap/models/constants.py +76 -1
- asap/models/entities.py +58 -7
- asap/models/envelope.py +14 -1
- asap/models/ids.py +8 -4
- asap/models/parts.py +33 -3
- asap/models/validators.py +16 -0
- asap/observability/__init__.py +6 -0
- asap/observability/dashboards/README.md +24 -0
- asap/observability/dashboards/asap-detailed.json +131 -0
- asap/observability/dashboards/asap-red.json +129 -0
- asap/observability/logging.py +81 -1
- asap/observability/metrics.py +15 -1
- asap/observability/trace_parser.py +238 -0
- asap/observability/trace_ui.py +218 -0
- asap/observability/tracing.py +293 -0
- asap/state/machine.py +15 -2
- asap/state/snapshot.py +0 -9
- asap/testing/__init__.py +31 -0
- asap/testing/assertions.py +108 -0
- asap/testing/fixtures.py +113 -0
- asap/testing/mocks.py +152 -0
- asap/transport/__init__.py +31 -0
- asap/transport/cache.py +180 -0
- asap/transport/circuit_breaker.py +194 -0
- asap/transport/client.py +989 -72
- asap/transport/compression.py +389 -0
- asap/transport/handlers.py +106 -53
- asap/transport/middleware.py +64 -39
- asap/transport/server.py +461 -94
- asap/transport/validators.py +320 -0
- asap/utils/__init__.py +7 -0
- asap/utils/sanitization.py +134 -0
- asap_protocol-1.0.0.dist-info/METADATA +264 -0
- asap_protocol-1.0.0.dist-info/RECORD +70 -0
- asap_protocol-0.3.0.dist-info/METADATA +0 -227
- asap_protocol-0.3.0.dist-info/RECORD +0 -37
- {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/WHEEL +0 -0
- {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/entry_points.txt +0 -0
- {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""OpenTelemetry tracing integration for ASAP protocol.
|
|
2
|
+
|
|
3
|
+
This module provides distributed tracing with W3C Trace Context propagation.
|
|
4
|
+
When enabled, FastAPI and httpx are auto-instrumented; custom spans cover
|
|
5
|
+
handler execution and state transitions. Trace IDs can be carried in
|
|
6
|
+
envelope.trace_id and envelope.extensions for cross-service correlation.
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
>>> from asap.observability.tracing import configure_tracing, get_tracer
|
|
10
|
+
>>> configure_tracing(service_name="my-agent", app=app)
|
|
11
|
+
>>> tracer = get_tracer(__name__)
|
|
12
|
+
>>> with tracer.start_as_current_span("my.operation"):
|
|
13
|
+
... ...
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from contextlib import AbstractContextManager
|
|
20
|
+
from typing import TYPE_CHECKING, Any
|
|
21
|
+
|
|
22
|
+
from opentelemetry import context, trace
|
|
23
|
+
from opentelemetry.sdk.resources import Resource
|
|
24
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
25
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter
|
|
26
|
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from fastapi import FastAPI
|
|
30
|
+
|
|
31
|
+
from asap.models.envelope import Envelope
|
|
32
|
+
from asap.observability.logging import get_logger
|
|
33
|
+
|
|
34
|
+
# Environment variables for zero-config (OpenTelemetry convention)
|
|
35
|
+
_ENV_OTEL_SERVICE_NAME = "OTEL_SERVICE_NAME"
|
|
36
|
+
_ENV_OTEL_TRACES_EXPORTER = "OTEL_TRACES_EXPORTER"
|
|
37
|
+
_ENV_OTEL_EXPORTER_OTLP_ENDPOINT = "OTEL_EXPORTER_OTLP_ENDPOINT"
|
|
38
|
+
|
|
39
|
+
# Extension keys for W3C trace context in envelope
|
|
40
|
+
EXTENSION_TRACE_ID = "trace_id"
|
|
41
|
+
EXTENSION_SPAN_ID = "span_id"
|
|
42
|
+
|
|
43
|
+
logger = get_logger(__name__)
|
|
44
|
+
|
|
45
|
+
_tracer_provider: TracerProvider | None = None
|
|
46
|
+
_tracer: trace.Tracer | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def configure_tracing(
|
|
50
|
+
service_name: str | None = None,
|
|
51
|
+
app: FastAPI | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Configure OpenTelemetry tracing and optionally instrument FastAPI and httpx.
|
|
54
|
+
|
|
55
|
+
Uses environment variables for zero-config:
|
|
56
|
+
- OTEL_SERVICE_NAME: service name (default: "asap-server")
|
|
57
|
+
- OTEL_TRACES_EXPORTER: "none" | "otlp" | "console" (default: "none")
|
|
58
|
+
- OTEL_EXPORTER_OTLP_ENDPOINT: OTLP endpoint (e.g. http://localhost:4317)
|
|
59
|
+
|
|
60
|
+
When OTEL_TRACES_EXPORTER is "none" or unset, tracing is configured but
|
|
61
|
+
no spans are exported (useful for dev without Jaeger). Set to "otlp" and
|
|
62
|
+
OTEL_EXPORTER_OTLP_ENDPOINT for Jaeger/collector.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
service_name: Override for OTEL_SERVICE_NAME.
|
|
66
|
+
app: If provided, FastAPI and httpx are instrumented.
|
|
67
|
+
"""
|
|
68
|
+
global _tracer_provider, _tracer
|
|
69
|
+
|
|
70
|
+
name = service_name or os.environ.get(_ENV_OTEL_SERVICE_NAME) or "asap-server"
|
|
71
|
+
resource = Resource.create({"service.name": name})
|
|
72
|
+
_tracer_provider = TracerProvider(resource=resource)
|
|
73
|
+
trace.set_tracer_provider(_tracer_provider)
|
|
74
|
+
|
|
75
|
+
exporter_name = os.environ.get(_ENV_OTEL_TRACES_EXPORTER, "none").strip().lower()
|
|
76
|
+
if exporter_name == "none":
|
|
77
|
+
_tracer = trace.get_tracer("asap.protocol", "1.0.0", schema_url=None)
|
|
78
|
+
if app is not None:
|
|
79
|
+
_instrument_app(app)
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
if exporter_name == "otlp":
|
|
83
|
+
_add_otlp_processor()
|
|
84
|
+
elif exporter_name == "console":
|
|
85
|
+
_add_console_processor()
|
|
86
|
+
|
|
87
|
+
_tracer = trace.get_tracer("asap.protocol", "1.0.0", schema_url=None)
|
|
88
|
+
if app is not None:
|
|
89
|
+
_instrument_app(app)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _add_otlp_processor() -> None:
|
|
93
|
+
"""Add OTLP span processor if endpoint is set."""
|
|
94
|
+
global _tracer_provider
|
|
95
|
+
if _tracer_provider is None:
|
|
96
|
+
return
|
|
97
|
+
endpoint = os.environ.get(_ENV_OTEL_EXPORTER_OTLP_ENDPOINT)
|
|
98
|
+
if not endpoint:
|
|
99
|
+
return
|
|
100
|
+
try:
|
|
101
|
+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
|
102
|
+
|
|
103
|
+
exporter: SpanExporter = OTLPSpanExporter(endpoint=endpoint, insecure=True)
|
|
104
|
+
_tracer_provider.add_span_processor(BatchSpanProcessor(exporter))
|
|
105
|
+
except ImportError as e:
|
|
106
|
+
logger.debug("OTLP gRPC exporter not available: %s", e)
|
|
107
|
+
try:
|
|
108
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
|
|
109
|
+
OTLPSpanExporter as OTLPSpanExporterHttp,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
exporter = OTLPSpanExporterHttp(endpoint=endpoint)
|
|
113
|
+
_tracer_provider.add_span_processor(BatchSpanProcessor(exporter))
|
|
114
|
+
except ImportError as e2:
|
|
115
|
+
logger.debug("OTLP HTTP exporter not available: %s", e2)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _add_console_processor() -> None:
|
|
119
|
+
"""Add console span processor for debugging."""
|
|
120
|
+
global _tracer_provider
|
|
121
|
+
if _tracer_provider is None:
|
|
122
|
+
return
|
|
123
|
+
try:
|
|
124
|
+
from opentelemetry.sdk.trace.export import ConsoleSpanExporter
|
|
125
|
+
|
|
126
|
+
_tracer_provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
|
|
127
|
+
except ImportError as e:
|
|
128
|
+
logger.debug("Console span exporter not available: %s", e)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _instrument_app(app: FastAPI) -> None:
|
|
132
|
+
"""Instrument FastAPI and httpx for automatic spans."""
|
|
133
|
+
try:
|
|
134
|
+
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
|
135
|
+
|
|
136
|
+
FastAPIInstrumentor.instrument_app(app)
|
|
137
|
+
except Exception as e:
|
|
138
|
+
logger.debug("Failed to instrument FastAPI: %s", e)
|
|
139
|
+
try:
|
|
140
|
+
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
|
141
|
+
|
|
142
|
+
HTTPXClientInstrumentor().instrument()
|
|
143
|
+
except Exception as e:
|
|
144
|
+
logger.debug("Failed to instrument httpx: %s", e)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def reset_tracing() -> None:
|
|
148
|
+
"""Reset global tracer state (for test teardown).
|
|
149
|
+
|
|
150
|
+
Clears the module-level tracer provider and tracer so that subsequent
|
|
151
|
+
tests or configure_tracing() calls start from a clean state.
|
|
152
|
+
"""
|
|
153
|
+
global _tracer_provider, _tracer
|
|
154
|
+
_tracer_provider = None
|
|
155
|
+
_tracer = None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def get_tracer(name: str | None = None) -> trace.Tracer:
|
|
159
|
+
"""Return the ASAP protocol tracer for custom spans.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
name: Optional logger/module name for span attribution.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
OpenTelemetry Tracer instance.
|
|
166
|
+
"""
|
|
167
|
+
if _tracer is None:
|
|
168
|
+
trace.set_tracer_provider(TracerProvider())
|
|
169
|
+
return trace.get_tracer("asap.protocol", "1.0.0", schema_url=None)
|
|
170
|
+
return _tracer
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def inject_envelope_trace_context(envelope: Envelope) -> Envelope:
|
|
174
|
+
"""Inject current span trace_id and span_id into envelope (W3C propagation).
|
|
175
|
+
|
|
176
|
+
Sets envelope.trace_id and envelope.extensions[trace_id, span_id] from the
|
|
177
|
+
current OpenTelemetry context so downstream services can continue the trace.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
envelope: Response envelope to annotate.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
New envelope with trace_id and extensions updated (immutable).
|
|
184
|
+
"""
|
|
185
|
+
span = trace.get_current_span()
|
|
186
|
+
if not span.is_recording():
|
|
187
|
+
return envelope
|
|
188
|
+
|
|
189
|
+
ctx = span.get_span_context()
|
|
190
|
+
trace_id_hex = format(ctx.trace_id, "032x")
|
|
191
|
+
span_id_hex = format(ctx.span_id, "016x")
|
|
192
|
+
|
|
193
|
+
extensions = dict(envelope.extensions or {})
|
|
194
|
+
extensions[EXTENSION_TRACE_ID] = trace_id_hex
|
|
195
|
+
extensions[EXTENSION_SPAN_ID] = span_id_hex
|
|
196
|
+
|
|
197
|
+
# Preserve existing trace_id for correlation (e.g. from request); set only if missing
|
|
198
|
+
new_trace_id = envelope.trace_id if envelope.trace_id else trace_id_hex
|
|
199
|
+
return envelope.model_copy(update={"trace_id": new_trace_id, "extensions": extensions})
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def extract_and_activate_envelope_trace_context(envelope: Envelope) -> Any | None:
|
|
203
|
+
"""Extract trace context from envelope and set as current context (W3C).
|
|
204
|
+
|
|
205
|
+
If envelope has trace_id (and optionally span_id in extensions), creates
|
|
206
|
+
a non-recording span context so new spans become children of the incoming
|
|
207
|
+
trace. Caller should call context.detach(token) when request ends.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
envelope: Incoming envelope carrying trace_id / extensions.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Context token to pass to context.detach(), or None if no context set.
|
|
214
|
+
"""
|
|
215
|
+
trace_id_str = envelope.trace_id
|
|
216
|
+
if not trace_id_str or len(trace_id_str) != 32:
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
extensions = envelope.extensions or {}
|
|
220
|
+
span_id_str = extensions.get(EXTENSION_SPAN_ID)
|
|
221
|
+
if not span_id_str or len(span_id_str) != 16:
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
trace_id = int(trace_id_str, 16)
|
|
226
|
+
span_id = int(span_id_str, 16)
|
|
227
|
+
except ValueError:
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags
|
|
231
|
+
|
|
232
|
+
span_context = SpanContext(
|
|
233
|
+
trace_id=trace_id,
|
|
234
|
+
span_id=span_id,
|
|
235
|
+
is_remote=True,
|
|
236
|
+
trace_flags=TraceFlags(0x01),
|
|
237
|
+
)
|
|
238
|
+
ctx = trace.set_span_in_context(NonRecordingSpan(span_context))
|
|
239
|
+
return context.attach(ctx)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def handler_span_context(
|
|
243
|
+
payload_type: str,
|
|
244
|
+
agent_urn: str,
|
|
245
|
+
envelope_id: str | None,
|
|
246
|
+
) -> AbstractContextManager[trace.Span]:
|
|
247
|
+
"""Start a current span for handler execution (use as context manager).
|
|
248
|
+
|
|
249
|
+
Attributes: asap.payload_type, asap.agent.urn, asap.envelope.id.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
payload_type: Envelope payload type.
|
|
253
|
+
agent_urn: Manifest/agent URN.
|
|
254
|
+
envelope_id: Envelope id.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Span context manager (use with "with").
|
|
258
|
+
"""
|
|
259
|
+
tracer = get_tracer(__name__)
|
|
260
|
+
attrs: dict[str, str] = {
|
|
261
|
+
"asap.payload_type": payload_type,
|
|
262
|
+
"asap.agent.urn": agent_urn,
|
|
263
|
+
}
|
|
264
|
+
if envelope_id:
|
|
265
|
+
attrs["asap.envelope.id"] = envelope_id
|
|
266
|
+
return tracer.start_as_current_span("asap.handler.execute", attributes=attrs)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def state_transition_span_context(
|
|
270
|
+
from_status: str,
|
|
271
|
+
to_status: str,
|
|
272
|
+
task_id: str | None = None,
|
|
273
|
+
) -> AbstractContextManager[trace.Span]:
|
|
274
|
+
"""Start a current span for a state machine transition (use as context manager).
|
|
275
|
+
|
|
276
|
+
Attributes: asap.state.from, asap.state.to, asap.task.id.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
from_status: Previous status.
|
|
280
|
+
to_status: New status.
|
|
281
|
+
task_id: Optional task id.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
Span context manager (use with "with").
|
|
285
|
+
"""
|
|
286
|
+
tracer = get_tracer(__name__)
|
|
287
|
+
attrs: dict[str, str] = {
|
|
288
|
+
"asap.state.from": from_status,
|
|
289
|
+
"asap.state.to": to_status,
|
|
290
|
+
}
|
|
291
|
+
if task_id:
|
|
292
|
+
attrs["asap.task.id"] = task_id
|
|
293
|
+
return tracer.start_as_current_span("asap.state.transition", attributes=attrs)
|
asap/state/machine.py
CHANGED
|
@@ -14,7 +14,10 @@ from datetime import datetime, timezone
|
|
|
14
14
|
from asap.errors import InvalidTransitionError
|
|
15
15
|
from asap.models.entities import Task
|
|
16
16
|
from asap.models.enums import TaskStatus
|
|
17
|
+
from asap.observability import get_metrics
|
|
18
|
+
from asap.observability.tracing import state_transition_span_context
|
|
17
19
|
|
|
20
|
+
__all__ = ["TaskStatus", "can_transition", "transition", "VALID_TRANSITIONS"]
|
|
18
21
|
|
|
19
22
|
# Valid state transitions mapping
|
|
20
23
|
VALID_TRANSITIONS: dict[TaskStatus, set[TaskStatus]] = {
|
|
@@ -82,5 +85,15 @@ def transition(task: Task, new_status: TaskStatus) -> Task:
|
|
|
82
85
|
from_state=task.status.value, to_state=new_status.value, details={"task_id": task.id}
|
|
83
86
|
)
|
|
84
87
|
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
with state_transition_span_context(
|
|
89
|
+
from_status=task.status.value,
|
|
90
|
+
to_status=new_status.value,
|
|
91
|
+
task_id=task.id,
|
|
92
|
+
):
|
|
93
|
+
get_metrics().increment_counter(
|
|
94
|
+
"asap_state_transitions_total",
|
|
95
|
+
{"from_status": task.status.value, "to_status": new_status.value},
|
|
96
|
+
)
|
|
97
|
+
return task.model_copy(
|
|
98
|
+
update={"status": new_status, "updated_at": datetime.now(timezone.utc)}
|
|
99
|
+
)
|
asap/state/snapshot.py
CHANGED
|
@@ -154,14 +154,10 @@ class InMemorySnapshotStore:
|
|
|
154
154
|
with self._lock:
|
|
155
155
|
task_id = snapshot.task_id
|
|
156
156
|
|
|
157
|
-
# Initialize storage for this task if needed
|
|
158
157
|
if task_id not in self._snapshots:
|
|
159
158
|
self._snapshots[task_id] = {}
|
|
160
159
|
|
|
161
|
-
# Store the snapshot
|
|
162
160
|
self._snapshots[task_id][snapshot.version] = snapshot
|
|
163
|
-
|
|
164
|
-
# Update latest version
|
|
165
161
|
self._latest_versions[task_id] = max(
|
|
166
162
|
self._latest_versions.get(task_id, 0), snapshot.version
|
|
167
163
|
)
|
|
@@ -186,13 +182,11 @@ class InMemorySnapshotStore:
|
|
|
186
182
|
return None
|
|
187
183
|
|
|
188
184
|
if version is None:
|
|
189
|
-
# Return latest version
|
|
190
185
|
latest_version = self._latest_versions.get(task_id)
|
|
191
186
|
if latest_version is None:
|
|
192
187
|
return None
|
|
193
188
|
return self._snapshots[task_id].get(latest_version)
|
|
194
189
|
|
|
195
|
-
# Return specific version
|
|
196
190
|
return self._snapshots[task_id].get(version)
|
|
197
191
|
|
|
198
192
|
def list_versions(self, task_id: TaskID) -> list[int]:
|
|
@@ -243,18 +237,15 @@ class InMemorySnapshotStore:
|
|
|
243
237
|
return True
|
|
244
238
|
return False
|
|
245
239
|
|
|
246
|
-
# Delete specific version
|
|
247
240
|
if version in self._snapshots[task_id]:
|
|
248
241
|
del self._snapshots[task_id][version]
|
|
249
242
|
|
|
250
|
-
# Update latest version if needed
|
|
251
243
|
if self._latest_versions.get(task_id) == version:
|
|
252
244
|
if self._snapshots[task_id]:
|
|
253
245
|
self._latest_versions[task_id] = max(self._snapshots[task_id].keys())
|
|
254
246
|
else:
|
|
255
247
|
del self._latest_versions[task_id]
|
|
256
248
|
|
|
257
|
-
# Clean up empty task dict
|
|
258
249
|
if not self._snapshots[task_id]:
|
|
259
250
|
del self._snapshots[task_id]
|
|
260
251
|
if task_id in self._latest_versions:
|
asap/testing/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""ASAP testing utilities for easier test authoring.
|
|
2
|
+
|
|
3
|
+
This package provides pytest fixtures, mock agents, and custom assertions
|
|
4
|
+
to reduce boilerplate when testing ASAP protocol integrations.
|
|
5
|
+
|
|
6
|
+
Modules:
|
|
7
|
+
fixtures: Pytest fixtures (mock_agent, mock_client, mock_snapshot_store)
|
|
8
|
+
and context managers (test_agent, test_client).
|
|
9
|
+
mocks: MockAgent for configurable mock agents with pre-set responses
|
|
10
|
+
and request recording.
|
|
11
|
+
assertions: Custom assertions (assert_envelope_valid, assert_task_completed,
|
|
12
|
+
assert_response_correlates).
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
>>> from asap.testing import MockAgent, assert_envelope_valid
|
|
16
|
+
>>> from asap.testing.fixtures import mock_agent, test_client
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from asap.testing.assertions import (
|
|
20
|
+
assert_envelope_valid,
|
|
21
|
+
assert_response_correlates,
|
|
22
|
+
assert_task_completed,
|
|
23
|
+
)
|
|
24
|
+
from asap.testing.mocks import MockAgent
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"MockAgent",
|
|
28
|
+
"assert_envelope_valid",
|
|
29
|
+
"assert_response_correlates",
|
|
30
|
+
"assert_task_completed",
|
|
31
|
+
]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Custom assertions for ASAP protocol tests.
|
|
2
|
+
|
|
3
|
+
This module provides assertion helpers to validate envelopes and
|
|
4
|
+
task outcomes with clear error messages.
|
|
5
|
+
|
|
6
|
+
Functions:
|
|
7
|
+
assert_envelope_valid: Assert an Envelope has required fields and valid shape.
|
|
8
|
+
assert_task_completed: Assert a TaskResponse or envelope payload indicates
|
|
9
|
+
task completion (e.g. status completed).
|
|
10
|
+
assert_response_correlates: Assert response envelope correlates to request.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from asap.models.envelope import Envelope
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def assert_envelope_valid(
|
|
19
|
+
envelope: Envelope,
|
|
20
|
+
*,
|
|
21
|
+
require_id: bool = True,
|
|
22
|
+
require_timestamp: bool = True,
|
|
23
|
+
allowed_payload_types: list[str] | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Assert that an envelope has required fields and valid structure.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
envelope: The envelope to validate.
|
|
29
|
+
require_id: If True, envelope.id must be non-empty.
|
|
30
|
+
require_timestamp: If True, envelope.timestamp must be set.
|
|
31
|
+
allowed_payload_types: If set, payload_type must be in this list.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
AssertionError: If any check fails.
|
|
35
|
+
"""
|
|
36
|
+
assert envelope is not None, "Envelope must not be None" # nosec B101
|
|
37
|
+
if require_id:
|
|
38
|
+
assert envelope.id, "Envelope must have a non-empty id" # nosec B101
|
|
39
|
+
if require_timestamp:
|
|
40
|
+
assert envelope.timestamp is not None, "Envelope must have a timestamp" # nosec B101
|
|
41
|
+
assert envelope.sender, "Envelope must have a sender" # nosec B101
|
|
42
|
+
assert envelope.recipient, "Envelope must have a recipient" # nosec B101
|
|
43
|
+
assert envelope.payload_type, "Envelope must have a payload_type" # nosec B101
|
|
44
|
+
assert envelope.payload is not None, "Envelope must have a payload" # nosec B101
|
|
45
|
+
if allowed_payload_types is not None:
|
|
46
|
+
assert envelope.payload_type in allowed_payload_types, ( # nosec B101
|
|
47
|
+
f"payload_type {envelope.payload_type!r} not in {allowed_payload_types}"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def assert_task_completed(
|
|
52
|
+
payload: dict[str, Any] | Envelope,
|
|
53
|
+
*,
|
|
54
|
+
status_key: str = "status",
|
|
55
|
+
completed_value: str = "completed",
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Assert that a task response indicates completion.
|
|
58
|
+
|
|
59
|
+
Accepts either a TaskResponse-like dict (with status_key) or an
|
|
60
|
+
Envelope whose payload is such a dict.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
payload: TaskResponse payload dict or Envelope containing it.
|
|
64
|
+
status_key: Key in payload that holds status (default 'status').
|
|
65
|
+
completed_value: Value that indicates completion (default 'completed').
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
AssertionError: If payload does not indicate completion.
|
|
69
|
+
"""
|
|
70
|
+
if isinstance(payload, Envelope):
|
|
71
|
+
payload = payload.payload or {}
|
|
72
|
+
assert isinstance(payload, dict), "payload must be a dict or Envelope" # nosec B101
|
|
73
|
+
actual = payload.get(status_key)
|
|
74
|
+
assert actual == completed_value, f"Expected task status {completed_value!r}, got {actual!r}" # nosec B101
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def assert_response_correlates(
|
|
78
|
+
request_envelope: Envelope,
|
|
79
|
+
response_envelope: Envelope,
|
|
80
|
+
*,
|
|
81
|
+
correlation_id_field: str = "correlation_id",
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Assert that a response envelope correlates to the request (by correlation id).
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
request_envelope: The request envelope (must have id).
|
|
87
|
+
response_envelope: The response envelope (must have correlation_id).
|
|
88
|
+
correlation_id_field: Attribute name on response for correlation (default
|
|
89
|
+
'correlation_id').
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
AssertionError: If request id or response correlation_id is missing or
|
|
93
|
+
they do not match.
|
|
94
|
+
"""
|
|
95
|
+
assert request_envelope.id, "Request envelope must have a non-empty id" # nosec B101
|
|
96
|
+
correlation_id = getattr(response_envelope, correlation_id_field, None)
|
|
97
|
+
assert correlation_id is not None, f"Response envelope must have {correlation_id_field!r}" # nosec B101
|
|
98
|
+
assert correlation_id == request_envelope.id, ( # nosec B101
|
|
99
|
+
f"Response {correlation_id_field!r} {correlation_id!r} does not match "
|
|
100
|
+
f"request id {request_envelope.id!r}"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
__all__ = [
|
|
105
|
+
"assert_envelope_valid",
|
|
106
|
+
"assert_task_completed",
|
|
107
|
+
"assert_response_correlates",
|
|
108
|
+
]
|
asap/testing/fixtures.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Pytest fixtures and context managers for ASAP tests.
|
|
2
|
+
|
|
3
|
+
This module provides shared fixtures and context managers to reduce
|
|
4
|
+
boilerplate when testing agents, clients, and snapshot stores.
|
|
5
|
+
|
|
6
|
+
Fixtures (use with pytest):
|
|
7
|
+
mock_agent: Configurable mock agent for request/response tests.
|
|
8
|
+
mock_client: ASAP client configured for testing (async; use in async tests).
|
|
9
|
+
mock_snapshot_store: In-memory snapshot store for state persistence tests.
|
|
10
|
+
|
|
11
|
+
Context managers:
|
|
12
|
+
test_agent(): Sync context manager yielding a MockAgent for the scope.
|
|
13
|
+
test_client(): Async context manager yielding an ASAPClient for the scope.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
17
|
+
from typing import AsyncIterator, Iterator
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from asap.state.snapshot import InMemorySnapshotStore
|
|
22
|
+
from asap.testing.mocks import MockAgent
|
|
23
|
+
from asap.transport.client import ASAPClient
|
|
24
|
+
|
|
25
|
+
DEFAULT_TEST_BASE_URL = "http://localhost:9999"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture
|
|
29
|
+
def mock_agent() -> MockAgent:
|
|
30
|
+
"""Create a fresh MockAgent for the test.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
A MockAgent instance (cleared between tests via fresh fixture).
|
|
34
|
+
"""
|
|
35
|
+
return MockAgent()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture
|
|
39
|
+
def mock_snapshot_store() -> InMemorySnapshotStore:
|
|
40
|
+
"""Create an in-memory snapshot store for the test.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
An InMemorySnapshotStore instance (empty, isolated per test).
|
|
44
|
+
"""
|
|
45
|
+
return InMemorySnapshotStore()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.fixture
|
|
49
|
+
async def mock_client() -> AsyncIterator[ASAPClient]:
|
|
50
|
+
"""Provide an ASAPClient entered for the test (async fixture).
|
|
51
|
+
|
|
52
|
+
Yields an open ASAPClient pointing at DEFAULT_TEST_BASE_URL.
|
|
53
|
+
Use in async tests; the client is closed after the test.
|
|
54
|
+
|
|
55
|
+
Yields:
|
|
56
|
+
ASAPClient instance (already in async context).
|
|
57
|
+
"""
|
|
58
|
+
async with ASAPClient(DEFAULT_TEST_BASE_URL) as client:
|
|
59
|
+
yield client
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@contextmanager
|
|
63
|
+
def test_agent(agent_id: str = "urn:asap:agent:mock") -> Iterator[MockAgent]:
|
|
64
|
+
"""Context manager that provides a MockAgent for the scope.
|
|
65
|
+
|
|
66
|
+
On exit, the agent is cleared (requests and responses reset).
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
agent_id: URN for the mock agent.
|
|
70
|
+
|
|
71
|
+
Yields:
|
|
72
|
+
A MockAgent instance.
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
>>> with test_agent() as agent:
|
|
76
|
+
... agent.set_response("echo", {"status": "completed"})
|
|
77
|
+
... out = agent.handle(request_envelope)
|
|
78
|
+
"""
|
|
79
|
+
agent = MockAgent(agent_id)
|
|
80
|
+
try:
|
|
81
|
+
yield agent
|
|
82
|
+
finally:
|
|
83
|
+
agent.clear()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@asynccontextmanager
|
|
87
|
+
async def test_client(
|
|
88
|
+
base_url: str = DEFAULT_TEST_BASE_URL,
|
|
89
|
+
) -> AsyncIterator[ASAPClient]:
|
|
90
|
+
"""Async context manager that provides an ASAPClient for the scope.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
base_url: Agent base URL (default: localhost:9999 for test servers).
|
|
94
|
+
|
|
95
|
+
Yields:
|
|
96
|
+
An open ASAPClient instance.
|
|
97
|
+
|
|
98
|
+
Example:
|
|
99
|
+
>>> async with test_client("http://localhost:8000") as client:
|
|
100
|
+
... response = await client.send(envelope)
|
|
101
|
+
"""
|
|
102
|
+
async with ASAPClient(base_url) as client:
|
|
103
|
+
yield client
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
__all__ = [
|
|
107
|
+
"DEFAULT_TEST_BASE_URL",
|
|
108
|
+
"mock_agent",
|
|
109
|
+
"mock_client",
|
|
110
|
+
"mock_snapshot_store",
|
|
111
|
+
"test_agent",
|
|
112
|
+
"test_client",
|
|
113
|
+
]
|