mockserver-client 9.0.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.
Files changed (51) hide show
  1. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/PKG-INFO +113 -2
  2. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/README.md +112 -1
  3. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/mockserver/__init__.py +12 -0
  4. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/mockserver/a2a.py +23 -2
  5. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/mockserver/async_client.py +211 -18
  6. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/mockserver/client.py +71 -14
  7. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/mockserver/fluent.py +29 -0
  8. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/mockserver/launcher.py +1 -1
  9. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/mockserver/mcp.py +24 -3
  10. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/mockserver/models.py +523 -24
  11. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/mockserver/websocket_client.py +30 -1
  12. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/mockserver_client.egg-info/PKG-INFO +113 -2
  13. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/mockserver_client.egg-info/SOURCES.txt +5 -0
  14. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/pyproject.toml +1 -1
  15. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/tests/test_a2a.py +6 -0
  16. mockserver_client-9.2.0/tests/test_any_of_request_definition.py +102 -0
  17. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/tests/test_async_client.py +6 -4
  18. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/tests/test_auth_tls.py +62 -1
  19. mockserver_client-9.2.0/tests/test_body_date_time_matcher.py +62 -0
  20. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/tests/test_client.py +3 -3
  21. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/tests/test_control_plane.py +71 -0
  22. mockserver_client-9.2.0/tests/test_date_time_matcher.py +166 -0
  23. mockserver_client-9.2.0/tests/test_expectation_filter.py +319 -0
  24. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/tests/test_explain.py +2 -1
  25. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/tests/test_init.py +2 -0
  26. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/tests/test_mcp.py +68 -1
  27. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/tests/test_models.py +76 -0
  28. mockserver_client-9.2.0/tests/test_recorded_expectations_options.py +176 -0
  29. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/tests/test_websocket_client.py +229 -0
  30. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/mockserver/exceptions.py +0 -0
  31. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/mockserver/llm.py +0 -0
  32. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/mockserver/py.typed +0 -0
  33. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/mockserver/pytest_plugin.py +0 -0
  34. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/mockserver_client.egg-info/dependency_links.txt +0 -0
  35. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/mockserver_client.egg-info/entry_points.txt +0 -0
  36. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/mockserver_client.egg-info/requires.txt +0 -0
  37. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/mockserver_client.egg-info/top_level.txt +0 -0
  38. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/setup.cfg +0 -0
  39. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/tests/test_breakpoint.py +0 -0
  40. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/tests/test_conditional_request_and_load_check.py +0 -0
  41. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/tests/test_exceptions.py +0 -0
  42. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/tests/test_fluent.py +0 -0
  43. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/tests/test_from_dict_fidelity.py +0 -0
  44. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/tests/test_integration.py +0 -0
  45. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/tests/test_key_multi_value_forms.py +0 -0
  46. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/tests/test_launcher.py +0 -0
  47. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/tests/test_llm.py +0 -0
  48. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/tests/test_override_forwarded_request.py +0 -0
  49. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/tests/test_pytest_plugin.py +0 -0
  50. {mockserver_client-9.0.0 → mockserver_client-9.2.0}/tests/test_roundtrip_fidelity.py +0 -0
  51. {mockserver_client-9.0.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.0.0
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
@@ -465,6 +554,28 @@ client = MockServerClient(
465
554
  )
466
555
  ```
467
556
 
557
+ ## Control-Plane Authentication
558
+
559
+ When MockServer requires a control-plane JWT, pass the token (or a supplier for a token that is refreshed). The client sends `Authorization: Bearer <token>` on every control-plane request and on the callback WebSocket used by callbacks and breakpoints; a supplier is called again on each reconnect.
560
+
561
+ ```python
562
+ client = MockServerClient("localhost", 1080, control_plane_bearer_token=jwt)
563
+ client = MockServerClient("localhost", 1080, control_plane_bearer_token_supplier=fetch_jwt)
564
+ ```
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
+
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.
573
+
574
+ ```python
575
+ client = MockServerClient("localhost", 1080, secure=True, ca_cert_path="ca.pem",
576
+ client_cert_path="client-cert.pem", client_key_path="client-key.pem")
577
+ ```
578
+
468
579
  ## Domain Model
469
580
 
470
581
  All domain model classes support builder-style chaining:
@@ -568,7 +679,7 @@ launcher_path = ensure_binary() # returns Path to the launcher executable
568
679
  ```python
569
680
  from mockserver.launcher import start
570
681
 
571
- server = start(port=1080, version="9.0.0")
682
+ server = start(port=1080, version="9.2.0")
572
683
  # ...
573
684
  server.stop()
574
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
@@ -435,6 +524,28 @@ client = MockServerClient(
435
524
  )
436
525
  ```
437
526
 
527
+ ## Control-Plane Authentication
528
+
529
+ When MockServer requires a control-plane JWT, pass the token (or a supplier for a token that is refreshed). The client sends `Authorization: Bearer <token>` on every control-plane request and on the callback WebSocket used by callbacks and breakpoints; a supplier is called again on each reconnect.
530
+
531
+ ```python
532
+ client = MockServerClient("localhost", 1080, control_plane_bearer_token=jwt)
533
+ client = MockServerClient("localhost", 1080, control_plane_bearer_token_supplier=fetch_jwt)
534
+ ```
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
+
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.
543
+
544
+ ```python
545
+ client = MockServerClient("localhost", 1080, secure=True, ca_cert_path="ca.pem",
546
+ client_cert_path="client-cert.pem", client_key_path="client-key.pem")
547
+ ```
548
+
438
549
  ## Domain Model
439
550
 
440
551
  All domain model classes support builder-style chaining:
@@ -538,7 +649,7 @@ launcher_path = ensure_binary() # returns Path to the launcher executable
538
649
  ```python
539
650
  from mockserver.launcher import start
540
651
 
541
- server = start(port=1080, version="9.0.0")
652
+ server = start(port=1080, version="9.2.0")
542
653
  # ...
543
654
  server.stop()
544
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",
@@ -44,6 +44,7 @@ Example::
44
44
  from __future__ import annotations
45
45
 
46
46
  import json
47
+ import re
47
48
  from dataclasses import dataclass, field
48
49
  from urllib.parse import urlparse
49
50
 
@@ -70,11 +71,31 @@ def _escape_json(value: str | None) -> str:
70
71
  return quoted[1:-1]
71
72
 
72
73
 
74
+ # group 2 consumes the metacharacter after a backslash run, is empty at the end
75
+ # and unset (empty alternative) otherwise, so no lookaround is needed
76
+ _VELOCITY_METACHARACTERS = re.compile(r"(\\+)(?:([$#]|\Z)|)|[$#]")
77
+ _VELOCITY_METACHARACTER_ESCAPES = {"$": "${esc.d}", "#": "${esc.h}", "": ""}
78
+
79
+
80
+ def _velocity_escape_match(match: re.Match) -> str:
81
+ run, following = match.group(1), match.group(2)
82
+ if run is None:
83
+ return _VELOCITY_METACHARACTER_ESCAPES[match.group(0)]
84
+ if following is None:
85
+ return run
86
+ return "${esc.b}" * len(run) + _VELOCITY_METACHARACTER_ESCAPES[following]
87
+
88
+
73
89
  def _escape_velocity(value: str | None) -> str | None:
74
- """Escape Velocity metacharacters so literal ``$`` / ``#`` survive rendering."""
90
+ """Escape Velocity metacharacters so literal ``$`` / ``#`` survive rendering.
91
+
92
+ Velocity consumes backslashes written before a reference or directive, so a
93
+ run of backslashes followed by ``$``, ``#`` or the end of the value is
94
+ escaped too; other backslashes are literal to Velocity and left as-is.
95
+ """
75
96
  if value is None:
76
97
  return None
77
- return value.replace("$", "${esc.d}").replace("#", "${esc.h}")
98
+ return _VELOCITY_METACHARACTERS.sub(_velocity_escape_match, value)
78
99
 
79
100
 
80
101
  def _escape_message_pattern(pattern: str) -> str: