donkey-kit 0.1.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 (101) hide show
  1. donkey_kit/__init__.py +92 -0
  2. donkey_kit/conformance/__init__.py +83 -0
  3. donkey_kit/conformance/gateway.py +217 -0
  4. donkey_kit/conformance/harness.py +320 -0
  5. donkey_kit/conformance/plugin.py +274 -0
  6. donkey_kit/conformance/report.py +85 -0
  7. donkey_kit/conformance/suite.py +256 -0
  8. donkey_kit/core/__init__.py +88 -0
  9. donkey_kit/core/_verify.py +264 -0
  10. donkey_kit/core/auth.py +158 -0
  11. donkey_kit/core/budget.py +264 -0
  12. donkey_kit/core/cache.py +57 -0
  13. donkey_kit/core/cachecontrol.py +208 -0
  14. donkey_kit/core/config.py +357 -0
  15. donkey_kit/core/cost.py +139 -0
  16. donkey_kit/core/errors.py +835 -0
  17. donkey_kit/core/lastcall.py +688 -0
  18. donkey_kit/core/telemetry.py +754 -0
  19. donkey_kit/core/toolspec.py +103 -0
  20. donkey_kit/core/transport.py +1117 -0
  21. donkey_kit/donkey.py +606 -0
  22. donkey_kit/governance.py +196 -0
  23. donkey_kit/integrations/__init__.py +75 -0
  24. donkey_kit/integrations/_base.py +103 -0
  25. donkey_kit/integrations/adk.py +57 -0
  26. donkey_kit/integrations/agent_framework.py +103 -0
  27. donkey_kit/integrations/anthropic.py +72 -0
  28. donkey_kit/integrations/crewai.py +57 -0
  29. donkey_kit/integrations/langgraph.py +136 -0
  30. donkey_kit/integrations/llamaindex.py +55 -0
  31. donkey_kit/integrations/openai_agents.py +72 -0
  32. donkey_kit/integrations/strands.py +48 -0
  33. donkey_kit/llm/__init__.py +6 -0
  34. donkey_kit/llm/catalog.py +55 -0
  35. donkey_kit/llm/client.py +169 -0
  36. donkey_kit/provisioning/__init__.py +38 -0
  37. donkey_kit/provisioning/applier.py +56 -0
  38. donkey_kit/provisioning/cli.py +461 -0
  39. donkey_kit/provisioning/doctor.py +272 -0
  40. donkey_kit/provisioning/lint.py +48 -0
  41. donkey_kit/provisioning/planner.py +52 -0
  42. donkey_kit/provisioning/publish.py +41 -0
  43. donkey_kit/provisioning/spec.py +77 -0
  44. donkey_kit/py.typed +0 -0
  45. donkey_kit/registry/__init__.py +32 -0
  46. donkey_kit/registry/exchange.py +93 -0
  47. donkey_kit/registry/governance.py +78 -0
  48. donkey_kit/registry/introspect.py +40 -0
  49. donkey_kit/registry/models.py +65 -0
  50. donkey_kit/registry/publication.py +134 -0
  51. donkey_kit/simulator/__init__.py +22 -0
  52. donkey_kit/simulator/_fixtures/anypoint/llm_proxy/README.md +79 -0
  53. donkey_kit/simulator/_fixtures/anypoint/llm_proxy/api_describe.openai-sdk.json +120 -0
  54. donkey_kit/simulator/_fixtures/anypoint/llm_proxy/models.notfound.headers.txt +2 -0
  55. donkey_kit/simulator/_fixtures/anypoint/llm_proxy/policy_list.openai-sdk.json +62 -0
  56. donkey_kit/simulator/_fixtures/anypoint/llm_proxy/reject.client-id-missing.body.json +1 -0
  57. donkey_kit/simulator/_fixtures/anypoint/llm_proxy/reject.client-id-missing.headers.txt +3 -0
  58. donkey_kit/simulator/_fixtures/anypoint/llm_proxy/reject.model-not-found.body.json +8 -0
  59. donkey_kit/simulator/_fixtures/anypoint/llm_proxy/reject.pii-detected.body.json +1 -0
  60. donkey_kit/simulator/_fixtures/anypoint/llm_proxy/reject.pii-detected.headers.txt +15 -0
  61. donkey_kit/simulator/_fixtures/anypoint/llm_proxy/reject.token-rate-limit.body.empty +0 -0
  62. donkey_kit/simulator/_fixtures/anypoint/llm_proxy/reject.token-rate-limit.headers.txt +16 -0
  63. donkey_kit/simulator/_fixtures/anypoint/llm_proxy/responses.stream.headers.txt +5 -0
  64. donkey_kit/simulator/_fixtures/anypoint/llm_proxy/responses.stream.sample.sse +2 -0
  65. donkey_kit/simulator/_fixtures/anypoint/llm_proxy/responses.success.body.json +93 -0
  66. donkey_kit/simulator/_fixtures/anypoint/llm_proxy/responses.success.headers.txt +33 -0
  67. donkey_kit/simulator/_fixtures/anypoint/llm_proxy/responses.success.policy-applied.headers.txt +7 -0
  68. donkey_kit/simulator/_fixtures/anypoint/semantic_routing/README.md +62 -0
  69. donkey_kit/simulator/_fixtures/anypoint/semantic_routing/request.success.http +18 -0
  70. donkey_kit/simulator/_fixtures/anypoint/semantic_routing/responses.code.body.json +1 -0
  71. donkey_kit/simulator/_fixtures/anypoint/semantic_routing/responses.code.headers.txt +24 -0
  72. donkey_kit/simulator/_fixtures/anypoint/semantic_routing/responses.finance.body.json +35 -0
  73. donkey_kit/simulator/_fixtures/anypoint/semantic_routing/responses.finance.headers.txt +36 -0
  74. donkey_kit/simulator/_fixtures/anypoint/semantic_routing/responses.offtopic.body.json +35 -0
  75. donkey_kit/simulator/_fixtures/anypoint/semantic_routing/responses.offtopic.headers.txt +36 -0
  76. donkey_kit/simulator/_fixtures/fixtures.lock +22 -0
  77. donkey_kit/simulator/_fixtures/rejections/README.md +82 -0
  78. donkey_kit/simulator/_fixtures/rejections/reject.content-moderation.body.empty +0 -0
  79. donkey_kit/simulator/_fixtures/rejections/reject.content-moderation.headers.txt +2 -0
  80. donkey_kit/simulator/_fixtures/rejections/reject.content-safety-bedrock.body.json +1 -0
  81. donkey_kit/simulator/_fixtures/rejections/reject.content-safety-bedrock.headers.txt +4 -0
  82. donkey_kit/simulator/_fixtures/rejections/reject.content-safety.body.json +1 -0
  83. donkey_kit/simulator/_fixtures/rejections/reject.content-safety.headers.txt +5 -0
  84. donkey_kit/simulator/_fixtures/rejections/reject.injection-protection.body.empty +0 -0
  85. donkey_kit/simulator/_fixtures/rejections/reject.injection-protection.headers.txt +3 -0
  86. donkey_kit/simulator/_fixtures/rejections/reject.regex-prompt-guard.body.json +1 -0
  87. donkey_kit/simulator/_fixtures/rejections/reject.regex-prompt-guard.headers.txt +2 -0
  88. donkey_kit/simulator/_fixtures/rejections/reject.upstream-5xx.body.empty +0 -0
  89. donkey_kit/simulator/_fixtures/rejections/reject.upstream-5xx.headers.txt +2 -0
  90. donkey_kit/simulator/app.py +283 -0
  91. donkey_kit/simulator/fixtures.py +435 -0
  92. donkey_kit/simulator/inject.py +212 -0
  93. donkey_kit/simulator/scenarios.py +333 -0
  94. donkey_kit/simulator/server.py +38 -0
  95. donkey_kit/tools/__init__.py +6 -0
  96. donkey_kit/tools/filter.py +61 -0
  97. donkey_kit/tools/session.py +118 -0
  98. donkey_kit-0.1.0.dist-info/METADATA +229 -0
  99. donkey_kit-0.1.0.dist-info/RECORD +101 -0
  100. donkey_kit-0.1.0.dist-info/WHEEL +4 -0
  101. donkey_kit-0.1.0.dist-info/entry_points.txt +5 -0
