donkey-kit 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- donkey_kit/__init__.py +102 -0
- donkey_kit/conformance/__init__.py +83 -0
- donkey_kit/conformance/gateway.py +217 -0
- donkey_kit/conformance/harness.py +417 -0
- donkey_kit/conformance/plugin.py +274 -0
- donkey_kit/conformance/report.py +85 -0
- donkey_kit/conformance/suite.py +310 -0
- donkey_kit/core/__init__.py +102 -0
- donkey_kit/core/_verify.py +217 -0
- donkey_kit/core/auth.py +252 -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 +927 -0
- donkey_kit/core/cost.py +139 -0
- donkey_kit/core/endpoints.py +97 -0
- donkey_kit/core/errors.py +1057 -0
- donkey_kit/core/header_names.py +94 -0
- donkey_kit/core/lastcall.py +791 -0
- donkey_kit/core/masking.py +90 -0
- donkey_kit/core/runtime.py +236 -0
- donkey_kit/core/telemetry.py +748 -0
- donkey_kit/core/toolspec.py +103 -0
- donkey_kit/core/transport.py +1895 -0
- donkey_kit/donkey.py +622 -0
- donkey_kit/governance.py +183 -0
- donkey_kit/integrations/__init__.py +90 -0
- donkey_kit/integrations/_base.py +251 -0
- donkey_kit/integrations/_httpx2_bridge.py +177 -0
- donkey_kit/integrations/adk.py +160 -0
- donkey_kit/integrations/agent_framework.py +243 -0
- donkey_kit/integrations/anthropic.py +138 -0
- donkey_kit/integrations/crewai.py +177 -0
- donkey_kit/integrations/langgraph.py +236 -0
- donkey_kit/integrations/llamaindex.py +125 -0
- donkey_kit/integrations/openai_agents.py +56 -0
- donkey_kit/integrations/strands.py +76 -0
- donkey_kit/llm/__init__.py +6 -0
- donkey_kit/llm/catalog.py +55 -0
- donkey_kit/llm/client.py +171 -0
- donkey_kit/provisioning/__init__.py +38 -0
- donkey_kit/provisioning/applier.py +56 -0
- donkey_kit/provisioning/cli.py +495 -0
- donkey_kit/provisioning/doctor.py +328 -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 +90 -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.model-not-routable.body.json +1 -0
- donkey_kit/simulator/_fixtures/anypoint/llm_proxy/reject.model-not-routable.headers.txt +2 -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 +27 -0
- donkey_kit/simulator/_fixtures/rejections/README.md +143 -0
- donkey_kit/simulator/_fixtures/rejections/reject.agent-killed.body.json +1 -0
- donkey_kit/simulator/_fixtures/rejections/reject.agent-killed.headers.txt +2 -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.json +1 -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 +285 -0
- donkey_kit/simulator/fixtures.py +454 -0
- donkey_kit/simulator/inject.py +216 -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.1.dist-info/METADATA +230 -0
- donkey_kit-0.1.1.dist-info/RECORD +110 -0
- donkey_kit-0.1.1.dist-info/WHEEL +4 -0
- donkey_kit-0.1.1.dist-info/entry_points.txt +5 -0
donkey_kit/__init__.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""donkey-kit — an SDK for consuming Agent Fabric
|
|
2
|
+
capabilities from your own agent framework.
|
|
3
|
+
|
|
4
|
+
See the README for the maintainer + support statement and the trademark note.
|
|
5
|
+
"Agent Fabric" is a MuleSoft product name; this package is descriptive.
|
|
6
|
+
|
|
7
|
+
Public surface (BG §1.8):
|
|
8
|
+
|
|
9
|
+
from donkey_kit import Donkey
|
|
10
|
+
donkey = Donkey.from_env()
|
|
11
|
+
donkey.langgraph.chat_model("gpt-4o") # native ChatOpenAI
|
|
12
|
+
|
|
13
|
+
Working-instruction reminder (verification discipline, #2): many platform endpoints/headers/class
|
|
14
|
+
names are UNVERIFIED. Those code paths raise
|
|
15
|
+
``NotImplementedError("blocked on verification: …")`` rather than guessing. See
|
|
16
|
+
docs/verified-apis.md.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from .core.budget import Budget
|
|
22
|
+
from .core.cachecontrol import CacheControls
|
|
23
|
+
from .core.config import DonkeyConfig, Region
|
|
24
|
+
from .core.cost import CostTags
|
|
25
|
+
from .core.errors import (
|
|
26
|
+
AgentKilled,
|
|
27
|
+
AuthError,
|
|
28
|
+
BudgetReserveReached,
|
|
29
|
+
ConfigError,
|
|
30
|
+
ContentSafetyBlocked,
|
|
31
|
+
DonkeyError,
|
|
32
|
+
GatewayUnavailable,
|
|
33
|
+
GovernanceDrift,
|
|
34
|
+
ModelNotRoutable,
|
|
35
|
+
ModelSubstituted,
|
|
36
|
+
PIIDetected,
|
|
37
|
+
PlatformTeamOnly,
|
|
38
|
+
PolicyViolation,
|
|
39
|
+
PromptInjectionBlocked,
|
|
40
|
+
ProvisioningError,
|
|
41
|
+
PublicationDrift,
|
|
42
|
+
RegistryError,
|
|
43
|
+
TokenBudgetExceeded,
|
|
44
|
+
ToolInvocationError,
|
|
45
|
+
UpstreamModelError,
|
|
46
|
+
UpstreamRequestError,
|
|
47
|
+
classify,
|
|
48
|
+
)
|
|
49
|
+
from .core.toolspec import ToolSpec, registered_tools
|
|
50
|
+
from .donkey import Donkey
|
|
51
|
+
from .registry import (
|
|
52
|
+
STRICT,
|
|
53
|
+
AssetRef,
|
|
54
|
+
AssetType,
|
|
55
|
+
Contact,
|
|
56
|
+
GovernanceCriteria,
|
|
57
|
+
Publication,
|
|
58
|
+
PublicationAssetType,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
__version__ = "0.1.1"
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"STRICT",
|
|
65
|
+
"AgentKilled",
|
|
66
|
+
"AssetRef",
|
|
67
|
+
"AssetType",
|
|
68
|
+
"AuthError",
|
|
69
|
+
"Budget",
|
|
70
|
+
"BudgetReserveReached",
|
|
71
|
+
"CacheControls",
|
|
72
|
+
"ConfigError",
|
|
73
|
+
"Contact",
|
|
74
|
+
"ContentSafetyBlocked",
|
|
75
|
+
"CostTags",
|
|
76
|
+
"Donkey",
|
|
77
|
+
"DonkeyConfig",
|
|
78
|
+
"DonkeyError",
|
|
79
|
+
"GatewayUnavailable",
|
|
80
|
+
"GovernanceCriteria",
|
|
81
|
+
"GovernanceDrift",
|
|
82
|
+
"ModelNotRoutable",
|
|
83
|
+
"ModelSubstituted",
|
|
84
|
+
"PIIDetected",
|
|
85
|
+
"PlatformTeamOnly",
|
|
86
|
+
"PolicyViolation",
|
|
87
|
+
"PromptInjectionBlocked",
|
|
88
|
+
"Publication",
|
|
89
|
+
"PublicationAssetType",
|
|
90
|
+
"ProvisioningError",
|
|
91
|
+
"PublicationDrift",
|
|
92
|
+
"Region",
|
|
93
|
+
"RegistryError",
|
|
94
|
+
"TokenBudgetExceeded",
|
|
95
|
+
"ToolInvocationError",
|
|
96
|
+
"ToolSpec",
|
|
97
|
+
"UpstreamModelError",
|
|
98
|
+
"UpstreamRequestError",
|
|
99
|
+
"__version__",
|
|
100
|
+
"classify",
|
|
101
|
+
"registered_tools",
|
|
102
|
+
]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""The customer-facing conformance kit (#191, `BG §1.5`).
|
|
2
|
+
|
|
3
|
+
The public, run-it-against-*your*-agent sibling of the SDK's internal adapter
|
|
4
|
+
matrix (``tests/conformance/``). Install ``donkey-kit[test]`` and run::
|
|
5
|
+
|
|
6
|
+
pytest --donkey-conformance --agent=my_app.agent:build
|
|
7
|
+
|
|
8
|
+
- :mod:`~donkey_kit.conformance.suite` — the four scenarios and their types.
|
|
9
|
+
- :mod:`~donkey_kit.conformance.harness` — the observable ``Donkey`` the
|
|
10
|
+
scenarios run against, plus :func:`run_conformance` for programmatic use.
|
|
11
|
+
- :mod:`~donkey_kit.conformance.report` — the dependency-free status table.
|
|
12
|
+
- :mod:`~donkey_kit.conformance.plugin` — the pytest11 plugin (auto-loaded).
|
|
13
|
+
|
|
14
|
+
**Dev-only sibling of the five production layers** (the layered architecture): an import-linter
|
|
15
|
+
contract forbids ``core``/``llm``/``registry``/``tools``/``integrations`` from
|
|
16
|
+
importing it. Because the pytest11 entry point auto-loads
|
|
17
|
+
:mod:`~donkey_kit.conformance.plugin` on *every* pytest run where this package
|
|
18
|
+
is installed — including the base-only job — only the framework-free
|
|
19
|
+
``suite``/``report`` submodules are imported at package top. The harness (which
|
|
20
|
+
imports :class:`~donkey_kit.donkey.Donkey`) is exposed lazily via
|
|
21
|
+
:func:`__getattr__`, so ``import donkey_kit.conformance`` never eagerly drags
|
|
22
|
+
the full SDK — or a framework — onto the base import path.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import TYPE_CHECKING, Any
|
|
28
|
+
|
|
29
|
+
from .report import StatusCounts, count_statuses, render_report
|
|
30
|
+
from .suite import (
|
|
31
|
+
PROBE_CORRELATION_ID,
|
|
32
|
+
SCENARIO_NAMES,
|
|
33
|
+
SCENARIOS,
|
|
34
|
+
Observation,
|
|
35
|
+
Outcome,
|
|
36
|
+
Result,
|
|
37
|
+
Scenario,
|
|
38
|
+
ScenarioContext,
|
|
39
|
+
Status,
|
|
40
|
+
validate_known_limitations,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING: # for type-checkers only; not imported at runtime module top
|
|
44
|
+
from .harness import (
|
|
45
|
+
ConformanceHarness,
|
|
46
|
+
ConformanceUsageError,
|
|
47
|
+
run_conformance,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"PROBE_CORRELATION_ID",
|
|
52
|
+
"SCENARIOS",
|
|
53
|
+
"SCENARIO_NAMES",
|
|
54
|
+
"ConformanceHarness",
|
|
55
|
+
"ConformanceUsageError",
|
|
56
|
+
"Observation",
|
|
57
|
+
"Outcome",
|
|
58
|
+
"Result",
|
|
59
|
+
"Scenario",
|
|
60
|
+
"ScenarioContext",
|
|
61
|
+
"Status",
|
|
62
|
+
"StatusCounts",
|
|
63
|
+
"count_statuses",
|
|
64
|
+
"render_report",
|
|
65
|
+
"run_conformance",
|
|
66
|
+
"validate_known_limitations",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
# The harness pulls in Donkey; import it only when actually asked for, so the
|
|
70
|
+
# auto-loaded plugin path stays light (mirrors the lazy-adapter discipline).
|
|
71
|
+
_LAZY = {"ConformanceHarness", "ConformanceUsageError", "run_conformance"}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def __getattr__(name: str) -> Any:
|
|
75
|
+
if name in _LAZY:
|
|
76
|
+
from . import harness
|
|
77
|
+
|
|
78
|
+
return getattr(harness, name)
|
|
79
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def __dir__() -> list[str]:
|
|
83
|
+
return sorted(__all__)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""An out-of-process local gateway simulator a test can assert against (#278, `BG §1.5`).
|
|
2
|
+
|
|
3
|
+
``simulate()`` (#190) and the in-process ``donkey`` fixture (#191) cover anything
|
|
4
|
+
that calls the SDK in-process. They cannot cover the cases the vision deck names
|
|
5
|
+
explicitly — a containerised agent, a Node service, an A2A client, a manual
|
|
6
|
+
``curl`` — none of which import ``donkey_kit``. Those need a **real listener on a
|
|
7
|
+
real port**, plus a way to ask the gateway what it *received* ("did my agent stop
|
|
8
|
+
after the refusal, or retry four more times?").
|
|
9
|
+
|
|
10
|
+
This module boots the same local gateway simulator
|
|
11
|
+
(:func:`donkey_kit.simulator.build_app`, BG §1.4) that ``simulate()`` and
|
|
12
|
+
``donkey mock`` replay — so a stock client sees byte-identical rejection shapes —
|
|
13
|
+
on an **ephemeral port** (bind ``0``) in a daemon thread, and wraps it in a
|
|
14
|
+
request-recording ASGI spy the test can assert on. The public entry point is the
|
|
15
|
+
``gateway`` pytest fixture in :mod:`~donkey_kit.conformance.plugin`; this module
|
|
16
|
+
is imported lazily from there so the auto-loaded plugin never drags ``uvicorn``
|
|
17
|
+
onto every pytest run.
|
|
18
|
+
|
|
19
|
+
Framework isolation (the layered architecture): ``uvicorn`` is imported lazily
|
|
20
|
+
inside :func:`start_gateway`, and ``starlette`` only inside the simulator's own
|
|
21
|
+
``build_app`` — so ``import donkey_kit.conformance.gateway`` stays green under the
|
|
22
|
+
base-only CI job (``[dev]`` only, no ``[local]`` extra). Taking the fixture
|
|
23
|
+
without that extra raises an :class:`ImportError` naming the exact
|
|
24
|
+
``pip install`` — never a bare ``ModuleNotFoundError`` and never a silent skip.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
from collections.abc import Awaitable, Callable, Mapping, MutableMapping
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from ..simulator.app import ASGIApp, SimulatorConfig, build_app
|
|
36
|
+
from ..simulator.scenarios import Scenario, parse_scenario
|
|
37
|
+
|
|
38
|
+
__all__ = ["Gateway", "RecordedRequest", "start_gateway"]
|
|
39
|
+
|
|
40
|
+
# The one extra that makes the out-of-process path real: starlette (the ASGI app)
|
|
41
|
+
# + uvicorn (the server). Both live in `[local]`, so the hint names that extra —
|
|
42
|
+
# not `[test]`, which ships the fixture itself but not the server it drives.
|
|
43
|
+
_LOCAL_EXTRA_HINT = (
|
|
44
|
+
"The `gateway` fixture needs the local simulator server extra "
|
|
45
|
+
"(starlette + uvicorn). Install it with:\n"
|
|
46
|
+
' pip install "donkey-kit[local]"'
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Request headers whose values are redacted in the spy: the consumer-auth secret
|
|
50
|
+
# (BG §1.1 — the `client_secret` request header the transport sends) and any
|
|
51
|
+
# bearer token. Matched case-insensitively; ASGI already lowercases header names.
|
|
52
|
+
_REDACTED_HEADERS = frozenset({"client_secret", "authorization"})
|
|
53
|
+
_REDACTED = "***"
|
|
54
|
+
|
|
55
|
+
# How long to wait for uvicorn to report `started` before giving up.
|
|
56
|
+
_STARTUP_TIMEOUT_S = 10.0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class RecordedRequest:
|
|
61
|
+
"""One request the gateway received, as seen from the gateway's side.
|
|
62
|
+
|
|
63
|
+
``headers`` has the consumer-auth secret redacted (see :data:`_REDACTED_HEADERS`),
|
|
64
|
+
so a captured request is safe to print in an assertion failure or a fixture
|
|
65
|
+
dump. It does not carry the body — the simulator replays captured shapes and
|
|
66
|
+
does not evaluate request content (#189/#250)."""
|
|
67
|
+
|
|
68
|
+
method: str
|
|
69
|
+
path: str
|
|
70
|
+
headers: Mapping[str, str]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _record(scope: MutableMapping[str, Any]) -> RecordedRequest:
|
|
74
|
+
"""Build a :class:`RecordedRequest` from an ASGI ``http`` scope, redacting
|
|
75
|
+
the consumer-auth secret and any bearer header."""
|
|
76
|
+
raw: Any = scope.get("headers") or []
|
|
77
|
+
headers: dict[str, str] = {}
|
|
78
|
+
for name, value in raw:
|
|
79
|
+
key = name.decode("latin-1")
|
|
80
|
+
headers[key] = _REDACTED if key.lower() in _REDACTED_HEADERS else value.decode("latin-1")
|
|
81
|
+
return RecordedRequest(
|
|
82
|
+
method=str(scope.get("method", "")),
|
|
83
|
+
path=str(scope.get("path", "")),
|
|
84
|
+
headers=headers,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _RecordingApp:
|
|
89
|
+
"""An ASGI app that records every HTTP request, then delegates to a swappable
|
|
90
|
+
inner app. The inner app is swapped (never the outer one uvicorn holds) so
|
|
91
|
+
:meth:`Gateway.set_scenarios` can reconfigure the simulator live, without
|
|
92
|
+
restarting the server or losing the request log."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
95
|
+
self.app = app
|
|
96
|
+
self.requests: list[RecordedRequest] = []
|
|
97
|
+
|
|
98
|
+
async def __call__(
|
|
99
|
+
self,
|
|
100
|
+
scope: MutableMapping[str, Any],
|
|
101
|
+
receive: Callable[[], Awaitable[MutableMapping[str, Any]]],
|
|
102
|
+
send: Callable[[MutableMapping[str, Any]], Awaitable[None]],
|
|
103
|
+
) -> None:
|
|
104
|
+
if scope.get("type") == "http":
|
|
105
|
+
self.requests.append(_record(scope))
|
|
106
|
+
await self.app(scope, receive, send)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Gateway:
|
|
110
|
+
"""A running out-of-process simulator plus the spy over what it received.
|
|
111
|
+
|
|
112
|
+
Yielded by the ``gateway`` pytest fixture. ``.url`` is a real
|
|
113
|
+
``http://127.0.0.1:<port>`` any process can point ``DONKEY_LLM_PROXY_URL`` at;
|
|
114
|
+
the simulator replays the same captured fixtures the SDK's typed refusals are
|
|
115
|
+
tested against, and every response carries ``x-donkey-simulator: true`` (#189).
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self, *, url: str, recorder: _RecordingApp, shutdown: Callable[[], None]
|
|
120
|
+
) -> None:
|
|
121
|
+
self._url = url
|
|
122
|
+
self._recorder = recorder
|
|
123
|
+
self._shutdown = shutdown
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def url(self) -> str:
|
|
127
|
+
"""The base URL of the running simulator, e.g. ``http://127.0.0.1:54123``."""
|
|
128
|
+
return self._url
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def requests_received(self) -> int:
|
|
132
|
+
"""How many requests the gateway has received since start (or the last
|
|
133
|
+
:meth:`reset`)."""
|
|
134
|
+
return len(self._recorder.requests)
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def requests(self) -> list[RecordedRequest]:
|
|
138
|
+
"""A snapshot of the recorded requests, oldest first (secret redacted)."""
|
|
139
|
+
return list(self._recorder.requests)
|
|
140
|
+
|
|
141
|
+
def set_scenarios(self, *scenarios: str | Scenario) -> None:
|
|
142
|
+
"""Reconfigure the simulator's #188 fault-injection scenarios for this
|
|
143
|
+
test, live (no restart). Each argument is either a parsed
|
|
144
|
+
:class:`~donkey_kit.simulator.scenarios.Scenario` or a ``--scenario`` spec
|
|
145
|
+
string (e.g. ``"pii_block:every=1"``). Scenarios are stateful and
|
|
146
|
+
single-use, so this builds a fresh simulator each call; the request log is
|
|
147
|
+
left untouched (use :meth:`reset` to clear it)."""
|
|
148
|
+
parsed = tuple(
|
|
149
|
+
parse_scenario(s) if isinstance(s, str) else s for s in scenarios
|
|
150
|
+
)
|
|
151
|
+
self._recorder.app = build_app(SimulatorConfig(scenarios=parsed))
|
|
152
|
+
|
|
153
|
+
def reset(self) -> None:
|
|
154
|
+
"""Clear the recorded-request log (the running server is left alone)."""
|
|
155
|
+
self._recorder.requests.clear()
|
|
156
|
+
|
|
157
|
+
def close(self) -> None:
|
|
158
|
+
"""Stop the server and join its thread. Idempotent; the fixture calls it
|
|
159
|
+
on teardown, including when the test failed."""
|
|
160
|
+
self._shutdown()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _bound_port(server: Any) -> int:
|
|
164
|
+
"""Read the ephemeral port uvicorn actually bound. Valid only once
|
|
165
|
+
``server.started`` is true (that is when ``server.servers`` is populated),
|
|
166
|
+
which is what makes ``port=0`` race-free — we never guess a port."""
|
|
167
|
+
for srv in getattr(server, "servers", None) or []:
|
|
168
|
+
for sock in getattr(srv, "sockets", None) or []:
|
|
169
|
+
return int(sock.getsockname()[1])
|
|
170
|
+
raise RuntimeError("could not determine the local gateway simulator's bound port")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def start_gateway(
|
|
174
|
+
config: SimulatorConfig | None = None,
|
|
175
|
+
*,
|
|
176
|
+
host: str = "127.0.0.1",
|
|
177
|
+
startup_timeout: float = _STARTUP_TIMEOUT_S,
|
|
178
|
+
) -> Gateway:
|
|
179
|
+
"""Boot the local gateway simulator on an ephemeral port and return a
|
|
180
|
+
:class:`Gateway` handle. The caller owns teardown via :meth:`Gateway.close`.
|
|
181
|
+
|
|
182
|
+
Raises :class:`ImportError` (naming ``pip install "donkey-kit[local]"``) when
|
|
183
|
+
the ``[local]`` extra — ``starlette`` + ``uvicorn`` — is not installed.
|
|
184
|
+
"""
|
|
185
|
+
try:
|
|
186
|
+
import uvicorn # lazy: provided by the [local] extra
|
|
187
|
+
except ImportError as exc:
|
|
188
|
+
raise ImportError(_LOCAL_EXTRA_HINT) from exc
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
# build_app imports starlette lazily; a missing [local] surfaces here too.
|
|
192
|
+
recorder = _RecordingApp(build_app(config))
|
|
193
|
+
except ImportError as exc:
|
|
194
|
+
raise ImportError(_LOCAL_EXTRA_HINT) from exc
|
|
195
|
+
|
|
196
|
+
uv_config = uvicorn.Config(recorder, host=host, port=0, log_level="warning")
|
|
197
|
+
server = uvicorn.Server(uv_config)
|
|
198
|
+
thread = threading.Thread(target=server.run, daemon=True)
|
|
199
|
+
thread.start()
|
|
200
|
+
|
|
201
|
+
deadline = time.time() + startup_timeout
|
|
202
|
+
while not server.started and time.time() < deadline:
|
|
203
|
+
time.sleep(0.02)
|
|
204
|
+
if not server.started:
|
|
205
|
+
server.should_exit = True
|
|
206
|
+
thread.join(timeout=5.0)
|
|
207
|
+
raise RuntimeError(
|
|
208
|
+
f"local gateway simulator did not start within {startup_timeout}s"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
port = _bound_port(server)
|
|
212
|
+
|
|
213
|
+
def _shutdown() -> None:
|
|
214
|
+
server.should_exit = True
|
|
215
|
+
thread.join(timeout=10.0)
|
|
216
|
+
|
|
217
|
+
return Gateway(url=f"http://{host}:{port}", recorder=recorder, shutdown=_shutdown)
|