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.
- donkey_kit/__init__.py +92 -0
- donkey_kit/conformance/__init__.py +83 -0
- donkey_kit/conformance/gateway.py +217 -0
- donkey_kit/conformance/harness.py +320 -0
- donkey_kit/conformance/plugin.py +274 -0
- donkey_kit/conformance/report.py +85 -0
- donkey_kit/conformance/suite.py +256 -0
- donkey_kit/core/__init__.py +88 -0
- donkey_kit/core/_verify.py +264 -0
- donkey_kit/core/auth.py +158 -0
- donkey_kit/core/budget.py +264 -0
- donkey_kit/core/cache.py +57 -0
- donkey_kit/core/cachecontrol.py +208 -0
- donkey_kit/core/config.py +357 -0
- donkey_kit/core/cost.py +139 -0
- donkey_kit/core/errors.py +835 -0
- donkey_kit/core/lastcall.py +688 -0
- donkey_kit/core/telemetry.py +754 -0
- donkey_kit/core/toolspec.py +103 -0
- donkey_kit/core/transport.py +1117 -0
- donkey_kit/donkey.py +606 -0
- donkey_kit/governance.py +196 -0
- donkey_kit/integrations/__init__.py +75 -0
- donkey_kit/integrations/_base.py +103 -0
- donkey_kit/integrations/adk.py +57 -0
- donkey_kit/integrations/agent_framework.py +103 -0
- donkey_kit/integrations/anthropic.py +72 -0
- donkey_kit/integrations/crewai.py +57 -0
- donkey_kit/integrations/langgraph.py +136 -0
- donkey_kit/integrations/llamaindex.py +55 -0
- donkey_kit/integrations/openai_agents.py +72 -0
- donkey_kit/integrations/strands.py +48 -0
- donkey_kit/llm/__init__.py +6 -0
- donkey_kit/llm/catalog.py +55 -0
- donkey_kit/llm/client.py +169 -0
- donkey_kit/provisioning/__init__.py +38 -0
- donkey_kit/provisioning/applier.py +56 -0
- donkey_kit/provisioning/cli.py +461 -0
- donkey_kit/provisioning/doctor.py +272 -0
- donkey_kit/provisioning/lint.py +48 -0
- donkey_kit/provisioning/planner.py +52 -0
- donkey_kit/provisioning/publish.py +41 -0
- donkey_kit/provisioning/spec.py +77 -0
- donkey_kit/py.typed +0 -0
- donkey_kit/registry/__init__.py +32 -0
- donkey_kit/registry/exchange.py +93 -0
- donkey_kit/registry/governance.py +78 -0
- donkey_kit/registry/introspect.py +40 -0
- donkey_kit/registry/models.py +65 -0
- donkey_kit/registry/publication.py +134 -0
- donkey_kit/simulator/__init__.py +22 -0
- donkey_kit/simulator/_fixtures/anypoint/llm_proxy/README.md +79 -0
- donkey_kit/simulator/_fixtures/anypoint/llm_proxy/api_describe.openai-sdk.json +120 -0
- donkey_kit/simulator/_fixtures/anypoint/llm_proxy/models.notfound.headers.txt +2 -0
- donkey_kit/simulator/_fixtures/anypoint/llm_proxy/policy_list.openai-sdk.json +62 -0
- donkey_kit/simulator/_fixtures/anypoint/llm_proxy/reject.client-id-missing.body.json +1 -0
- donkey_kit/simulator/_fixtures/anypoint/llm_proxy/reject.client-id-missing.headers.txt +3 -0
- donkey_kit/simulator/_fixtures/anypoint/llm_proxy/reject.model-not-found.body.json +8 -0
- donkey_kit/simulator/_fixtures/anypoint/llm_proxy/reject.pii-detected.body.json +1 -0
- donkey_kit/simulator/_fixtures/anypoint/llm_proxy/reject.pii-detected.headers.txt +15 -0
- donkey_kit/simulator/_fixtures/anypoint/llm_proxy/reject.token-rate-limit.body.empty +0 -0
- donkey_kit/simulator/_fixtures/anypoint/llm_proxy/reject.token-rate-limit.headers.txt +16 -0
- donkey_kit/simulator/_fixtures/anypoint/llm_proxy/responses.stream.headers.txt +5 -0
- donkey_kit/simulator/_fixtures/anypoint/llm_proxy/responses.stream.sample.sse +2 -0
- donkey_kit/simulator/_fixtures/anypoint/llm_proxy/responses.success.body.json +93 -0
- donkey_kit/simulator/_fixtures/anypoint/llm_proxy/responses.success.headers.txt +33 -0
- donkey_kit/simulator/_fixtures/anypoint/llm_proxy/responses.success.policy-applied.headers.txt +7 -0
- donkey_kit/simulator/_fixtures/anypoint/semantic_routing/README.md +62 -0
- donkey_kit/simulator/_fixtures/anypoint/semantic_routing/request.success.http +18 -0
- donkey_kit/simulator/_fixtures/anypoint/semantic_routing/responses.code.body.json +1 -0
- donkey_kit/simulator/_fixtures/anypoint/semantic_routing/responses.code.headers.txt +24 -0
- donkey_kit/simulator/_fixtures/anypoint/semantic_routing/responses.finance.body.json +35 -0
- donkey_kit/simulator/_fixtures/anypoint/semantic_routing/responses.finance.headers.txt +36 -0
- donkey_kit/simulator/_fixtures/anypoint/semantic_routing/responses.offtopic.body.json +35 -0
- donkey_kit/simulator/_fixtures/anypoint/semantic_routing/responses.offtopic.headers.txt +36 -0
- donkey_kit/simulator/_fixtures/fixtures.lock +22 -0
- donkey_kit/simulator/_fixtures/rejections/README.md +82 -0
- donkey_kit/simulator/_fixtures/rejections/reject.content-moderation.body.empty +0 -0
- donkey_kit/simulator/_fixtures/rejections/reject.content-moderation.headers.txt +2 -0
- donkey_kit/simulator/_fixtures/rejections/reject.content-safety-bedrock.body.json +1 -0
- donkey_kit/simulator/_fixtures/rejections/reject.content-safety-bedrock.headers.txt +4 -0
- donkey_kit/simulator/_fixtures/rejections/reject.content-safety.body.json +1 -0
- donkey_kit/simulator/_fixtures/rejections/reject.content-safety.headers.txt +5 -0
- donkey_kit/simulator/_fixtures/rejections/reject.injection-protection.body.empty +0 -0
- donkey_kit/simulator/_fixtures/rejections/reject.injection-protection.headers.txt +3 -0
- donkey_kit/simulator/_fixtures/rejections/reject.regex-prompt-guard.body.json +1 -0
- donkey_kit/simulator/_fixtures/rejections/reject.regex-prompt-guard.headers.txt +2 -0
- donkey_kit/simulator/_fixtures/rejections/reject.upstream-5xx.body.empty +0 -0
- donkey_kit/simulator/_fixtures/rejections/reject.upstream-5xx.headers.txt +2 -0
- donkey_kit/simulator/app.py +283 -0
- donkey_kit/simulator/fixtures.py +435 -0
- donkey_kit/simulator/inject.py +212 -0
- donkey_kit/simulator/scenarios.py +333 -0
- donkey_kit/simulator/server.py +38 -0
- donkey_kit/tools/__init__.py +6 -0
- donkey_kit/tools/filter.py +61 -0
- donkey_kit/tools/session.py +118 -0
- donkey_kit-0.1.0.dist-info/METADATA +229 -0
- donkey_kit-0.1.0.dist-info/RECORD +101 -0
- donkey_kit-0.1.0.dist-info/WHEEL +4 -0
- 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)
|