mockserver-client 9.1.0__tar.gz → 9.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-9.1.0 → mockserver_client-9.2.0}/PKG-INFO +97 -2
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/README.md +96 -1
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/mockserver/__init__.py +12 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/mockserver/async_client.py +160 -13
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/mockserver/client.py +63 -10
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/mockserver/fluent.py +29 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/mockserver/launcher.py +1 -1
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/mockserver/models.py +523 -24
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/mockserver/websocket_client.py +11 -1
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/mockserver_client.egg-info/PKG-INFO +97 -2
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/mockserver_client.egg-info/SOURCES.txt +5 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/pyproject.toml +1 -1
- mockserver_client-9.2.0/tests/test_any_of_request_definition.py +102 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/tests/test_async_client.py +1 -1
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/tests/test_auth_tls.py +62 -1
- mockserver_client-9.2.0/tests/test_body_date_time_matcher.py +62 -0
- mockserver_client-9.2.0/tests/test_date_time_matcher.py +166 -0
- mockserver_client-9.2.0/tests/test_expectation_filter.py +319 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/tests/test_explain.py +2 -1
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/tests/test_init.py +2 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/tests/test_models.py +76 -0
- mockserver_client-9.2.0/tests/test_recorded_expectations_options.py +176 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/tests/test_websocket_client.py +14 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/mockserver/a2a.py +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/mockserver/exceptions.py +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/mockserver/llm.py +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/mockserver/mcp.py +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/mockserver/py.typed +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/mockserver/pytest_plugin.py +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/mockserver_client.egg-info/dependency_links.txt +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/mockserver_client.egg-info/entry_points.txt +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/mockserver_client.egg-info/requires.txt +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/mockserver_client.egg-info/top_level.txt +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/setup.cfg +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/tests/test_a2a.py +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/tests/test_breakpoint.py +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/tests/test_client.py +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/tests/test_conditional_request_and_load_check.py +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/tests/test_control_plane.py +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/tests/test_exceptions.py +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/tests/test_fluent.py +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/tests/test_from_dict_fidelity.py +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/tests/test_integration.py +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/tests/test_key_multi_value_forms.py +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/tests/test_launcher.py +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/tests/test_llm.py +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/tests/test_mcp.py +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/tests/test_override_forwarded_request.py +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/tests/test_pytest_plugin.py +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/tests/test_roundtrip_fidelity.py +0 -0
- {mockserver_client-9.1.0 → mockserver_client-9.2.0}/tests/test_sync_chain_parity.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mockserver-client
|
|
3
|
-
Version: 9.
|
|
3
|
+
Version: 9.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
|
|
@@ -238,6 +238,35 @@ client.when(
|
|
|
238
238
|
HttpRequest(headers={"keyMatchStyle": "MATCHING_KEY", "X-Tag": ["a", "b"]})
|
|
239
239
|
```
|
|
240
240
|
|
|
241
|
+
A value can also be a `DateTimeMatcher`, which matches a header, query or path
|
|
242
|
+
parameter, or cookie holding a date-time before, after or equal to an expected
|
|
243
|
+
value (`now`, `now -1 hours`, or a date-time such as `2026-10-21T00:00:00Z`).
|
|
244
|
+
A plain string such as `"before:2026-01-01"` is always matched literally.
|
|
245
|
+
|
|
246
|
+
```python
|
|
247
|
+
from mockserver import DateTimeMatcher, HttpRequest
|
|
248
|
+
|
|
249
|
+
HttpRequest(path="/orders") \
|
|
250
|
+
.with_header("X-Issued-At", DateTimeMatcher.after("now -1 hours")) \
|
|
251
|
+
.with_header("If-Modified-Since", DateTimeMatcher.before("now").with_actual_format("rfc1123")) \
|
|
252
|
+
.with_cookie("seen", DateTimeMatcher.equal_to_date_time("now").with_truncate_actual("startOfDay"))
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Each `with_*` option (`with_actual_format`, `with_truncate_expected`,
|
|
256
|
+
`with_truncate_actual`, `with_apply_truncation_last`, `with_zone`, `with_not`)
|
|
257
|
+
returns a new matcher.
|
|
258
|
+
|
|
259
|
+
A JSONPath or XPath body matcher can take a `DateTimeMatcher` too: the body
|
|
260
|
+
matches when a value the JSONPath selects, or the text of a node the XPath
|
|
261
|
+
selects, is a matching date-time.
|
|
262
|
+
|
|
263
|
+
```python
|
|
264
|
+
from mockserver import Body, DateTimeMatcher, HttpRequest
|
|
265
|
+
|
|
266
|
+
HttpRequest(path="/orders", body=Body.json_path("$.order.created", DateTimeMatcher.after("now -1 days")))
|
|
267
|
+
HttpRequest(path="/soap", body=Body.xpath("/order/@expires", None, DateTimeMatcher.after("now")))
|
|
268
|
+
```
|
|
269
|
+
|
|
241
270
|
### JWT matcher
|
|
242
271
|
|
|
243
272
|
Match requests by the claims (and metadata) of a JWT carried in a header. Each
|
|
@@ -269,6 +298,32 @@ client.when(
|
|
|
269
298
|
HttpRequest.request("/secure").with_jwt(Jwt(claims={"sub": "user-123"}))
|
|
270
299
|
```
|
|
271
300
|
|
|
301
|
+
### Client IP matcher
|
|
302
|
+
|
|
303
|
+
Match requests by the client IP address or CIDR range, IPv4 or IPv6. The client address is the TCP peer of the connection, not `X-Forwarded-For`; prefix with `!` to match every other address.
|
|
304
|
+
|
|
305
|
+
```python
|
|
306
|
+
HttpRequest.request("/internal").with_client_ip("10.0.0.0/8")
|
|
307
|
+
# or HttpRequest(path="/internal", client_ip="!10.0.0.0/8")
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### Any-of (logical OR) matcher
|
|
311
|
+
|
|
312
|
+
`AnyOfRequestDefinition` matches a request when any one of its request matchers does, tried in order. Entries may be `HttpRequest`, `OpenAPIDefinition`, `ConditionalRequestDefinition` or a nested `AnyOfRequestDefinition`; `not_any_of=True` matches requests that match none of them. It serialises to `{"anyOf": [...]}` inside `httpRequest`.
|
|
313
|
+
|
|
314
|
+
```python
|
|
315
|
+
from mockserver import AnyOfRequestDefinition, HttpRequest, HttpResponse
|
|
316
|
+
|
|
317
|
+
client.when(
|
|
318
|
+
AnyOfRequestDefinition.request_any_of(
|
|
319
|
+
HttpRequest(method="GET", path="/pets"),
|
|
320
|
+
HttpRequest(method="GET", path="/animals"),
|
|
321
|
+
)
|
|
322
|
+
).respond(HttpResponse(body="pets or animals"))
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
`ExplainedExpectation.any_of` (an `AnyOfBranchComparison`) says which entry came closest when none matches.
|
|
326
|
+
|
|
272
327
|
### allOf body matcher
|
|
273
328
|
|
|
274
329
|
Require a request body to satisfy **all** of several body matchers at once.
|
|
@@ -329,6 +384,40 @@ expectations = client.retrieve_active_expectations()
|
|
|
329
384
|
logs = client.retrieve_log_messages()
|
|
330
385
|
```
|
|
331
386
|
|
|
387
|
+
### Expectation metadata, unmatched expectations and paging
|
|
388
|
+
|
|
389
|
+
Label expectations with metadata (string keys, any JSON value). Metadata never
|
|
390
|
+
affects matching; it is returned on retrieve and lets you retrieve or clear
|
|
391
|
+
just your own expectations.
|
|
392
|
+
|
|
393
|
+
```python
|
|
394
|
+
from mockserver import ExpectationFilter, JSON_NULL, RecordedExpectationsOptions
|
|
395
|
+
|
|
396
|
+
client.when(HttpRequest.request("/api/users")).with_metadata("owner", "team-a").with_metadata(
|
|
397
|
+
"ticket", 4211
|
|
398
|
+
).respond(HttpResponse(status_code=200))
|
|
399
|
+
|
|
400
|
+
mine = client.retrieve_active_expectations_by_metadata("owner", "team-a")
|
|
401
|
+
labelled = client.retrieve_active_expectations_by_metadata_key("owner")
|
|
402
|
+
never_matched = client.retrieve_unmatched_expectations()
|
|
403
|
+
page = client.retrieve_active_expectations(
|
|
404
|
+
expectation_filter=ExpectationFilter().with_metadata_json_path("$.tags[?(@ == 'smoke')]").with_offset(0).with_limit(20)
|
|
405
|
+
)
|
|
406
|
+
recorded_page = client.retrieve_recorded_expectations(offset=0, limit=50)
|
|
407
|
+
# replay changing responses in order, keep only some request headers, move large bodies into files
|
|
408
|
+
shaped = client.retrieve_recorded_expectations(
|
|
409
|
+
options=RecordedExpectationsOptions().with_repeats_as_scenarios().with_capture_headers("X-Tenant").with_extract_bodies_larger_than(1024)
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
client.clear_by_metadata("owner", "team-a") # str compared as text, other values as JSON
|
|
413
|
+
client.clear_by_metadata_key("ticket") # any value
|
|
414
|
+
client.clear_unmatched_expectations()
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
These calls raise `ValueError` before sending anything for a blank key or JSONPath,
|
|
418
|
+
a `None` value (use `..._by_metadata_key` for any value, or `JSON_NULL` for a JSON
|
|
419
|
+
null), a negative offset or limit, or a clear whose filter selects nothing or pages.
|
|
420
|
+
|
|
332
421
|
## Control
|
|
333
422
|
|
|
334
423
|
```python
|
|
@@ -474,6 +563,12 @@ client = MockServerClient("localhost", 1080, control_plane_bearer_token=jwt)
|
|
|
474
563
|
client = MockServerClient("localhost", 1080, control_plane_bearer_token_supplier=fetch_jwt)
|
|
475
564
|
```
|
|
476
565
|
|
|
566
|
+
When MockServer accepts control-plane Basic authentication, pass `control_plane_basic_auth=(username, password)` with `secure=True` (MockServer refuses Basic credentials without TLS). They are sent on the same requests and WebSocket; a bearer token, when also set, is sent instead.
|
|
567
|
+
|
|
568
|
+
```python
|
|
569
|
+
client = MockServerClient("localhost", 1080, secure=True, control_plane_basic_auth=("admin", os.environ["MOCKSERVER_PASSWORD"]))
|
|
570
|
+
```
|
|
571
|
+
|
|
477
572
|
When MockServer requires mutual TLS for the control plane, pass `client_cert_path` and `client_key_path` (with `secure=True` and, for a private CA, `ca_cert_path`). The client presents that certificate, and trusts that CA, on every control-plane request and on the callback WebSocket.
|
|
478
573
|
|
|
479
574
|
```python
|
|
@@ -584,7 +679,7 @@ launcher_path = ensure_binary() # returns Path to the launcher executable
|
|
|
584
679
|
```python
|
|
585
680
|
from mockserver.launcher import start
|
|
586
681
|
|
|
587
|
-
server = start(port=1080, version="9.
|
|
682
|
+
server = start(port=1080, version="9.2.0")
|
|
588
683
|
# ...
|
|
589
684
|
server.stop()
|
|
590
685
|
```
|
|
@@ -208,6 +208,35 @@ client.when(
|
|
|
208
208
|
HttpRequest(headers={"keyMatchStyle": "MATCHING_KEY", "X-Tag": ["a", "b"]})
|
|
209
209
|
```
|
|
210
210
|
|
|
211
|
+
A value can also be a `DateTimeMatcher`, which matches a header, query or path
|
|
212
|
+
parameter, or cookie holding a date-time before, after or equal to an expected
|
|
213
|
+
value (`now`, `now -1 hours`, or a date-time such as `2026-10-21T00:00:00Z`).
|
|
214
|
+
A plain string such as `"before:2026-01-01"` is always matched literally.
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
from mockserver import DateTimeMatcher, HttpRequest
|
|
218
|
+
|
|
219
|
+
HttpRequest(path="/orders") \
|
|
220
|
+
.with_header("X-Issued-At", DateTimeMatcher.after("now -1 hours")) \
|
|
221
|
+
.with_header("If-Modified-Since", DateTimeMatcher.before("now").with_actual_format("rfc1123")) \
|
|
222
|
+
.with_cookie("seen", DateTimeMatcher.equal_to_date_time("now").with_truncate_actual("startOfDay"))
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Each `with_*` option (`with_actual_format`, `with_truncate_expected`,
|
|
226
|
+
`with_truncate_actual`, `with_apply_truncation_last`, `with_zone`, `with_not`)
|
|
227
|
+
returns a new matcher.
|
|
228
|
+
|
|
229
|
+
A JSONPath or XPath body matcher can take a `DateTimeMatcher` too: the body
|
|
230
|
+
matches when a value the JSONPath selects, or the text of a node the XPath
|
|
231
|
+
selects, is a matching date-time.
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
from mockserver import Body, DateTimeMatcher, HttpRequest
|
|
235
|
+
|
|
236
|
+
HttpRequest(path="/orders", body=Body.json_path("$.order.created", DateTimeMatcher.after("now -1 days")))
|
|
237
|
+
HttpRequest(path="/soap", body=Body.xpath("/order/@expires", None, DateTimeMatcher.after("now")))
|
|
238
|
+
```
|
|
239
|
+
|
|
211
240
|
### JWT matcher
|
|
212
241
|
|
|
213
242
|
Match requests by the claims (and metadata) of a JWT carried in a header. Each
|
|
@@ -239,6 +268,32 @@ client.when(
|
|
|
239
268
|
HttpRequest.request("/secure").with_jwt(Jwt(claims={"sub": "user-123"}))
|
|
240
269
|
```
|
|
241
270
|
|
|
271
|
+
### Client IP matcher
|
|
272
|
+
|
|
273
|
+
Match requests by the client IP address or CIDR range, IPv4 or IPv6. The client address is the TCP peer of the connection, not `X-Forwarded-For`; prefix with `!` to match every other address.
|
|
274
|
+
|
|
275
|
+
```python
|
|
276
|
+
HttpRequest.request("/internal").with_client_ip("10.0.0.0/8")
|
|
277
|
+
# or HttpRequest(path="/internal", client_ip="!10.0.0.0/8")
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### Any-of (logical OR) matcher
|
|
281
|
+
|
|
282
|
+
`AnyOfRequestDefinition` matches a request when any one of its request matchers does, tried in order. Entries may be `HttpRequest`, `OpenAPIDefinition`, `ConditionalRequestDefinition` or a nested `AnyOfRequestDefinition`; `not_any_of=True` matches requests that match none of them. It serialises to `{"anyOf": [...]}` inside `httpRequest`.
|
|
283
|
+
|
|
284
|
+
```python
|
|
285
|
+
from mockserver import AnyOfRequestDefinition, HttpRequest, HttpResponse
|
|
286
|
+
|
|
287
|
+
client.when(
|
|
288
|
+
AnyOfRequestDefinition.request_any_of(
|
|
289
|
+
HttpRequest(method="GET", path="/pets"),
|
|
290
|
+
HttpRequest(method="GET", path="/animals"),
|
|
291
|
+
)
|
|
292
|
+
).respond(HttpResponse(body="pets or animals"))
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
`ExplainedExpectation.any_of` (an `AnyOfBranchComparison`) says which entry came closest when none matches.
|
|
296
|
+
|
|
242
297
|
### allOf body matcher
|
|
243
298
|
|
|
244
299
|
Require a request body to satisfy **all** of several body matchers at once.
|
|
@@ -299,6 +354,40 @@ expectations = client.retrieve_active_expectations()
|
|
|
299
354
|
logs = client.retrieve_log_messages()
|
|
300
355
|
```
|
|
301
356
|
|
|
357
|
+
### Expectation metadata, unmatched expectations and paging
|
|
358
|
+
|
|
359
|
+
Label expectations with metadata (string keys, any JSON value). Metadata never
|
|
360
|
+
affects matching; it is returned on retrieve and lets you retrieve or clear
|
|
361
|
+
just your own expectations.
|
|
362
|
+
|
|
363
|
+
```python
|
|
364
|
+
from mockserver import ExpectationFilter, JSON_NULL, RecordedExpectationsOptions
|
|
365
|
+
|
|
366
|
+
client.when(HttpRequest.request("/api/users")).with_metadata("owner", "team-a").with_metadata(
|
|
367
|
+
"ticket", 4211
|
|
368
|
+
).respond(HttpResponse(status_code=200))
|
|
369
|
+
|
|
370
|
+
mine = client.retrieve_active_expectations_by_metadata("owner", "team-a")
|
|
371
|
+
labelled = client.retrieve_active_expectations_by_metadata_key("owner")
|
|
372
|
+
never_matched = client.retrieve_unmatched_expectations()
|
|
373
|
+
page = client.retrieve_active_expectations(
|
|
374
|
+
expectation_filter=ExpectationFilter().with_metadata_json_path("$.tags[?(@ == 'smoke')]").with_offset(0).with_limit(20)
|
|
375
|
+
)
|
|
376
|
+
recorded_page = client.retrieve_recorded_expectations(offset=0, limit=50)
|
|
377
|
+
# replay changing responses in order, keep only some request headers, move large bodies into files
|
|
378
|
+
shaped = client.retrieve_recorded_expectations(
|
|
379
|
+
options=RecordedExpectationsOptions().with_repeats_as_scenarios().with_capture_headers("X-Tenant").with_extract_bodies_larger_than(1024)
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
client.clear_by_metadata("owner", "team-a") # str compared as text, other values as JSON
|
|
383
|
+
client.clear_by_metadata_key("ticket") # any value
|
|
384
|
+
client.clear_unmatched_expectations()
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
These calls raise `ValueError` before sending anything for a blank key or JSONPath,
|
|
388
|
+
a `None` value (use `..._by_metadata_key` for any value, or `JSON_NULL` for a JSON
|
|
389
|
+
null), a negative offset or limit, or a clear whose filter selects nothing or pages.
|
|
390
|
+
|
|
302
391
|
## Control
|
|
303
392
|
|
|
304
393
|
```python
|
|
@@ -444,6 +533,12 @@ client = MockServerClient("localhost", 1080, control_plane_bearer_token=jwt)
|
|
|
444
533
|
client = MockServerClient("localhost", 1080, control_plane_bearer_token_supplier=fetch_jwt)
|
|
445
534
|
```
|
|
446
535
|
|
|
536
|
+
When MockServer accepts control-plane Basic authentication, pass `control_plane_basic_auth=(username, password)` with `secure=True` (MockServer refuses Basic credentials without TLS). They are sent on the same requests and WebSocket; a bearer token, when also set, is sent instead.
|
|
537
|
+
|
|
538
|
+
```python
|
|
539
|
+
client = MockServerClient("localhost", 1080, secure=True, control_plane_basic_auth=("admin", os.environ["MOCKSERVER_PASSWORD"]))
|
|
540
|
+
```
|
|
541
|
+
|
|
447
542
|
When MockServer requires mutual TLS for the control plane, pass `client_cert_path` and `client_key_path` (with `secure=True` and, for a private CA, `ca_cert_path`). The client presents that certificate, and trusts that CA, on every control-plane request and on the callback WebSocket.
|
|
448
543
|
|
|
449
544
|
```python
|
|
@@ -554,7 +649,7 @@ launcher_path = ensure_binary() # returns Path to the launcher executable
|
|
|
554
649
|
```python
|
|
555
650
|
from mockserver.launcher import start
|
|
556
651
|
|
|
557
|
-
server = start(port=1080, version="9.
|
|
652
|
+
server = start(port=1080, version="9.2.0")
|
|
558
653
|
# ...
|
|
559
654
|
server.stop()
|
|
560
655
|
```
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from mockserver.models import (
|
|
2
2
|
AfterAction,
|
|
3
3
|
AllOfBody,
|
|
4
|
+
AnyOfBranchComparison,
|
|
5
|
+
AnyOfRequestDefinition,
|
|
4
6
|
BinaryResponse,
|
|
5
7
|
BinaryUpstream,
|
|
6
8
|
Body,
|
|
@@ -8,6 +10,7 @@ from mockserver.models import (
|
|
|
8
10
|
CaptureSource,
|
|
9
11
|
ConditionalRequestDefinition,
|
|
10
12
|
ConnectionOptions,
|
|
13
|
+
DateTimeMatcher,
|
|
11
14
|
CrossProtocolScenario,
|
|
12
15
|
CrossProtocolTrigger,
|
|
13
16
|
Delay,
|
|
@@ -18,7 +21,10 @@ from mockserver.models import (
|
|
|
18
21
|
DnsRequestDefinition,
|
|
19
22
|
DnsResponse,
|
|
20
23
|
Expectation,
|
|
24
|
+
ExpectationFilter,
|
|
25
|
+
RecordedExpectationsOptions,
|
|
21
26
|
ExpectationId,
|
|
27
|
+
JSON_NULL,
|
|
22
28
|
ExpectationStep,
|
|
23
29
|
GraphQLBody,
|
|
24
30
|
GraphQLSubscriptionFilter,
|
|
@@ -165,6 +171,8 @@ from mockserver.launcher import (
|
|
|
165
171
|
__all__ = [
|
|
166
172
|
"AfterAction",
|
|
167
173
|
"AllOfBody",
|
|
174
|
+
"AnyOfBranchComparison",
|
|
175
|
+
"AnyOfRequestDefinition",
|
|
168
176
|
"AsyncMockServerClient",
|
|
169
177
|
"BinaryResponse",
|
|
170
178
|
"BinaryUpstream",
|
|
@@ -173,6 +181,7 @@ __all__ = [
|
|
|
173
181
|
"CaptureSource",
|
|
174
182
|
"ConditionalRequestDefinition",
|
|
175
183
|
"ConnectionOptions",
|
|
184
|
+
"DateTimeMatcher",
|
|
176
185
|
"CrossProtocolScenario",
|
|
177
186
|
"CrossProtocolTrigger",
|
|
178
187
|
"Delay",
|
|
@@ -183,7 +192,10 @@ __all__ = [
|
|
|
183
192
|
"DnsRequestDefinition",
|
|
184
193
|
"DnsResponse",
|
|
185
194
|
"Expectation",
|
|
195
|
+
"ExpectationFilter",
|
|
196
|
+
"RecordedExpectationsOptions",
|
|
186
197
|
"ExpectationId",
|
|
198
|
+
"JSON_NULL",
|
|
187
199
|
"ExpectationStep",
|
|
188
200
|
"GraphQLBody",
|
|
189
201
|
"GraphQLSubscriptionFilter",
|
|
@@ -18,10 +18,12 @@ from mockserver.exceptions import (
|
|
|
18
18
|
)
|
|
19
19
|
from mockserver.fluent import ForwardChainExpectation
|
|
20
20
|
from mockserver.models import (
|
|
21
|
+
AnyOfRequestDefinition,
|
|
21
22
|
ConditionalRequestDefinition,
|
|
22
23
|
DebugMismatchResult,
|
|
23
24
|
DnsRequestDefinition,
|
|
24
25
|
Expectation,
|
|
26
|
+
ExpectationFilter,
|
|
25
27
|
ExplainUnmatchedResult,
|
|
26
28
|
HttpChaosProfile,
|
|
27
29
|
HttpObjectCallback,
|
|
@@ -31,6 +33,7 @@ from mockserver.models import (
|
|
|
31
33
|
LoadScenario,
|
|
32
34
|
OpenAPIExpectation,
|
|
33
35
|
Ports,
|
|
36
|
+
RecordedExpectationsOptions,
|
|
34
37
|
RequestExplanation,
|
|
35
38
|
TimeToLive,
|
|
36
39
|
Times,
|
|
@@ -60,6 +63,10 @@ def _log_import_warnings(format: str | None, warnings: object) -> None:
|
|
|
60
63
|
)
|
|
61
64
|
|
|
62
65
|
|
|
66
|
+
# Default for clear()'s expectation_filter: only an omitted argument means "no filter".
|
|
67
|
+
NO_FILTER: Any = object()
|
|
68
|
+
|
|
69
|
+
|
|
63
70
|
class AsyncScenarioHandle:
|
|
64
71
|
"""An async handle to a single named stateful scenario on the server.
|
|
65
72
|
|
|
@@ -126,6 +133,7 @@ class AsyncMockServerClient:
|
|
|
126
133
|
client_key_path: str | None = None,
|
|
127
134
|
control_plane_bearer_token: str | None = None,
|
|
128
135
|
control_plane_bearer_token_supplier: Callable[[], str] | None = None,
|
|
136
|
+
control_plane_basic_auth: tuple[str, str] | None = None,
|
|
129
137
|
) -> None:
|
|
130
138
|
self._host = host
|
|
131
139
|
self._port = port
|
|
@@ -137,6 +145,22 @@ class AsyncMockServerClient:
|
|
|
137
145
|
self._client_key_path = client_key_path
|
|
138
146
|
self._control_plane_bearer_token = control_plane_bearer_token
|
|
139
147
|
self._control_plane_bearer_token_supplier = control_plane_bearer_token_supplier
|
|
148
|
+
self._control_plane_basic_authorization: str | None = None
|
|
149
|
+
if control_plane_basic_auth is not None:
|
|
150
|
+
username, password = control_plane_basic_auth
|
|
151
|
+
if not username or ":" in username or password is None:
|
|
152
|
+
raise ValueError(
|
|
153
|
+
"control_plane_basic_auth needs a (username, password) with a username without ':'"
|
|
154
|
+
)
|
|
155
|
+
self._control_plane_basic_authorization = "Basic " + base64.b64encode(
|
|
156
|
+
f"{username}:{password}".encode("utf-8")
|
|
157
|
+
).decode("ascii")
|
|
158
|
+
if not secure:
|
|
159
|
+
logger.warning(
|
|
160
|
+
"control-plane Basic credentials are configured without secure=True, so the password can be "
|
|
161
|
+
"read on the network, and MockServer refuses them unless "
|
|
162
|
+
"controlPlaneBasicAuthenticationAllowInsecure is set (tests only)"
|
|
163
|
+
)
|
|
140
164
|
self._websocket_clients: list[MockServerWebSocketClient] = []
|
|
141
165
|
|
|
142
166
|
scheme = "https" if secure else "http"
|
|
@@ -169,6 +193,16 @@ class AsyncMockServerClient:
|
|
|
169
193
|
return self._control_plane_bearer_token_supplier()
|
|
170
194
|
return self._control_plane_bearer_token
|
|
171
195
|
|
|
196
|
+
def _control_plane_authorization(self) -> str | None:
|
|
197
|
+
"""The control-plane ``Authorization`` header value: the bearer token
|
|
198
|
+
when one is configured, else the Basic credentials, else ``None``.
|
|
199
|
+
MockServer refuses Basic credentials without TLS, so use ``secure=True``.
|
|
200
|
+
"""
|
|
201
|
+
bearer = self._control_plane_bearer()
|
|
202
|
+
if bearer:
|
|
203
|
+
return f"Bearer {bearer}"
|
|
204
|
+
return self._control_plane_basic_authorization
|
|
205
|
+
|
|
172
206
|
async def _request(
|
|
173
207
|
self,
|
|
174
208
|
method: str,
|
|
@@ -189,9 +223,9 @@ class AsyncMockServerClient:
|
|
|
189
223
|
data = None
|
|
190
224
|
req = urllib.request.Request(url, data=data, method=method)
|
|
191
225
|
req.add_header("Content-Type", content_type)
|
|
192
|
-
|
|
193
|
-
if
|
|
194
|
-
req.add_header("Authorization",
|
|
226
|
+
authorization = self._control_plane_authorization()
|
|
227
|
+
if authorization:
|
|
228
|
+
req.add_header("Authorization", authorization)
|
|
195
229
|
|
|
196
230
|
def _do_request() -> tuple[int, str]:
|
|
197
231
|
try:
|
|
@@ -242,7 +276,21 @@ class AsyncMockServerClient:
|
|
|
242
276
|
self,
|
|
243
277
|
request: HttpRequest | None = None,
|
|
244
278
|
clear_type: str | None = None,
|
|
279
|
+
expectation_filter: ExpectationFilter = NO_FILTER, # type: ignore[assignment]
|
|
245
280
|
) -> None:
|
|
281
|
+
"""Clear state matching ``request`` (everything when omitted).
|
|
282
|
+
|
|
283
|
+
With ``expectation_filter``, only the expectations that match both the
|
|
284
|
+
request and the filter's metadata or unmatched selection are cleared and
|
|
285
|
+
the event log is left untouched. A filter that selects nothing, that pages,
|
|
286
|
+
or a ``clear_type`` other than ``expectations`` raises ``ValueError``
|
|
287
|
+
before anything is sent. Passing ``expectation_filter=None`` explicitly
|
|
288
|
+
raises too: only omitting it means a clear without a filter.
|
|
289
|
+
"""
|
|
290
|
+
if expectation_filter is not NO_FILTER:
|
|
291
|
+
# fail closed: an explicit None (e.g. from a helper that returned nothing) must not
|
|
292
|
+
# become a clear of every expectation and the whole log
|
|
293
|
+
return await self._clear_with_filter(request, clear_type, expectation_filter)
|
|
246
294
|
query_params = {}
|
|
247
295
|
if clear_type:
|
|
248
296
|
query_params["type"] = clear_type
|
|
@@ -255,6 +303,53 @@ class AsyncMockServerClient:
|
|
|
255
303
|
f"Failed to clear (status={status}): {response_body}"
|
|
256
304
|
)
|
|
257
305
|
|
|
306
|
+
async def _clear_with_filter(
|
|
307
|
+
self,
|
|
308
|
+
request: HttpRequest | None,
|
|
309
|
+
clear_type: str | None,
|
|
310
|
+
expectation_filter: ExpectationFilter,
|
|
311
|
+
) -> None:
|
|
312
|
+
# fail closed: a filter that selects nothing would otherwise clear every expectation
|
|
313
|
+
if not isinstance(expectation_filter, ExpectationFilter):
|
|
314
|
+
raise ValueError("expectation_filter must be an ExpectationFilter")
|
|
315
|
+
if not expectation_filter.has_selection():
|
|
316
|
+
raise ValueError(
|
|
317
|
+
"clear with a filter requires a metadata or unmatched selection; "
|
|
318
|
+
"use clear(request) to clear without one"
|
|
319
|
+
)
|
|
320
|
+
if expectation_filter.has_paging():
|
|
321
|
+
raise ValueError("offset and limit are not supported for clear")
|
|
322
|
+
if clear_type is not None and clear_type.lower() != "expectations":
|
|
323
|
+
raise ValueError(
|
|
324
|
+
f"clear with a filter only clears expectations, not clear_type={clear_type!r}"
|
|
325
|
+
)
|
|
326
|
+
query_params = {"type": "expectations"}
|
|
327
|
+
query_params.update(expectation_filter.to_query_params())
|
|
328
|
+
body = json.dumps(request.to_dict()) if request else ""
|
|
329
|
+
status, response_body = await self._request(
|
|
330
|
+
"PUT", "/mockserver/clear", body, query_params
|
|
331
|
+
)
|
|
332
|
+
if status >= 400:
|
|
333
|
+
raise MockServerError(
|
|
334
|
+
f"Failed to clear (status={status}): {response_body}"
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
async def clear_by_metadata(self, key: str, value: Any) -> None:
|
|
338
|
+
"""Clear the expectations whose metadata has ``key`` with a value equal to ``value``.
|
|
339
|
+
|
|
340
|
+
``value`` must not be ``None``: use :meth:`clear_by_metadata_key` for any
|
|
341
|
+
value, or ``JSON_NULL`` for a JSON null.
|
|
342
|
+
"""
|
|
343
|
+
await self.clear(expectation_filter=ExpectationFilter().with_metadata(key, value))
|
|
344
|
+
|
|
345
|
+
async def clear_by_metadata_key(self, key: str) -> None:
|
|
346
|
+
"""Clear the expectations whose metadata has ``key``, whatever its value."""
|
|
347
|
+
await self.clear(expectation_filter=ExpectationFilter().with_metadata_key(key))
|
|
348
|
+
|
|
349
|
+
async def clear_unmatched_expectations(self) -> None:
|
|
350
|
+
"""Clear the expectations that have not matched any request."""
|
|
351
|
+
await self.clear(expectation_filter=ExpectationFilter().with_unmatched(True))
|
|
352
|
+
|
|
258
353
|
async def clear_by_id(
|
|
259
354
|
self,
|
|
260
355
|
expectation_id: str,
|
|
@@ -393,7 +488,9 @@ class AsyncMockServerClient:
|
|
|
393
488
|
Issues ``PUT /mockserver/configuration`` with *config_json* (a
|
|
394
489
|
``ConfigurationDTO`` JSON document; only the present fields are applied)
|
|
395
490
|
and returns the serialized updated configuration JSON. A ``None`` body is
|
|
396
|
-
sent as an empty string.
|
|
491
|
+
sent as an empty string. Properties fixed at startup, such as
|
|
492
|
+
``forwardProxyBlockPrivateNetworks``, ``forwardProxyAllowedHosts`` and
|
|
493
|
+
``forwardProxyDeniedHosts``, are ignored and the server logs a warning.
|
|
397
494
|
"""
|
|
398
495
|
body = config_json if config_json is not None else ""
|
|
399
496
|
status, response_body = await self._request(
|
|
@@ -1165,7 +1262,7 @@ class AsyncMockServerClient:
|
|
|
1165
1262
|
|
|
1166
1263
|
async def verify(
|
|
1167
1264
|
self,
|
|
1168
|
-
request: HttpRequest | ConditionalRequestDefinition | None = None,
|
|
1265
|
+
request: HttpRequest | ConditionalRequestDefinition | AnyOfRequestDefinition | None = None,
|
|
1169
1266
|
times: VerificationTimes | None = None,
|
|
1170
1267
|
*,
|
|
1171
1268
|
response: HttpResponse | None = None,
|
|
@@ -1182,7 +1279,7 @@ class AsyncMockServerClient:
|
|
|
1182
1279
|
|
|
1183
1280
|
async def verify_sequence(
|
|
1184
1281
|
self,
|
|
1185
|
-
*requests: HttpRequest | ConditionalRequestDefinition,
|
|
1282
|
+
*requests: HttpRequest | ConditionalRequestDefinition | AnyOfRequestDefinition,
|
|
1186
1283
|
responses: list[HttpResponse] | None = None,
|
|
1187
1284
|
) -> None:
|
|
1188
1285
|
verification = VerificationSequence(
|
|
@@ -1231,14 +1328,23 @@ class AsyncMockServerClient:
|
|
|
1231
1328
|
return []
|
|
1232
1329
|
|
|
1233
1330
|
async def retrieve_active_expectations(
|
|
1234
|
-
self,
|
|
1331
|
+
self,
|
|
1332
|
+
request: HttpRequest | None = None,
|
|
1333
|
+
expectation_filter: ExpectationFilter | None = None,
|
|
1235
1334
|
) -> list[Expectation]:
|
|
1335
|
+
"""Retrieve the active expectations matching ``request`` (all when omitted)
|
|
1336
|
+
and, when given, ``expectation_filter`` (metadata, unmatched, offset, limit)."""
|
|
1337
|
+
query_params = {"type": "ACTIVE_EXPECTATIONS", "format": "JSON"}
|
|
1338
|
+
if expectation_filter is not None:
|
|
1339
|
+
if not isinstance(expectation_filter, ExpectationFilter):
|
|
1340
|
+
raise ValueError("expectation_filter must be an ExpectationFilter")
|
|
1341
|
+
query_params.update(expectation_filter.to_query_params())
|
|
1236
1342
|
body = json.dumps(request.to_dict()) if request else ""
|
|
1237
1343
|
status, response_body = await self._request(
|
|
1238
1344
|
"PUT",
|
|
1239
1345
|
"/mockserver/retrieve",
|
|
1240
1346
|
body,
|
|
1241
|
-
|
|
1347
|
+
query_params,
|
|
1242
1348
|
)
|
|
1243
1349
|
if status >= 400:
|
|
1244
1350
|
raise MockServerError(
|
|
@@ -1250,15 +1356,56 @@ class AsyncMockServerClient:
|
|
|
1250
1356
|
return [Expectation.from_dict(e) for e in parsed]
|
|
1251
1357
|
return []
|
|
1252
1358
|
|
|
1359
|
+
async def retrieve_active_expectations_by_metadata(
|
|
1360
|
+
self, key: str, value: Any
|
|
1361
|
+
) -> list[Expectation]:
|
|
1362
|
+
"""Retrieve the active expectations whose metadata has ``key`` with a value
|
|
1363
|
+
equal to ``value``: a ``str`` is compared as text, any other value as JSON."""
|
|
1364
|
+
return await self.retrieve_active_expectations(
|
|
1365
|
+
None, ExpectationFilter().with_metadata(key, value)
|
|
1366
|
+
)
|
|
1367
|
+
|
|
1368
|
+
async def retrieve_active_expectations_by_metadata_key(
|
|
1369
|
+
self, key: str
|
|
1370
|
+
) -> list[Expectation]:
|
|
1371
|
+
"""Retrieve the active expectations whose metadata has ``key``, whatever its value."""
|
|
1372
|
+
return await self.retrieve_active_expectations(
|
|
1373
|
+
None, ExpectationFilter().with_metadata_key(key)
|
|
1374
|
+
)
|
|
1375
|
+
|
|
1376
|
+
async def retrieve_unmatched_expectations(self) -> list[Expectation]:
|
|
1377
|
+
"""Retrieve the active expectations that have not matched any request."""
|
|
1378
|
+
return await self.retrieve_active_expectations(
|
|
1379
|
+
None, ExpectationFilter().with_unmatched(True)
|
|
1380
|
+
)
|
|
1381
|
+
|
|
1253
1382
|
async def retrieve_recorded_expectations(
|
|
1254
|
-
self,
|
|
1383
|
+
self,
|
|
1384
|
+
request: HttpRequest | None = None,
|
|
1385
|
+
offset: int | None = None,
|
|
1386
|
+
limit: int | None = None,
|
|
1387
|
+
options: RecordedExpectationsOptions | None = None,
|
|
1255
1388
|
) -> list[Expectation]:
|
|
1389
|
+
"""Retrieve the recorded expectations matching ``request`` (all when omitted),
|
|
1390
|
+
optionally one page of them, in the order the requests were received.
|
|
1391
|
+
|
|
1392
|
+
``options`` shapes the returned expectations, for example replaying a request
|
|
1393
|
+
recorded with different responses in order (see :class:`RecordedExpectationsOptions`).
|
|
1394
|
+
"""
|
|
1395
|
+
if options is not None and not isinstance(options, RecordedExpectationsOptions):
|
|
1396
|
+
raise ValueError(f"options must be a RecordedExpectationsOptions, got {options!r}")
|
|
1397
|
+
query_params = {"type": "RECORDED_EXPECTATIONS", "format": "JSON"}
|
|
1398
|
+
if options is not None:
|
|
1399
|
+
query_params.update(options.to_query_params())
|
|
1400
|
+
query_params.update(
|
|
1401
|
+
ExpectationFilter().with_offset(offset).with_limit(limit).to_query_params()
|
|
1402
|
+
)
|
|
1256
1403
|
body = json.dumps(request.to_dict()) if request else ""
|
|
1257
1404
|
status, response_body = await self._request(
|
|
1258
1405
|
"PUT",
|
|
1259
1406
|
"/mockserver/retrieve",
|
|
1260
1407
|
body,
|
|
1261
|
-
|
|
1408
|
+
query_params,
|
|
1262
1409
|
)
|
|
1263
1410
|
if status >= 400:
|
|
1264
1411
|
raise MockServerError(
|
|
@@ -1558,7 +1705,7 @@ class AsyncMockServerClient:
|
|
|
1558
1705
|
|
|
1559
1706
|
def when(
|
|
1560
1707
|
self,
|
|
1561
|
-
request: HttpRequest | ConditionalRequestDefinition,
|
|
1708
|
+
request: HttpRequest | ConditionalRequestDefinition | AnyOfRequestDefinition,
|
|
1562
1709
|
times: Times | None = None,
|
|
1563
1710
|
time_to_live: TimeToLive | None = None,
|
|
1564
1711
|
priority: int | None = None,
|
|
@@ -1637,7 +1784,7 @@ class AsyncMockServerClient:
|
|
|
1637
1784
|
self._secure,
|
|
1638
1785
|
self._ca_cert_path,
|
|
1639
1786
|
tls_verify=self._tls_verify,
|
|
1640
|
-
|
|
1787
|
+
authorization_supplier=self._control_plane_authorization,
|
|
1641
1788
|
ssl_context=self._ssl_context,
|
|
1642
1789
|
)
|
|
1643
1790
|
ws_client._is_breakpoint_ws = True # type: ignore[attr-defined]
|
|
@@ -1799,7 +1946,7 @@ class AsyncMockServerClient:
|
|
|
1799
1946
|
self._secure,
|
|
1800
1947
|
self._ca_cert_path,
|
|
1801
1948
|
tls_verify=self._tls_verify,
|
|
1802
|
-
|
|
1949
|
+
authorization_supplier=self._control_plane_authorization,
|
|
1803
1950
|
ssl_context=self._ssl_context,
|
|
1804
1951
|
)
|
|
1805
1952
|
|