mockserver-client 7.1.0__tar.gz → 7.2.0__tar.gz
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.
- {mockserver_client-7.1.0 → mockserver_client-7.2.0}/PKG-INFO +2 -2
- {mockserver_client-7.1.0 → mockserver_client-7.2.0}/README.md +1 -1
- {mockserver_client-7.1.0 → mockserver_client-7.2.0}/mockserver/__init__.py +88 -0
- {mockserver_client-7.1.0 → mockserver_client-7.2.0}/mockserver/async_client.py +368 -7
- {mockserver_client-7.1.0 → mockserver_client-7.2.0}/mockserver/client.py +150 -1
- {mockserver_client-7.1.0 → mockserver_client-7.2.0}/mockserver/fluent.py +31 -3
- {mockserver_client-7.1.0 → mockserver_client-7.2.0}/mockserver/launcher.py +27 -2
- mockserver_client-7.2.0/mockserver/llm.py +1021 -0
- mockserver_client-7.2.0/mockserver/mcp.py +434 -0
- {mockserver_client-7.1.0 → mockserver_client-7.2.0}/mockserver/models.py +363 -4
- {mockserver_client-7.1.0 → mockserver_client-7.2.0}/mockserver/websocket_client.py +42 -14
- {mockserver_client-7.1.0 → mockserver_client-7.2.0}/mockserver_client.egg-info/PKG-INFO +2 -2
- {mockserver_client-7.1.0 → mockserver_client-7.2.0}/mockserver_client.egg-info/SOURCES.txt +5 -0
- {mockserver_client-7.1.0 → mockserver_client-7.2.0}/pyproject.toml +1 -1
- mockserver_client-7.2.0/tests/test_async_client.py +882 -0
- mockserver_client-7.2.0/tests/test_auth_tls.py +205 -0
- {mockserver_client-7.1.0 → mockserver_client-7.2.0}/tests/test_client.py +363 -2
- {mockserver_client-7.1.0 → mockserver_client-7.2.0}/tests/test_fluent.py +37 -2
- {mockserver_client-7.1.0 → mockserver_client-7.2.0}/tests/test_launcher.py +28 -0
- mockserver_client-7.2.0/tests/test_llm.py +579 -0
- mockserver_client-7.2.0/tests/test_mcp.py +257 -0
- {mockserver_client-7.1.0 → mockserver_client-7.2.0}/tests/test_models.py +185 -1
- mockserver_client-7.1.0/tests/test_async_client.py +0 -475
- {mockserver_client-7.1.0 → mockserver_client-7.2.0}/mockserver/exceptions.py +0 -0
- {mockserver_client-7.1.0 → mockserver_client-7.2.0}/mockserver_client.egg-info/dependency_links.txt +0 -0
- {mockserver_client-7.1.0 → mockserver_client-7.2.0}/mockserver_client.egg-info/requires.txt +0 -0
- {mockserver_client-7.1.0 → mockserver_client-7.2.0}/mockserver_client.egg-info/top_level.txt +0 -0
- {mockserver_client-7.1.0 → mockserver_client-7.2.0}/setup.cfg +0 -0
- {mockserver_client-7.1.0 → mockserver_client-7.2.0}/tests/test_breakpoint.py +0 -0
- {mockserver_client-7.1.0 → mockserver_client-7.2.0}/tests/test_exceptions.py +0 -0
- {mockserver_client-7.1.0 → mockserver_client-7.2.0}/tests/test_init.py +0 -0
- {mockserver_client-7.1.0 → mockserver_client-7.2.0}/tests/test_integration.py +0 -0
- {mockserver_client-7.1.0 → mockserver_client-7.2.0}/tests/test_websocket_client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mockserver-client
|
|
3
|
-
Version: 7.
|
|
3
|
+
Version: 7.2.0
|
|
4
4
|
Summary: Official Python client for MockServer — create expectations, verify requests, and register dynamic callbacks via WebSocket
|
|
5
5
|
Author-email: James Bloom <jamesdbloom@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -352,7 +352,7 @@ launcher_path = ensure_binary() # returns Path to the launcher executable
|
|
|
352
352
|
```python
|
|
353
353
|
from mockserver.launcher import start
|
|
354
354
|
|
|
355
|
-
server = start(port=1080, version="7.
|
|
355
|
+
server = start(port=1080, version="7.2.0")
|
|
356
356
|
# ...
|
|
357
357
|
server.stop()
|
|
358
358
|
```
|
|
@@ -322,7 +322,7 @@ launcher_path = ensure_binary() # returns Path to the launcher executable
|
|
|
322
322
|
```python
|
|
323
323
|
from mockserver.launcher import start
|
|
324
324
|
|
|
325
|
-
server = start(port=1080, version="7.
|
|
325
|
+
server = start(port=1080, version="7.2.0")
|
|
326
326
|
# ...
|
|
327
327
|
server.stop()
|
|
328
328
|
```
|
|
@@ -3,6 +3,8 @@ from mockserver.models import (
|
|
|
3
3
|
BinaryResponse,
|
|
4
4
|
Body,
|
|
5
5
|
ConnectionOptions,
|
|
6
|
+
CrossProtocolScenario,
|
|
7
|
+
CrossProtocolTrigger,
|
|
6
8
|
Delay,
|
|
7
9
|
DelayDistribution,
|
|
8
10
|
DnsRecord,
|
|
@@ -26,12 +28,18 @@ from mockserver.models import (
|
|
|
26
28
|
HttpSseResponse,
|
|
27
29
|
HttpTemplate,
|
|
28
30
|
HttpWebSocketResponse,
|
|
31
|
+
JsonPathBody,
|
|
29
32
|
JsonRpcBody,
|
|
30
33
|
KeyToMultiValue,
|
|
34
|
+
LoadProfile,
|
|
35
|
+
LoadScenario,
|
|
36
|
+
LoadStage,
|
|
37
|
+
LoadStep,
|
|
31
38
|
OpenAPIDefinition,
|
|
32
39
|
OpenAPIExpectation,
|
|
33
40
|
Ports,
|
|
34
41
|
RequestDefinition,
|
|
42
|
+
ResponseMode,
|
|
35
43
|
SocketAddress,
|
|
36
44
|
SseEvent,
|
|
37
45
|
TimeToLive,
|
|
@@ -41,6 +49,37 @@ from mockserver.models import (
|
|
|
41
49
|
VerificationTimes,
|
|
42
50
|
WebSocketMessage,
|
|
43
51
|
)
|
|
52
|
+
from mockserver.llm import (
|
|
53
|
+
Completion,
|
|
54
|
+
ConversationPredicates,
|
|
55
|
+
EmbeddingResponse,
|
|
56
|
+
HttpLlmResponse,
|
|
57
|
+
IsolationSource,
|
|
58
|
+
LlmChaosProfile,
|
|
59
|
+
LlmConversationBuilder,
|
|
60
|
+
LlmFailoverBuilder,
|
|
61
|
+
LlmMockBuilder,
|
|
62
|
+
NormalizationOptions,
|
|
63
|
+
Provider,
|
|
64
|
+
Role,
|
|
65
|
+
StreamingPhysics,
|
|
66
|
+
ToolUse,
|
|
67
|
+
TurnBuilder,
|
|
68
|
+
Usage,
|
|
69
|
+
completion,
|
|
70
|
+
conversation,
|
|
71
|
+
cookie,
|
|
72
|
+
embedding,
|
|
73
|
+
header,
|
|
74
|
+
llm_failover,
|
|
75
|
+
llm_mock,
|
|
76
|
+
llm_response,
|
|
77
|
+
query_parameter,
|
|
78
|
+
streaming_physics,
|
|
79
|
+
time_to_first_token,
|
|
80
|
+
tool_use,
|
|
81
|
+
usage,
|
|
82
|
+
)
|
|
44
83
|
from mockserver.exceptions import (
|
|
45
84
|
MockServerCallbackError,
|
|
46
85
|
MockServerConnectionError,
|
|
@@ -49,6 +88,13 @@ from mockserver.exceptions import (
|
|
|
49
88
|
MockServerWebSocketError,
|
|
50
89
|
)
|
|
51
90
|
from mockserver.client import MockServerClient
|
|
91
|
+
from mockserver.mcp import (
|
|
92
|
+
McpMockBuilder,
|
|
93
|
+
McpPromptBuilder,
|
|
94
|
+
McpResourceBuilder,
|
|
95
|
+
McpToolBuilder,
|
|
96
|
+
mcp_mock,
|
|
97
|
+
)
|
|
52
98
|
from mockserver.async_client import AsyncMockServerClient
|
|
53
99
|
from mockserver.launcher import (
|
|
54
100
|
MockServerProcess,
|
|
@@ -65,6 +111,8 @@ __all__ = [
|
|
|
65
111
|
"BinaryResponse",
|
|
66
112
|
"Body",
|
|
67
113
|
"ConnectionOptions",
|
|
114
|
+
"CrossProtocolScenario",
|
|
115
|
+
"CrossProtocolTrigger",
|
|
68
116
|
"Delay",
|
|
69
117
|
"DelayDistribution",
|
|
70
118
|
"DnsRecord",
|
|
@@ -88,8 +136,47 @@ __all__ = [
|
|
|
88
136
|
"HttpSseResponse",
|
|
89
137
|
"HttpTemplate",
|
|
90
138
|
"HttpWebSocketResponse",
|
|
139
|
+
"JsonPathBody",
|
|
91
140
|
"JsonRpcBody",
|
|
92
141
|
"KeyToMultiValue",
|
|
142
|
+
"LoadProfile",
|
|
143
|
+
"LoadScenario",
|
|
144
|
+
"LoadStage",
|
|
145
|
+
"LoadStep",
|
|
146
|
+
"Completion",
|
|
147
|
+
"ConversationPredicates",
|
|
148
|
+
"EmbeddingResponse",
|
|
149
|
+
"HttpLlmResponse",
|
|
150
|
+
"IsolationSource",
|
|
151
|
+
"LlmChaosProfile",
|
|
152
|
+
"LlmConversationBuilder",
|
|
153
|
+
"LlmFailoverBuilder",
|
|
154
|
+
"LlmMockBuilder",
|
|
155
|
+
"NormalizationOptions",
|
|
156
|
+
"Provider",
|
|
157
|
+
"Role",
|
|
158
|
+
"StreamingPhysics",
|
|
159
|
+
"ToolUse",
|
|
160
|
+
"TurnBuilder",
|
|
161
|
+
"Usage",
|
|
162
|
+
"completion",
|
|
163
|
+
"conversation",
|
|
164
|
+
"cookie",
|
|
165
|
+
"embedding",
|
|
166
|
+
"header",
|
|
167
|
+
"llm_failover",
|
|
168
|
+
"llm_mock",
|
|
169
|
+
"llm_response",
|
|
170
|
+
"query_parameter",
|
|
171
|
+
"streaming_physics",
|
|
172
|
+
"time_to_first_token",
|
|
173
|
+
"tool_use",
|
|
174
|
+
"usage",
|
|
175
|
+
"McpMockBuilder",
|
|
176
|
+
"McpPromptBuilder",
|
|
177
|
+
"McpResourceBuilder",
|
|
178
|
+
"McpToolBuilder",
|
|
179
|
+
"mcp_mock",
|
|
93
180
|
"MockServerCallbackError",
|
|
94
181
|
"MockServerClient",
|
|
95
182
|
"MockServerConnectionError",
|
|
@@ -100,6 +187,7 @@ __all__ = [
|
|
|
100
187
|
"OpenAPIExpectation",
|
|
101
188
|
"Ports",
|
|
102
189
|
"RequestDefinition",
|
|
190
|
+
"ResponseMode",
|
|
103
191
|
"SocketAddress",
|
|
104
192
|
"SseEvent",
|
|
105
193
|
"TimeToLive",
|
|
@@ -23,6 +23,7 @@ from mockserver.models import (
|
|
|
23
23
|
HttpRequest,
|
|
24
24
|
HttpRequestAndHttpResponse,
|
|
25
25
|
HttpResponse,
|
|
26
|
+
LoadScenario,
|
|
26
27
|
OpenAPIExpectation,
|
|
27
28
|
Ports,
|
|
28
29
|
TimeToLive,
|
|
@@ -36,6 +37,59 @@ from mockserver.websocket_client import MockServerWebSocketClient
|
|
|
36
37
|
logger = logging.getLogger(__name__)
|
|
37
38
|
|
|
38
39
|
|
|
40
|
+
class AsyncScenarioHandle:
|
|
41
|
+
"""An async handle to a single named stateful scenario on the server.
|
|
42
|
+
|
|
43
|
+
Obtained via :meth:`AsyncMockServerClient.scenario`. Wraps the
|
|
44
|
+
``/mockserver/scenario/{name}`` control-plane endpoints. Each method returns
|
|
45
|
+
the server's JSON response as a dict (with ``scenarioName`` and
|
|
46
|
+
``currentState`` keys).
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, client: AsyncMockServerClient, name: str) -> None:
|
|
50
|
+
self._client = client
|
|
51
|
+
self._name = name
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def name(self) -> str:
|
|
55
|
+
return self._name
|
|
56
|
+
|
|
57
|
+
async def state(self) -> str | None:
|
|
58
|
+
"""GET the current state of this scenario (``None`` if not yet set)."""
|
|
59
|
+
result = await self._client._scenario_request(
|
|
60
|
+
"GET", f"/mockserver/scenario/{urllib.parse.quote(self._name, safe="")}"
|
|
61
|
+
)
|
|
62
|
+
return result.get("currentState")
|
|
63
|
+
|
|
64
|
+
async def set(
|
|
65
|
+
self,
|
|
66
|
+
state: str,
|
|
67
|
+
transition_after_ms: int | None = None,
|
|
68
|
+
next_state: str | None = None,
|
|
69
|
+
) -> dict:
|
|
70
|
+
"""PUT to set this scenario's state, optionally scheduling a timed
|
|
71
|
+
transition to ``next_state`` after ``transition_after_ms`` milliseconds.
|
|
72
|
+
"""
|
|
73
|
+
payload: dict = {"state": state}
|
|
74
|
+
if transition_after_ms is not None:
|
|
75
|
+
payload["transitionAfterMs"] = transition_after_ms
|
|
76
|
+
if next_state is not None:
|
|
77
|
+
payload["nextState"] = next_state
|
|
78
|
+
return await self._client._scenario_request(
|
|
79
|
+
"PUT",
|
|
80
|
+
f"/mockserver/scenario/{urllib.parse.quote(self._name, safe="")}",
|
|
81
|
+
json.dumps(payload),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
async def trigger(self, new_state: str) -> dict:
|
|
85
|
+
"""PUT an external trigger advancing this scenario to ``new_state``."""
|
|
86
|
+
return await self._client._scenario_request(
|
|
87
|
+
"PUT",
|
|
88
|
+
f"/mockserver/scenario/{urllib.parse.quote(self._name, safe="")}/trigger",
|
|
89
|
+
json.dumps({"newState": new_state}),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
39
93
|
class AsyncMockServerClient:
|
|
40
94
|
def __init__(
|
|
41
95
|
self,
|
|
@@ -45,6 +99,10 @@ class AsyncMockServerClient:
|
|
|
45
99
|
secure: bool = False,
|
|
46
100
|
ca_cert_path: str | None = None,
|
|
47
101
|
tls_verify: bool = True,
|
|
102
|
+
client_cert_path: str | None = None,
|
|
103
|
+
client_key_path: str | None = None,
|
|
104
|
+
control_plane_bearer_token: str | None = None,
|
|
105
|
+
control_plane_bearer_token_supplier: Callable[[], str] | None = None,
|
|
48
106
|
) -> None:
|
|
49
107
|
self._host = host
|
|
50
108
|
self._port = port
|
|
@@ -52,6 +110,10 @@ class AsyncMockServerClient:
|
|
|
52
110
|
self._secure = secure
|
|
53
111
|
self._ca_cert_path = ca_cert_path
|
|
54
112
|
self._tls_verify = tls_verify
|
|
113
|
+
self._client_cert_path = client_cert_path
|
|
114
|
+
self._client_key_path = client_key_path
|
|
115
|
+
self._control_plane_bearer_token = control_plane_bearer_token
|
|
116
|
+
self._control_plane_bearer_token_supplier = control_plane_bearer_token_supplier
|
|
55
117
|
self._websocket_clients: list[MockServerWebSocketClient] = []
|
|
56
118
|
|
|
57
119
|
scheme = "https" if secure else "http"
|
|
@@ -68,30 +130,57 @@ class AsyncMockServerClient:
|
|
|
68
130
|
elif not tls_verify:
|
|
69
131
|
self._ssl_context.check_hostname = False
|
|
70
132
|
self._ssl_context.verify_mode = ssl.CERT_NONE
|
|
133
|
+
if client_cert_path:
|
|
134
|
+
# Load the client certificate chain (and private key) for mTLS.
|
|
135
|
+
self._ssl_context.load_cert_chain(
|
|
136
|
+
certfile=client_cert_path, keyfile=client_key_path
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def _control_plane_bearer(self) -> str | None:
|
|
140
|
+
"""Resolve the control-plane bearer token for the current request.
|
|
141
|
+
|
|
142
|
+
The supplier (if configured) is evaluated per request so rotating /
|
|
143
|
+
short-lived tokens are picked up; otherwise the static token is used.
|
|
144
|
+
"""
|
|
145
|
+
if self._control_plane_bearer_token_supplier is not None:
|
|
146
|
+
return self._control_plane_bearer_token_supplier()
|
|
147
|
+
return self._control_plane_bearer_token
|
|
71
148
|
|
|
72
149
|
async def _request(
|
|
73
150
|
self,
|
|
74
151
|
method: str,
|
|
75
152
|
path: str,
|
|
76
|
-
body: str | None = None,
|
|
153
|
+
body: str | bytes | None = None,
|
|
77
154
|
query_params: dict[str, str] | None = None,
|
|
155
|
+
content_type: str = "application/json; charset=utf-8",
|
|
78
156
|
) -> tuple[int, str]:
|
|
79
157
|
url = f"{self._base_url}{path}"
|
|
80
158
|
if query_params:
|
|
81
159
|
url = f"{url}?{urllib.parse.urlencode(query_params)}"
|
|
82
160
|
|
|
83
|
-
|
|
161
|
+
if isinstance(body, bytes):
|
|
162
|
+
data = body
|
|
163
|
+
elif body:
|
|
164
|
+
data = body.encode("utf-8")
|
|
165
|
+
else:
|
|
166
|
+
data = None
|
|
84
167
|
req = urllib.request.Request(url, data=data, method=method)
|
|
85
|
-
req.add_header("Content-Type",
|
|
168
|
+
req.add_header("Content-Type", content_type)
|
|
169
|
+
bearer = self._control_plane_bearer()
|
|
170
|
+
if bearer:
|
|
171
|
+
req.add_header("Authorization", f"Bearer {bearer}")
|
|
86
172
|
|
|
87
173
|
def _do_request() -> tuple[int, str]:
|
|
88
174
|
try:
|
|
89
|
-
|
|
175
|
+
with urllib.request.urlopen(
|
|
90
176
|
req, context=self._ssl_context, timeout=60
|
|
91
|
-
)
|
|
92
|
-
|
|
177
|
+
) as response:
|
|
178
|
+
return response.status, response.read().decode("utf-8")
|
|
93
179
|
except urllib.error.HTTPError as e:
|
|
94
|
-
|
|
180
|
+
# HTTPError is itself a closeable, response-like object; reading
|
|
181
|
+
# its body without closing leaks the underlying socket/fd.
|
|
182
|
+
with e:
|
|
183
|
+
return e.code, e.read().decode("utf-8")
|
|
95
184
|
except socket.timeout as e:
|
|
96
185
|
raise MockServerConnectionError(
|
|
97
186
|
f"Request to MockServer at {self._base_url} timed out: {e}"
|
|
@@ -227,6 +316,53 @@ class AsyncMockServerClient:
|
|
|
227
316
|
)
|
|
228
317
|
return json.loads(response_body) if response_body else {}
|
|
229
318
|
|
|
319
|
+
async def upload_grpc_descriptor(self, descriptor_set_bytes: bytes) -> None:
|
|
320
|
+
"""Upload a compiled protobuf descriptor set so gRPC requests can be matched.
|
|
321
|
+
|
|
322
|
+
*descriptor_set_bytes* must be the raw bytes of a
|
|
323
|
+
``FileDescriptorSet`` (e.g. the output of
|
|
324
|
+
``protoc --descriptor_set_out=... --include_imports``). The bytes are
|
|
325
|
+
sent verbatim (not base64-encoded) as the request body.
|
|
326
|
+
"""
|
|
327
|
+
if not descriptor_set_bytes:
|
|
328
|
+
raise MockServerError("descriptor set bytes must not be empty")
|
|
329
|
+
status, response_body = await self._request(
|
|
330
|
+
"PUT",
|
|
331
|
+
"/mockserver/grpc/descriptors",
|
|
332
|
+
descriptor_set_bytes,
|
|
333
|
+
content_type="application/octet-stream",
|
|
334
|
+
)
|
|
335
|
+
if status >= 400:
|
|
336
|
+
raise MockServerError(
|
|
337
|
+
f"Failed to upload gRPC descriptor (status={status}): {response_body}"
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
async def retrieve_grpc_services(self) -> list[dict]:
|
|
341
|
+
"""Retrieve the gRPC services registered from uploaded descriptor sets.
|
|
342
|
+
|
|
343
|
+
Returns a list of service dicts, each with a ``name`` and a list of
|
|
344
|
+
``methods`` (``name``, ``inputType``, ``outputType``,
|
|
345
|
+
``clientStreaming``, ``serverStreaming``).
|
|
346
|
+
"""
|
|
347
|
+
status, response_body = await self._request(
|
|
348
|
+
"PUT", "/mockserver/grpc/services"
|
|
349
|
+
)
|
|
350
|
+
if status >= 400:
|
|
351
|
+
raise MockServerError(
|
|
352
|
+
f"Failed to retrieve gRPC services (status={status}): {response_body}"
|
|
353
|
+
)
|
|
354
|
+
return json.loads(response_body) if response_body else []
|
|
355
|
+
|
|
356
|
+
async def clear_grpc_descriptors(self) -> None:
|
|
357
|
+
"""Clear all uploaded gRPC descriptor sets and registered services."""
|
|
358
|
+
status, response_body = await self._request(
|
|
359
|
+
"PUT", "/mockserver/grpc/clear"
|
|
360
|
+
)
|
|
361
|
+
if status >= 400:
|
|
362
|
+
raise MockServerError(
|
|
363
|
+
f"Failed to clear gRPC descriptors (status={status}): {response_body}"
|
|
364
|
+
)
|
|
365
|
+
|
|
230
366
|
async def set_service_chaos(
|
|
231
367
|
self, host: str, chaos: HttpChaosProfile, ttl_millis: int | None = None
|
|
232
368
|
) -> dict:
|
|
@@ -282,6 +418,188 @@ class AsyncMockServerClient:
|
|
|
282
418
|
)
|
|
283
419
|
return json.loads(response_body) if response_body else {}
|
|
284
420
|
|
|
421
|
+
@staticmethod
|
|
422
|
+
def _load_generation_disabled_error(action: str, response_body: str) -> MockServerError:
|
|
423
|
+
return MockServerError(
|
|
424
|
+
f"Failed to {action}: load generation is disabled "
|
|
425
|
+
"(start MockServer with loadGenerationEnabled to enable it) "
|
|
426
|
+
f"(status=403): {response_body}"
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
async def load_scenario(self, scenario: LoadScenario | dict) -> dict:
|
|
430
|
+
"""Register (load) a load-injection scenario in the server-side registry.
|
|
431
|
+
|
|
432
|
+
*scenario* is a :class:`LoadScenario` (or an equivalent dict) and must
|
|
433
|
+
carry a unique ``name``. Registration adds the scenario in the ``LOADED``
|
|
434
|
+
state but does **not** start generating load; it is allowed even when the
|
|
435
|
+
server was started without ``loadGenerationEnabled``.
|
|
436
|
+
|
|
437
|
+
Returns the JSON ``{"name": ..., "state": ...}`` for the registered
|
|
438
|
+
scenario. Use :meth:`start_load_scenarios` to begin generating load.
|
|
439
|
+
"""
|
|
440
|
+
payload = scenario.to_dict() if hasattr(scenario, "to_dict") else scenario
|
|
441
|
+
body = json.dumps(payload)
|
|
442
|
+
status, response_body = await self._request(
|
|
443
|
+
"PUT", "/mockserver/loadScenario", body
|
|
444
|
+
)
|
|
445
|
+
if status >= 400:
|
|
446
|
+
raise MockServerError(
|
|
447
|
+
f"Failed to register load scenario (status={status}): {response_body}"
|
|
448
|
+
)
|
|
449
|
+
return json.loads(response_body) if response_body else {}
|
|
450
|
+
|
|
451
|
+
async def load_scenarios(self) -> dict:
|
|
452
|
+
"""List every registered load scenario.
|
|
453
|
+
|
|
454
|
+
Returns ``{"scenarios": [{"name", "state", "definition", "status"?}, ...]}``.
|
|
455
|
+
"""
|
|
456
|
+
status, response_body = await self._request("GET", "/mockserver/loadScenario")
|
|
457
|
+
if status >= 400:
|
|
458
|
+
raise MockServerError(
|
|
459
|
+
f"Failed to list load scenarios (status={status}): {response_body}"
|
|
460
|
+
)
|
|
461
|
+
return json.loads(response_body) if response_body else {}
|
|
462
|
+
|
|
463
|
+
async def get_load_scenario(self, name: str) -> dict:
|
|
464
|
+
"""Retrieve a single registered load scenario by *name*.
|
|
465
|
+
|
|
466
|
+
Raises :class:`MockServerError` if the scenario does not exist (``404``).
|
|
467
|
+
"""
|
|
468
|
+
status, response_body = await self._request(
|
|
469
|
+
"GET", f"/mockserver/loadScenario/{urllib.parse.quote(name, safe='')}"
|
|
470
|
+
)
|
|
471
|
+
if status == 404:
|
|
472
|
+
raise MockServerError(
|
|
473
|
+
f"Load scenario '{name}' not found (status=404): {response_body}"
|
|
474
|
+
)
|
|
475
|
+
if status >= 400:
|
|
476
|
+
raise MockServerError(
|
|
477
|
+
f"Failed to get load scenario '{name}' (status={status}): {response_body}"
|
|
478
|
+
)
|
|
479
|
+
return json.loads(response_body) if response_body else {}
|
|
480
|
+
|
|
481
|
+
async def delete_load_scenario(self, name: str) -> dict:
|
|
482
|
+
"""Remove a single registered load scenario by *name* (stops it if running)."""
|
|
483
|
+
status, response_body = await self._request(
|
|
484
|
+
"DELETE", f"/mockserver/loadScenario/{urllib.parse.quote(name, safe='')}"
|
|
485
|
+
)
|
|
486
|
+
if status == 404:
|
|
487
|
+
raise MockServerError(
|
|
488
|
+
f"Load scenario '{name}' not found (status=404): {response_body}"
|
|
489
|
+
)
|
|
490
|
+
if status >= 400:
|
|
491
|
+
raise MockServerError(
|
|
492
|
+
f"Failed to delete load scenario '{name}' (status={status}): {response_body}"
|
|
493
|
+
)
|
|
494
|
+
return json.loads(response_body) if response_body else {}
|
|
495
|
+
|
|
496
|
+
async def clear_load_scenarios(self) -> dict:
|
|
497
|
+
"""Remove every registered load scenario (stopping any that are running)."""
|
|
498
|
+
status, response_body = await self._request(
|
|
499
|
+
"DELETE", "/mockserver/loadScenario"
|
|
500
|
+
)
|
|
501
|
+
if status >= 400:
|
|
502
|
+
raise MockServerError(
|
|
503
|
+
f"Failed to clear load scenarios (status={status}): {response_body}"
|
|
504
|
+
)
|
|
505
|
+
return json.loads(response_body) if response_body else {}
|
|
506
|
+
|
|
507
|
+
async def start_load_scenarios(self, names: str | list[str]) -> dict:
|
|
508
|
+
"""Start one or more registered load scenarios.
|
|
509
|
+
|
|
510
|
+
*names* may be a single scenario name or a list of names. The server must
|
|
511
|
+
have been started with ``loadGenerationEnabled``; otherwise this raises
|
|
512
|
+
:class:`MockServerError` reporting the ``403`` response. Honours each
|
|
513
|
+
scenario's ``startDelayMillis``.
|
|
514
|
+
|
|
515
|
+
Returns ``{"started": [{"name", "state"}, ...], "status": ...}``.
|
|
516
|
+
"""
|
|
517
|
+
if isinstance(names, str):
|
|
518
|
+
names = [names]
|
|
519
|
+
body = json.dumps({"names": list(names)})
|
|
520
|
+
status, response_body = await self._request(
|
|
521
|
+
"PUT", "/mockserver/loadScenario/start", body
|
|
522
|
+
)
|
|
523
|
+
if status == 403:
|
|
524
|
+
raise self._load_generation_disabled_error(
|
|
525
|
+
"start load scenarios", response_body
|
|
526
|
+
)
|
|
527
|
+
if status == 404:
|
|
528
|
+
raise MockServerError(
|
|
529
|
+
f"Unknown load scenario (status=404): {response_body}"
|
|
530
|
+
)
|
|
531
|
+
if status >= 400:
|
|
532
|
+
raise MockServerError(
|
|
533
|
+
f"Failed to start load scenarios (status={status}): {response_body}"
|
|
534
|
+
)
|
|
535
|
+
return json.loads(response_body) if response_body else {}
|
|
536
|
+
|
|
537
|
+
async def _scenario_request(
|
|
538
|
+
self, method: str, path: str, body: str | None = None
|
|
539
|
+
) -> dict:
|
|
540
|
+
status, response_body = await self._request(method, path, body)
|
|
541
|
+
if status >= 400:
|
|
542
|
+
raise MockServerError(
|
|
543
|
+
f"Scenario request failed (status={status}): {response_body}"
|
|
544
|
+
)
|
|
545
|
+
return json.loads(response_body) if response_body else {}
|
|
546
|
+
|
|
547
|
+
def scenario(self, name: str) -> AsyncScenarioHandle:
|
|
548
|
+
"""Return a handle to the named stateful scenario, wrapping the
|
|
549
|
+
``/mockserver/scenario/{name}`` control-plane endpoints.
|
|
550
|
+
"""
|
|
551
|
+
return AsyncScenarioHandle(self, name)
|
|
552
|
+
|
|
553
|
+
async def scenarios(self) -> list[dict]:
|
|
554
|
+
"""List every known scenario and its current state.
|
|
555
|
+
|
|
556
|
+
Returns a list of dicts each with ``scenarioName`` and ``currentState``.
|
|
557
|
+
"""
|
|
558
|
+
result = await self._scenario_request("GET", "/mockserver/scenario")
|
|
559
|
+
return result.get("scenarios", [])
|
|
560
|
+
|
|
561
|
+
async def stop_load_scenarios(self, names: str | list[str] | None = None) -> dict:
|
|
562
|
+
"""Stop running load scenarios.
|
|
563
|
+
|
|
564
|
+
*names* may be a single name, a list of names, or ``None`` to stop every
|
|
565
|
+
running scenario.
|
|
566
|
+
|
|
567
|
+
Returns ``{"stopped": [...], "status": ...}``.
|
|
568
|
+
"""
|
|
569
|
+
if names is None:
|
|
570
|
+
payload: dict = {}
|
|
571
|
+
elif isinstance(names, str):
|
|
572
|
+
payload = {"names": [names]}
|
|
573
|
+
else:
|
|
574
|
+
payload = {"names": list(names)}
|
|
575
|
+
body = json.dumps(payload)
|
|
576
|
+
status, response_body = await self._request(
|
|
577
|
+
"PUT", "/mockserver/loadScenario/stop", body
|
|
578
|
+
)
|
|
579
|
+
if status >= 400:
|
|
580
|
+
raise MockServerError(
|
|
581
|
+
f"Failed to stop load scenarios (status={status}): {response_body}"
|
|
582
|
+
)
|
|
583
|
+
return json.loads(response_body) if response_body else {}
|
|
584
|
+
|
|
585
|
+
async def run_load_scenario(self, scenario: LoadScenario | dict) -> dict:
|
|
586
|
+
"""Convenience: register *scenario* then immediately start it.
|
|
587
|
+
|
|
588
|
+
Requires the server to have been started with ``loadGenerationEnabled``
|
|
589
|
+
(the start step responds ``403`` otherwise). Returns the start result
|
|
590
|
+
(``{"started": [...], "status": ...}``).
|
|
591
|
+
"""
|
|
592
|
+
registered = await self.load_scenario(scenario)
|
|
593
|
+
name = registered.get("name") if isinstance(registered, dict) else None
|
|
594
|
+
if not name:
|
|
595
|
+
payload = scenario.to_dict() if hasattr(scenario, "to_dict") else scenario
|
|
596
|
+
name = payload.get("name") if isinstance(payload, dict) else None
|
|
597
|
+
if not name:
|
|
598
|
+
raise MockServerError(
|
|
599
|
+
"Cannot run load scenario: scenario has no name to start"
|
|
600
|
+
)
|
|
601
|
+
return await self.start_load_scenarios(name)
|
|
602
|
+
|
|
285
603
|
async def verify(
|
|
286
604
|
self,
|
|
287
605
|
request: HttpRequest | None = None,
|
|
@@ -385,6 +703,49 @@ class AsyncMockServerClient:
|
|
|
385
703
|
return [Expectation.from_dict(e) for e in parsed]
|
|
386
704
|
return []
|
|
387
705
|
|
|
706
|
+
async def retrieve_expectations_as_code(
|
|
707
|
+
self, fmt: str = "java", request: HttpRequest | None = None
|
|
708
|
+
) -> str:
|
|
709
|
+
"""Retrieve the active expectations as MockServer SDK setup code.
|
|
710
|
+
|
|
711
|
+
Returns the builder code that recreates the expectations, generated in
|
|
712
|
+
the requested language (``fmt`` is one of ``java``, ``javascript``,
|
|
713
|
+
``python``, ``go``, ``csharp``, ``ruby``, ``rust`` or ``php``).
|
|
714
|
+
"""
|
|
715
|
+
body = json.dumps(request.to_dict()) if request else ""
|
|
716
|
+
status, response_body = await self._request(
|
|
717
|
+
"PUT",
|
|
718
|
+
"/mockserver/retrieve",
|
|
719
|
+
body,
|
|
720
|
+
{"type": "ACTIVE_EXPECTATIONS", "format": fmt.upper()},
|
|
721
|
+
)
|
|
722
|
+
if status >= 400:
|
|
723
|
+
raise MockServerError(
|
|
724
|
+
f"Failed to retrieve expectations as code (status={status}): {response_body}"
|
|
725
|
+
)
|
|
726
|
+
return response_body or ""
|
|
727
|
+
|
|
728
|
+
async def retrieve_recorded_expectations_as_code(
|
|
729
|
+
self, fmt: str = "java", request: HttpRequest | None = None
|
|
730
|
+
) -> str:
|
|
731
|
+
"""Retrieve the recorded (proxied) request/response pairs as MockServer
|
|
732
|
+
SDK setup code, generated in the requested language (``fmt`` is one of
|
|
733
|
+
``java``, ``javascript``, ``python``, ``go``, ``csharp``, ``ruby``,
|
|
734
|
+
``rust`` or ``php``).
|
|
735
|
+
"""
|
|
736
|
+
body = json.dumps(request.to_dict()) if request else ""
|
|
737
|
+
status, response_body = await self._request(
|
|
738
|
+
"PUT",
|
|
739
|
+
"/mockserver/retrieve",
|
|
740
|
+
body,
|
|
741
|
+
{"type": "RECORDED_EXPECTATIONS", "format": fmt.upper()},
|
|
742
|
+
)
|
|
743
|
+
if status >= 400:
|
|
744
|
+
raise MockServerError(
|
|
745
|
+
f"Failed to retrieve recorded expectations as code (status={status}): {response_body}"
|
|
746
|
+
)
|
|
747
|
+
return response_body or ""
|
|
748
|
+
|
|
388
749
|
async def retrieve_recorded_requests_and_responses(
|
|
389
750
|
self, request: HttpRequest | None = None
|
|
390
751
|
) -> list[HttpRequestAndHttpResponse]:
|