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
donkey_kit/__init__.py ADDED
@@ -0,0 +1,92 @@
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
+ AuthError,
27
+ BudgetReserveReached,
28
+ ConfigError,
29
+ ContentSafetyBlocked,
30
+ DonkeyError,
31
+ GatewayUnavailable,
32
+ GovernanceDrift,
33
+ ModelSubstituted,
34
+ PIIDetected,
35
+ PolicyViolation,
36
+ PromptInjectionBlocked,
37
+ ProvisioningError,
38
+ PublicationDrift,
39
+ RegistryError,
40
+ TokenBudgetExceeded,
41
+ ToolInvocationError,
42
+ UpstreamModelError,
43
+ )
44
+ from .core.toolspec import ToolSpec, registered_tools
45
+ from .donkey import Donkey
46
+ from .registry import (
47
+ STRICT,
48
+ AssetRef,
49
+ AssetType,
50
+ Contact,
51
+ GovernanceCriteria,
52
+ Publication,
53
+ PublicationAssetType,
54
+ )
55
+
56
+ __version__ = "0.1.0"
57
+
58
+ __all__ = [
59
+ "STRICT",
60
+ "AssetRef",
61
+ "AssetType",
62
+ "AuthError",
63
+ "Budget",
64
+ "BudgetReserveReached",
65
+ "CacheControls",
66
+ "ConfigError",
67
+ "Contact",
68
+ "ContentSafetyBlocked",
69
+ "CostTags",
70
+ "Donkey",
71
+ "DonkeyConfig",
72
+ "DonkeyError",
73
+ "GatewayUnavailable",
74
+ "GovernanceCriteria",
75
+ "GovernanceDrift",
76
+ "ModelSubstituted",
77
+ "PIIDetected",
78
+ "PolicyViolation",
79
+ "PromptInjectionBlocked",
80
+ "Publication",
81
+ "PublicationAssetType",
82
+ "ProvisioningError",
83
+ "PublicationDrift",
84
+ "Region",
85
+ "RegistryError",
86
+ "TokenBudgetExceeded",
87
+ "ToolInvocationError",
88
+ "ToolSpec",
89
+ "UpstreamModelError",
90
+ "__version__",
91
+ "registered_tools",
92
+ ]
@@ -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)