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.
Files changed (62) hide show
  1. asap/__init__.py +1 -1
  2. asap/cli.py +137 -2
  3. asap/errors.py +167 -0
  4. asap/examples/README.md +81 -10
  5. asap/examples/auth_patterns.py +212 -0
  6. asap/examples/error_recovery.py +248 -0
  7. asap/examples/long_running.py +287 -0
  8. asap/examples/mcp_integration.py +240 -0
  9. asap/examples/multi_step_workflow.py +134 -0
  10. asap/examples/orchestration.py +293 -0
  11. asap/examples/rate_limiting.py +137 -0
  12. asap/examples/run_demo.py +9 -4
  13. asap/examples/secure_handler.py +84 -0
  14. asap/examples/state_migration.py +240 -0
  15. asap/examples/streaming_response.py +108 -0
  16. asap/examples/websocket_concept.py +129 -0
  17. asap/mcp/__init__.py +43 -0
  18. asap/mcp/client.py +224 -0
  19. asap/mcp/protocol.py +179 -0
  20. asap/mcp/server.py +333 -0
  21. asap/mcp/server_runner.py +40 -0
  22. asap/models/__init__.py +4 -0
  23. asap/models/base.py +0 -3
  24. asap/models/constants.py +76 -1
  25. asap/models/entities.py +58 -7
  26. asap/models/envelope.py +14 -1
  27. asap/models/ids.py +8 -4
  28. asap/models/parts.py +33 -3
  29. asap/models/validators.py +16 -0
  30. asap/observability/__init__.py +6 -0
  31. asap/observability/dashboards/README.md +24 -0
  32. asap/observability/dashboards/asap-detailed.json +131 -0
  33. asap/observability/dashboards/asap-red.json +129 -0
  34. asap/observability/logging.py +81 -1
  35. asap/observability/metrics.py +15 -1
  36. asap/observability/trace_parser.py +238 -0
  37. asap/observability/trace_ui.py +218 -0
  38. asap/observability/tracing.py +293 -0
  39. asap/state/machine.py +15 -2
  40. asap/state/snapshot.py +0 -9
  41. asap/testing/__init__.py +31 -0
  42. asap/testing/assertions.py +108 -0
  43. asap/testing/fixtures.py +113 -0
  44. asap/testing/mocks.py +152 -0
  45. asap/transport/__init__.py +31 -0
  46. asap/transport/cache.py +180 -0
  47. asap/transport/circuit_breaker.py +194 -0
  48. asap/transport/client.py +989 -72
  49. asap/transport/compression.py +389 -0
  50. asap/transport/handlers.py +106 -53
  51. asap/transport/middleware.py +64 -39
  52. asap/transport/server.py +461 -94
  53. asap/transport/validators.py +320 -0
  54. asap/utils/__init__.py +7 -0
  55. asap/utils/sanitization.py +134 -0
  56. asap_protocol-1.0.0.dist-info/METADATA +264 -0
  57. asap_protocol-1.0.0.dist-info/RECORD +70 -0
  58. asap_protocol-0.3.0.dist-info/METADATA +0 -227
  59. asap_protocol-0.3.0.dist-info/RECORD +0 -37
  60. {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/WHEEL +0 -0
  61. {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/entry_points.txt +0 -0
  62. {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
- # Create new task instance with updated status and timestamp (immutable approach)
86
- return task.model_copy(update={"status": new_status, "updated_at": datetime.now(timezone.utc)})
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:
@@ -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
+ ]
@@ -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
+ ]