@@ -0,0 +1,320 @@
1
+ """The observable ``Donkey`` the conformance suite runs against (#191, `BG §1.5`).
2
+
3
+ The suite (:mod:`donkey_kit.conformance.suite`) says *what* to check; this
4
+ module owns *how* it is observed. For each scenario it builds a fresh ``Donkey``,
5
+ lets the customer's factory build an agent against it, arms the gateway to serve
6
+ one captured fixture for every send, runs ``agent.run(...)`` while capturing what
7
+ it raised and logged, and hands the resulting :class:`~.suite.Observation` to
8
+ the scenario's check.
9
+
10
+ It is the in-process sibling of the local gateway simulator, and it reuses the
11
+ simulator's machinery so the injected refusal is the *same captured bytes*
12
+ ``classify()`` is tested against (:func:`donkey_kit.simulator.inject._resolve`
13
+ gives us that fixture and asserts the classify() round-trip). The one deliberate
14
+ difference from ``simulate()``'s transport: this one counts *every* wire send
15
+ rather than deduping retries, because counting re-sends is exactly how the retry
16
+ scenario is observed.
17
+
18
+ Framework isolation (the layered architecture): imports only ``httpx``, the framework-free
19
+ ``core``/``donkey`` surface, and the sibling simulator loader — never a web
20
+ framework and never ``openai`` at module top. It is a dev-only simulator-style
21
+ sibling, kept out of the five production layers by an import-linter contract.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import inspect
27
+ import logging
28
+ from collections.abc import Callable
29
+ from contextlib import nullcontext
30
+ from dataclasses import replace
31
+ from typing import Any
32
+
33
+ import httpx
34
+
35
+ from ..core.config import DonkeyConfig
36
+ from ..core.errors import DonkeyError
37
+ from ..donkey import Donkey
38
+ from ..simulator.app import SIMULATOR_HEADER
39
+ from ..simulator.fixtures import Fixture, load, replay_headers
40
+
41
+ # Reuse the simulator's single exception->fixture mapping (and its classify()
42
+ # round-trip assertion) rather than duplicating the table: if the mapping ever
43
+ # drifts, simulate() and this harness fail together — the "same files" rule.
44
+ from ..simulator.inject import _resolve as _refusal_fixture
45
+ from .suite import SCENARIOS, Observation, Outcome, Result, Scenario, validate_known_limitations
46
+
47
+ __all__ = [
48
+ "DEFAULT_RUN_INPUT",
49
+ "ConformanceHarness",
50
+ "ConformanceUsageError",
51
+ "run_conformance",
52
+ ]
53
+
54
+ # A neutral prompt handed to every agent under test. It is never actually sent
55
+ # upstream — the transport is swapped — but agents that inspect their input want
56
+ # a real string.
57
+ DEFAULT_RUN_INPUT = "This is a Donkey conformance probe. Reply briefly."
58
+
59
+ # Filled in only when the customer's environment does not already supply the LLM
60
+ # proxy config. The harness never connects (the transport is swapped for every
61
+ # scenario), so these just need to be non-empty to satisfy validated(need="llm").
62
+ _PLACEHOLDER = "donkey-conformance"
63
+ _PLACEHOLDER_URL = "https://donkey-conformance.invalid"
64
+
65
+ AgentFactory = Callable[..., Any]
66
+
67
+
68
+ class ConformanceUsageError(Exception):
69
+ """The agent under test does not meet the harness contract (e.g. no callable
70
+ ``run()``). A setup problem to fix, not a scenario finding — it is surfaced
71
+ as a hard error, never a fail verdict."""
72
+
73
+
74
+ def _offline_config() -> DonkeyConfig:
75
+ """The customer's own config, with placeholder LLM credentials filled in
76
+ only where absent. Real config (base URL, model catalog) is preserved so the
77
+ agent is built as it would be in production; the placeholders exist purely so
78
+ ``donkey.openai()`` constructs without live credentials, since nothing here
79
+ ever reaches the network."""
80
+ cfg = DonkeyConfig.from_env()
81
+ # Replace with named str fields (not **dict) so the types stay checkable;
82
+ # ``x or <placeholder>`` keeps any value the customer has set and fills only
83
+ # the ones they have not.
84
+ return replace(
85
+ cfg,
86
+ llm_proxy_url=cfg.llm_proxy_url or _PLACEHOLDER_URL,
87
+ llm_proxy_client_id=cfg.llm_proxy_client_id or _PLACEHOLDER,
88
+ llm_proxy_client_secret=cfg.llm_proxy_client_secret or _PLACEHOLDER,
89
+ )
90
+
91
+
92
+ def _fixture_responder(
93
+ fixture: Fixture, *, strip_budget: bool = False
94
+ ) -> Callable[[httpx.Request], httpx.Response]:
95
+ """Build a responder that replays one captured fixture, honesty-stamped.
96
+
97
+ Uses the same header allow-list (:func:`replay_headers`) and honesty stamp
98
+ (``x-donkey-simulator: true``) as ``simulate()`` and the mock server, so a
99
+ conformance response is never mistaken for a live gateway response. With
100
+ ``strip_budget`` the ``x-token-*`` headers are dropped, so the run observes a
101
+ genuinely unobserved budget even if a future capture grows them."""
102
+
103
+ def respond(request: httpx.Request) -> httpx.Response:
104
+ headers = replay_headers(fixture)
105
+ if strip_budget:
106
+ headers = {k: v for k, v in headers.items() if not k.startswith("x-token-")}
107
+ if fixture.content_type is not None:
108
+ headers["content-type"] = fixture.content_type
109
+ headers[SIMULATOR_HEADER] = "true"
110
+ return httpx.Response(
111
+ status_code=fixture.status,
112
+ headers=headers,
113
+ content=fixture.body,
114
+ request=request,
115
+ )
116
+
117
+ return respond
118
+
119
+
120
+ class _ProbeTransport(httpx.AsyncBaseTransport, httpx.BaseTransport):
121
+ """Serves a fixed captured response for every send, counting wire sends.
122
+
123
+ Deliberately does NOT dedupe by request identity the way ``simulate()``'s
124
+ transport does: the retry scenario is observed by counting re-sends, so every
125
+ ``handle_request`` increments :attr:`wire_sends`. Implements both transport
126
+ protocols so one instance fronts a ``DonkeyClient`` and a
127
+ ``DonkeyAsyncClient`` at once (and its count spans both)."""
128
+
129
+ def __init__(self, responder: Callable[[httpx.Request], httpx.Response]) -> None:
130
+ self._responder = responder
131
+ self.wire_sends = 0
132
+
133
+ def _serve(self, request: httpx.Request) -> httpx.Response:
134
+ self.wire_sends += 1
135
+ return self._responder(request)
136
+
137
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
138
+ return self._serve(request)
139
+
140
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
141
+ return self._serve(request)
142
+
143
+
144
+ # Standard LogRecord attribute names, so :func:`_render_records` can tell an
145
+ # ``extra={...}`` field apart from the built-in ones and include it in the scan.
146
+ _STD_LOGRECORD_KEYS = frozenset(vars(logging.makeLogRecord({})).keys())
147
+
148
+
149
+ class _CaptureHandler(logging.Handler):
150
+ """Collects every record emitted anywhere during a run, for the correlation
151
+ scenario to grep."""
152
+
153
+ def __init__(self, sink: list[logging.LogRecord]) -> None:
154
+ super().__init__(level=logging.DEBUG)
155
+ self._sink = sink
156
+
157
+ def emit(self, record: logging.LogRecord) -> None:
158
+ self._sink.append(record)
159
+
160
+
161
+ def _render_records(records: list[logging.LogRecord]) -> str:
162
+ """Flatten captured records into one searchable string: the formatted
163
+ message plus the stringified value of every ``extra=`` field, so an id logged
164
+ either in the message or via ``extra={"correlation_id": ...}`` is found."""
165
+ parts: list[str] = []
166
+ for record in records:
167
+ try:
168
+ parts.append(record.getMessage())
169
+ except Exception: # noqa: BLE001 — a bad format string must not abort capture
170
+ parts.append(str(record.msg))
171
+ parts.extend(
172
+ str(value)
173
+ for key, value in record.__dict__.items()
174
+ if key not in _STD_LOGRECORD_KEYS
175
+ )
176
+ return "\n".join(parts)
177
+
178
+
179
+ def _build_agent(factory: AgentFactory, donkey: Donkey) -> Any:
180
+ """Call the customer's factory, passing the ``Donkey`` iff it declares a slot
181
+ for it — the app-factory / dependency-injection pattern (``gunicorn
182
+ app:create_app``). ``build(donkey)`` and ``build(*, donkey=...)`` receive it;
183
+ a zero-argument ``build()`` does not."""
184
+ try:
185
+ params = inspect.signature(factory).parameters
186
+ except (TypeError, ValueError):
187
+ # Un-introspectable (some builtins/C callables): assume it wants donkey.
188
+ return factory(donkey)
189
+ if "donkey" in params and params["donkey"].kind is inspect.Parameter.KEYWORD_ONLY:
190
+ return factory(donkey=donkey)
191
+ accepts_positional = any(
192
+ p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
193
+ for p in params.values()
194
+ ) or any(p.kind is inspect.Parameter.VAR_POSITIONAL for p in params.values())
195
+ return factory(donkey) if accepts_positional else factory()
196
+
197
+
198
+ class ConformanceHarness:
199
+ """Runs one scenario against a freshly built agent. Implements
200
+ :class:`~.suite.ScenarioContext`: the scenario check calls
201
+ :meth:`serve_refusal`/:meth:`serve_success` to arm the gateway and
202
+ :meth:`run` to drive the agent."""
203
+
204
+ def __init__(self, agent_factory: AgentFactory, *, run_input: str = DEFAULT_RUN_INPUT) -> None:
205
+ self._factory = agent_factory
206
+ self._run_input = run_input
207
+ self._donkey: Donkey | None = None
208
+ self._agent: Any = None
209
+ self._probe: _ProbeTransport | None = None
210
+
211
+ async def run_scenario(self, scenario: Scenario) -> Outcome:
212
+ """Build a fresh ``Donkey`` + agent, run the scenario's check against
213
+ this harness, and tear the ``Donkey`` down — so no transport swap, budget
214
+ state, or agent state leaks between scenarios."""
215
+ self._donkey = Donkey(_offline_config())
216
+ self._probe = None
217
+ try:
218
+ self._agent = _build_agent(self._factory, self._donkey)
219
+ return await scenario.check(self)
220
+ finally:
221
+ await self._donkey.aclose()
222
+ self._donkey = None
223
+ self._agent = None
224
+ self._probe = None
225
+
226
+ # --- ScenarioContext surface -------------------------------------------
227
+ @property
228
+ def agent(self) -> Any:
229
+ return self._agent
230
+
231
+ def serve_refusal(self, error: type[DonkeyError]) -> None:
232
+ self._arm(_fixture_responder(_refusal_fixture(error)))
233
+
234
+ def serve_success(self, *, budget_headers: bool = True) -> None:
235
+ self._arm(_fixture_responder(load("success"), strip_budget=not budget_headers))
236
+
237
+ async def run(self, *, correlation_id: str | None = None) -> Observation:
238
+ agent = self._agent
239
+ run = getattr(agent, "run", None)
240
+ if not callable(run):
241
+ raise ConformanceUsageError(
242
+ f"the agent your factory returned has no callable run(...) method "
243
+ f"(got {type(agent).__name__}); the conformance suite drives "
244
+ f"agent.run(<input>). Adapt your agent or expose a run() shim."
245
+ )
246
+ records: list[logging.LogRecord] = []
247
+ handler = _CaptureHandler(records)
248
+ root = logging.getLogger()
249
+ previous_level = root.level
250
+ root.addHandler(handler)
251
+ root.setLevel(logging.DEBUG) # let INFO/DEBUG records reach the handler
252
+ raised: BaseException | None = None
253
+ returned: Any = None
254
+ bind = (
255
+ self._donkey.run_context(correlation_id)
256
+ if correlation_id is not None and self._donkey is not None
257
+ else nullcontext()
258
+ )
259
+ try:
260
+ with bind:
261
+ result = run(self._run_input)
262
+ returned = await result if inspect.isawaitable(result) else result
263
+ except ConformanceUsageError:
264
+ raise
265
+ except Exception as exc: # noqa: BLE001 — we observe whatever the agent lets escape
266
+ raised = exc
267
+ finally:
268
+ root.removeHandler(handler)
269
+ root.setLevel(previous_level)
270
+ return Observation(
271
+ returned=returned,
272
+ raised=raised,
273
+ wire_sends=self._probe.wire_sends if self._probe is not None else 0,
274
+ log_text=_render_records(records),
275
+ )
276
+
277
+ def _arm(self, responder: Callable[[httpx.Request], httpx.Response]) -> None:
278
+ """Swap a counting probe onto both transports. The sync client is built
279
+ eagerly (it opens no connection until used) so a blocking agent's client
280
+ is armed too, and both share one probe so the wire-send count spans
281
+ async and sync."""
282
+ donkey = self._donkey
283
+ assert donkey is not None # set by run_scenario before any check runs
284
+ self._probe = _ProbeTransport(responder)
285
+ donkey._http._swap_transport(self._probe)
286
+ donkey._sync_http_client()._swap_transport(self._probe)
287
+
288
+
289
+ async def run_conformance(
290
+ agent_factory: AgentFactory,
291
+ *,
292
+ known_limitations: object = None,
293
+ run_input: str = DEFAULT_RUN_INPUT,
294
+ ) -> list[Result]:
295
+ """Run every scenario against ``agent_factory`` and return the table rows.
296
+
297
+ A scenario listed in ``known_limitations`` is reported ``exempt`` with its
298
+ asserted reason and is *not* run (the exemption replaces the check); every
299
+ other scenario runs and is reported ``pass``/``fail``. ``known_limitations``
300
+ is validated up front, so a bad key or empty reason raises before any agent
301
+ is built."""
302
+ known = validate_known_limitations(known_limitations)
303
+ results: list[Result] = []
304
+ for scenario in SCENARIOS:
305
+ if scenario.name in known:
306
+ results.append(
307
+ Result(scenario.name, scenario.title, "exempt", known[scenario.name])
308
+ )
309
+ continue
310
+ harness = ConformanceHarness(agent_factory, run_input=run_input)
311
+ outcome = await harness.run_scenario(scenario)
312
+ results.append(
313
+ Result(
314
+ scenario.name,
315
+ scenario.title,
316
+ "pass" if outcome.passed else "fail",
317
+ outcome.detail,
318
+ )
319
+ )
320
+ return results
@@ -0,0 +1,274 @@
1
+ """The public pytest plugin — ``pytest --donkey-conformance`` (#191, `BG §1.5`).
2
+
3
+ This is the deliverable the milestone's centre of gravity moved to: not our
4
+ internal adapter matrix, but a suite a **customer runs against their own agent**::
5
+
6
+ pip install "donkey-kit[test]"
7
+ pytest --donkey-conformance --agent=my_app.agent:build
8
+
9
+ It registers via a ``pytest11`` entry point, so it auto-loads on *every* pytest
10
+ run in an environment where ``donkey-kit[test]`` is installed — including this
11
+ repo's own base-only job. That constraint drives the two rules this module
12
+ follows without exception:
13
+
14
+ 1. **It is inert unless asked.** With no ``--donkey-conformance`` flag,
15
+ :func:`pytest_collection` returns ``None`` and default collection runs
16
+ untouched; the only footprint is three CLI options and one unused fixture.
17
+ 2. **Its module top is import-light.** It imports only stdlib, ``pytest``, and
18
+ the framework-free :mod:`~.suite`/:mod:`~.report` siblings. The heavy
19
+ harness (which imports :class:`~donkey_kit.donkey.Donkey`) and ``asyncio``
20
+ are imported lazily, inside the functions that need them, so merely loading
21
+ the plugin never drags the full SDK — or a framework — onto the import path.
22
+
23
+ When the flag is set, ``--donkey-conformance`` runs the conformance suite
24
+ *exclusively* (it replaces normal collection), turning each scenario into one
25
+ pytest item and printing the scenario→status table in the terminal summary.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import importlib
31
+ from collections.abc import Callable
32
+ from typing import TYPE_CHECKING, Any
33
+
34
+ import pytest
35
+
36
+ from .report import render_report
37
+ from .suite import SCENARIOS, Result, Scenario, validate_known_limitations
38
+
39
+ if TYPE_CHECKING: # keep the heavy import out of the auto-loaded module body
40
+ from .harness import AgentFactory
41
+
42
+ # Stash keys carry collection-time state to the run/summary phases without
43
+ # module globals (pytest recommends config.stash over ad-hoc attributes).
44
+ _FACTORY_KEY = pytest.StashKey[Callable[..., Any]]()
45
+ _KNOWN_KEY = pytest.StashKey[dict[str, str]]()
46
+ _RESULTS_KEY = pytest.StashKey[list[Result]]()
47
+
48
+
49
+ class _ConformanceFailure(AssertionError):
50
+ """A scenario's FAIL verdict, rendered as the item's failure message with no
51
+ Python traceback — the finding about the agent *is* the message."""
52
+
53
+
54
+ def pytest_addoption(parser: pytest.Parser) -> None:
55
+ group = parser.getgroup("donkey", "Donkey conformance")
56
+ group.addoption(
57
+ "--donkey-conformance",
58
+ action="store_true",
59
+ default=False,
60
+ dest="donkey_conformance",
61
+ help="Run the Donkey conformance suite against your agent "
62
+ "(requires --agent). Replaces normal test collection.",
63
+ )
64
+ group.addoption(
65
+ "--agent",
66
+ action="store",
67
+ default=None,
68
+ dest="donkey_agent",
69
+ metavar="MODULE:FACTORY",
70
+ help="Import path to your agent factory, e.g. my_app.agent:build. "
71
+ "Called once per scenario; receives the donkey fixture if it declares one.",
72
+ )
73
+ group.addoption(
74
+ "--donkey-known-limitations",
75
+ action="store",
76
+ default=None,
77
+ dest="donkey_known_limitations",
78
+ metavar="MODULE:NAME",
79
+ help="Import path to a KNOWN_LIMITATIONS dict of {scenario: reason}. "
80
+ "Defaults to a KNOWN_LIMITATIONS attribute in the --agent module if present.",
81
+ )
82
+
83
+
84
+ def _import_target(spec: str, *, what: str) -> tuple[Any, Any]:
85
+ """Resolve a ``module:attribute`` spec, raising :class:`pytest.UsageError`
86
+ (a clean CLI error, no traceback) for a malformed spec, an unimportable
87
+ module, or a missing attribute."""
88
+ if ":" not in spec:
89
+ raise pytest.UsageError(
90
+ f"{what} must be given as 'module:attribute', got {spec!r}"
91
+ )
92
+ mod_name, _, attr = spec.partition(":")
93
+ if not mod_name or not attr:
94
+ raise pytest.UsageError(
95
+ f"{what} must be given as 'module:attribute', got {spec!r}"
96
+ )
97
+ try:
98
+ module = importlib.import_module(mod_name)
99
+ except ImportError as exc:
100
+ raise pytest.UsageError(f"could not import {mod_name!r} for {what}: {exc}") from exc
101
+ try:
102
+ return module, getattr(module, attr)
103
+ except AttributeError as exc:
104
+ raise pytest.UsageError(
105
+ f"module {mod_name!r} has no attribute {attr!r} for {what}"
106
+ ) from exc
107
+
108
+
109
+ def _resolve_agent(config: pytest.Config) -> tuple[AgentFactory, dict[str, str]]:
110
+ """Resolve the ``--agent`` factory and its known-limitations mapping, and
111
+ validate the exemptions **now** (collection time) so a bad key or an empty
112
+ reason fails loudly before any scenario runs (the conformance kit: asserted, never silent)."""
113
+ agent_spec = config.getoption("donkey_agent")
114
+ if not agent_spec:
115
+ raise pytest.UsageError(
116
+ "--donkey-conformance requires --agent=module:factory "
117
+ "(the import path to your agent factory)"
118
+ )
119
+ module, factory = _import_target(agent_spec, what="the agent factory (--agent)")
120
+ if not callable(factory):
121
+ raise pytest.UsageError(f"--agent target {agent_spec!r} is not callable")
122
+
123
+ known_spec = config.getoption("donkey_known_limitations")
124
+ if known_spec:
125
+ _, known_obj = _import_target(
126
+ known_spec, what="known limitations (--donkey-known-limitations)"
127
+ )
128
+ else:
129
+ # Auto-discover: a KNOWN_LIMITATIONS attribute next to the factory.
130
+ known_obj = getattr(module, "KNOWN_LIMITATIONS", None)
131
+
132
+ try:
133
+ known = validate_known_limitations(known_obj)
134
+ except (TypeError, ValueError) as exc:
135
+ raise pytest.UsageError(str(exc)) from exc
136
+ return factory, known
137
+
138
+
139
+ class _ScenarioItem(pytest.Item):
140
+ """One conformance scenario as a pytest item. All scenarios share a single
141
+ :func:`~donkey_kit.conformance.harness.run_conformance` pass (memoised on
142
+ the config); each item reports its own row's verdict."""
143
+
144
+ def __init__(self, *, name: str, parent: Any, scenario: Scenario) -> None:
145
+ super().__init__(name, parent)
146
+ self._scenario = scenario
147
+
148
+ def runtest(self) -> None:
149
+ result = _result_for(self.config, self._scenario.name)
150
+ if result.status == "fail":
151
+ raise _ConformanceFailure(f"{self._scenario.title}: {result.detail}")
152
+ # pass and exempt both leave the item green; the asserted exemption is
153
+ # validated at collection and shown in the summary table, so it is
154
+ # recorded, not silently skipped (the conformance kit).
155
+ if result.status == "exempt":
156
+ self.add_report_section("call", "exempt", result.detail)
157
+
158
+ def repr_failure(self, excinfo: Any, style: Any = None) -> Any:
159
+ if isinstance(excinfo.value, _ConformanceFailure):
160
+ return str(excinfo.value)
161
+ return super().repr_failure(excinfo)
162
+
163
+ def reportinfo(self) -> tuple[Any, int, str]:
164
+ return self.path, 0, f"conformance: {self._scenario.title}"
165
+
166
+
167
+ class _ConformanceCollector(pytest.Collector):
168
+ """A single synthetic collector so item node ids read
169
+ ``donkey-conformance::<scenario>`` rather than dangling off the session."""
170
+
171
+ def collect(self) -> Any:
172
+ for scenario in SCENARIOS:
173
+ yield _ScenarioItem.from_parent(self, name=scenario.name, scenario=scenario)
174
+
175
+
176
+ @pytest.hookimpl(tryfirst=True)
177
+ def pytest_collection(session: pytest.Session) -> bool | None:
178
+ """When ``--donkey-conformance`` is set, replace normal collection with the
179
+ conformance items. Returning ``None`` (the default) leaves ordinary
180
+ collection untouched, so the plugin is inert on every other pytest run."""
181
+ config = session.config
182
+ if not config.getoption("donkey_conformance"):
183
+ return None
184
+ factory, known = _resolve_agent(config) # UsageError here aborts cleanly
185
+ config.stash[_FACTORY_KEY] = factory
186
+ config.stash[_KNOWN_KEY] = known
187
+ collector = _ConformanceCollector.from_parent(session, name="donkey-conformance")
188
+ session.items = list(session.genitems(collector))
189
+ session.testscollected = len(session.items)
190
+ return True
191
+
192
+
193
+ def _result_for(config: pytest.Config, scenario_name: str) -> Result:
194
+ """Return the :class:`Result` for one scenario, running the whole suite once
195
+ (lazily, on the first item) and caching it on the config stash so every item
196
+ and the terminal summary share the same single run."""
197
+ results = config.stash.get(_RESULTS_KEY, None)
198
+ if results is None:
199
+ import asyncio
200
+
201
+ from .harness import run_conformance
202
+
203
+ factory = config.stash[_FACTORY_KEY]
204
+ known = config.stash[_KNOWN_KEY]
205
+ results = asyncio.run(run_conformance(factory, known_limitations=known))
206
+ config.stash[_RESULTS_KEY] = results
207
+ for result in results:
208
+ if result.scenario == scenario_name:
209
+ return result
210
+ # Unreachable: every scenario has a row. Fail loudly rather than silently.
211
+ raise KeyError(f"no conformance result for scenario {scenario_name!r}")
212
+
213
+
214
+ def pytest_terminal_summary(
215
+ terminalreporter: Any, exitstatus: int, config: pytest.Config
216
+ ) -> None:
217
+ """Print the scenario→status table once, after the run. Only fires when the
218
+ conformance suite actually ran (results are cached on the stash)."""
219
+ results = config.stash.get(_RESULTS_KEY, None)
220
+ if not results:
221
+ return
222
+ terminalreporter.write_line("")
223
+ for line in render_report(results).splitlines():
224
+ terminalreporter.write_line(line)
225
+
226
+
227
+ @pytest.fixture
228
+ def donkey() -> Any:
229
+ """A :class:`~donkey_kit.donkey.Donkey` for tests that drive it directly
230
+ (e.g. ``with donkey.simulate(PIIDetected): ...``).
231
+
232
+ Built from the environment, with placeholder LLM credentials filled in only
233
+ where absent — so ``donkey.openai()`` constructs and ``simulate()`` works
234
+ with no live gateway and no env setup, while any real config you *have* set
235
+ is preserved. Torn down synchronously; the async transport opens no
236
+ connection unless a real request is made (which ``simulate()`` intercepts)."""
237
+ from ..donkey import Donkey
238
+ from .harness import _offline_config
239
+
240
+ fab = Donkey(_offline_config())
241
+ try:
242
+ yield fab
243
+ finally:
244
+ fab.close()
245
+
246
+
247
+ @pytest.fixture
248
+ def gateway() -> Any:
249
+ """A running local gateway simulator on an ephemeral port, for tests whose
250
+ subject does **not** import ``donkey_kit`` — a containerised agent, a Node
251
+ service, an A2A client, a manual ``curl`` (#278)::
252
+
253
+ async def test_agent_stops_after_refusal(gateway):
254
+ gateway.set_scenarios("pii_block:every=1")
255
+ app = deploy(env={"DONKEY_LLM_PROXY_URL": gateway.url})
256
+ await app.run(ticket)
257
+ assert gateway.requests_received == 1 # stopped, did not retry
258
+
259
+ It serves the same captured rejection fixtures ``simulate()`` and ``donkey
260
+ mock`` replay, on a real port bound to ``0`` (so parallel ``pytest -n`` runs
261
+ never collide), and records every request in a spy the test can assert on
262
+ (:attr:`~.gateway.Gateway.requests_received` plus per-request method, path and
263
+ headers with ``client_secret`` redacted). Torn down when the test exits,
264
+ including on failure.
265
+
266
+ Needs the ``[local]`` extra (``starlette`` + ``uvicorn``); taking the fixture
267
+ without it raises an :class:`ImportError` naming the exact ``pip install``."""
268
+ from .gateway import start_gateway
269
+
270
+ gw = start_gateway()
271
+ try:
272
+ yield gw
273
+ finally:
274
+ gw.close()
@@ -0,0 +1,85 @@
1
+ """Rendering the conformance :class:`~.suite.Result` list as a table (#191).
2
+
3
+ Dependency-free on purpose: the pytest plugin calls this from
4
+ ``pytest_terminal_summary`` and a programmatic caller can render the same table
5
+ without pytest installed. No colour codes, no terminal-width probing — a plain
6
+ monospace block that reads the same in CI logs, a terminal, and a PR comment.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Sequence
12
+
13
+ from .suite import Result, Status
14
+
15
+ __all__ = ["StatusCounts", "count_statuses", "format_status", "render_report"]
16
+
17
+ # The report is the SDK "telling the team something true about their own code",
18
+ # so the glyphs are unambiguous in a plain CI log — no colour dependency.
19
+ _GLYPH: dict[Status, str] = {"pass": "PASS", "fail": "FAIL", "exempt": "EXEMPT"}
20
+
21
+
22
+ class StatusCounts(dict[Status, int]):
23
+ """Counts per status with convenience accessors, so callers read
24
+ ``counts.failed`` rather than indexing a bare dict."""
25
+
26
+ @property
27
+ def passed(self) -> int:
28
+ return self.get("pass", 0)
29
+
30
+ @property
31
+ def failed(self) -> int:
32
+ return self.get("fail", 0)
33
+
34
+ @property
35
+ def exempt(self) -> int:
36
+ return self.get("exempt", 0)
37
+
38
+ @property
39
+ def total(self) -> int:
40
+ return self.passed + self.failed + self.exempt
41
+
42
+
43
+ def count_statuses(results: Sequence[Result]) -> StatusCounts:
44
+ counts = StatusCounts()
45
+ for result in results:
46
+ counts[result.status] = counts.get(result.status, 0) + 1
47
+ return counts
48
+
49
+
50
+ def format_status(status: Status) -> str:
51
+ """The fixed-width label for one status (``PASS``/``FAIL``/``EXEMPT``)."""
52
+ return _GLYPH.get(status, status.upper())
53
+
54
+
55
+ def _summary_line(counts: StatusCounts) -> str:
56
+ parts = [f"{counts.passed} passed", f"{counts.failed} failed"]
57
+ if counts.exempt:
58
+ parts.append(f"{counts.exempt} exempt")
59
+ return f"Donkey conformance: {', '.join(parts)}"
60
+
61
+
62
+ def render_report(results: Sequence[Result], *, title: str = "Donkey conformance") -> str:
63
+ """Render the results as a titled, aligned status table with a summary line.
64
+
65
+ Each row is ``<STATUS> <scenario title> — <detail>``. Rows are shown in the
66
+ order given (the suite's canonical scenario order), so the table reads the
67
+ same across runs. An empty result set renders the header and a single
68
+ ``(no scenarios ran)`` line rather than a bare title, so a misconfigured run
69
+ is obvious instead of silent."""
70
+ lines = [title, "=" * len(title)]
71
+ if not results:
72
+ lines.append("(no scenarios ran)")
73
+ return "\n".join(lines)
74
+
75
+ status_width = max(len(format_status(r.status)) for r in results)
76
+ title_width = max(len(r.title) for r in results)
77
+ for result in results:
78
+ label = format_status(result.status).ljust(status_width)
79
+ name = result.title.ljust(title_width)
80
+ detail = f" — {result.detail}" if result.detail else ""
81
+ lines.append(f"{label} {name}{detail}")
82
+
83
+ lines.append("")
84
+ lines.append(_summary_line(count_statuses(results)))
85
+ return "\n".join(lines)