mockserver-client 9.1.0__tar.gz → 9.3.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.
Files changed (51) hide show
  1. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/PKG-INFO +97 -2
  2. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/README.md +96 -1
  3. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/mockserver/__init__.py +12 -0
  4. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/mockserver/async_client.py +160 -13
  5. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/mockserver/client.py +63 -10
  6. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/mockserver/fluent.py +29 -0
  7. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/mockserver/launcher.py +1 -1
  8. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/mockserver/models.py +537 -24
  9. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/mockserver/websocket_client.py +11 -1
  10. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/mockserver_client.egg-info/PKG-INFO +97 -2
  11. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/mockserver_client.egg-info/SOURCES.txt +5 -0
  12. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/pyproject.toml +1 -1
  13. mockserver_client-9.3.0/tests/test_any_of_request_definition.py +102 -0
  14. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/tests/test_async_client.py +1 -1
  15. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/tests/test_auth_tls.py +62 -1
  16. mockserver_client-9.3.0/tests/test_body_date_time_matcher.py +62 -0
  17. mockserver_client-9.3.0/tests/test_date_time_matcher.py +166 -0
  18. mockserver_client-9.3.0/tests/test_expectation_filter.py +319 -0
  19. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/tests/test_explain.py +2 -1
  20. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/tests/test_init.py +2 -0
  21. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/tests/test_models.py +92 -0
  22. mockserver_client-9.3.0/tests/test_recorded_expectations_options.py +176 -0
  23. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/tests/test_websocket_client.py +14 -0
  24. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/mockserver/a2a.py +0 -0
  25. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/mockserver/exceptions.py +0 -0
  26. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/mockserver/llm.py +0 -0
  27. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/mockserver/mcp.py +0 -0
  28. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/mockserver/py.typed +0 -0
  29. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/mockserver/pytest_plugin.py +0 -0
  30. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/mockserver_client.egg-info/dependency_links.txt +0 -0
  31. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/mockserver_client.egg-info/entry_points.txt +0 -0
  32. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/mockserver_client.egg-info/requires.txt +0 -0
  33. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/mockserver_client.egg-info/top_level.txt +0 -0
  34. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/setup.cfg +0 -0
  35. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/tests/test_a2a.py +0 -0
  36. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/tests/test_breakpoint.py +0 -0
  37. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/tests/test_client.py +0 -0
  38. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/tests/test_conditional_request_and_load_check.py +0 -0
  39. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/tests/test_control_plane.py +0 -0
  40. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/tests/test_exceptions.py +0 -0
  41. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/tests/test_fluent.py +0 -0
  42. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/tests/test_from_dict_fidelity.py +0 -0
  43. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/tests/test_integration.py +0 -0
  44. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/tests/test_key_multi_value_forms.py +0 -0
  45. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/tests/test_launcher.py +0 -0
  46. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/tests/test_llm.py +0 -0
  47. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/tests/test_mcp.py +0 -0
  48. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/tests/test_override_forwarded_request.py +0 -0
  49. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/tests/test_pytest_plugin.py +0 -0
  50. {mockserver_client-9.1.0 → mockserver_client-9.3.0}/tests/test_roundtrip_fidelity.py +0 -0
  51. {mockserver_client-9.1.0 → mockserver_client-9.3.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.1.0
3
+ Version: 9.3.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.1.0")
682
+ server = start(port=1080, version="9.3.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.1.0")
652
+ server = start(port=1080, version="9.3.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
- bearer = self._control_plane_bearer()
193
- if bearer:
194
- req.add_header("Authorization", f"Bearer {bearer}")
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, request: HttpRequest | None = None
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
- {"type": "ACTIVE_EXPECTATIONS", "format": "JSON"},
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, request: HttpRequest | None = None
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
- {"type": "RECORDED_EXPECTATIONS", "format": "JSON"},
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
- bearer_token_supplier=self._control_plane_bearer,
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
- bearer_token_supplier=self._control_plane_bearer,
1949
+ authorization_supplier=self._control_plane_authorization,
1803
1950
  ssl_context=self._ssl_context,
1804
1951
  )
1805
1952