assertpy 2.35.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. assertpy/__init__.py +87 -0
  2. assertpy/_clustering.py +511 -0
  3. assertpy/_dangling.py +505 -0
  4. assertpy/_engine/__init__.py +1 -0
  5. assertpy/_engine/_builder_check_typing.py +1365 -0
  6. assertpy/_engine/_capable_typing.py +772 -0
  7. assertpy/_engine/_check_typing.py +753 -0
  8. assertpy/_engine/_compare.py +983 -0
  9. assertpy/_engine/_compat.py +38 -0
  10. assertpy/_engine/_contract.py +1926 -0
  11. assertpy/_engine/_diff.py +1058 -0
  12. assertpy/_engine/_equality.py +843 -0
  13. assertpy/_engine/_introspection.py +729 -0
  14. assertpy/_engine/_membership.py +522 -0
  15. assertpy/_engine/_mixin_base.py +258 -0
  16. assertpy/_engine/_negated_typing.py +2176 -0
  17. assertpy/_engine/_operations.py +75 -0
  18. assertpy/_engine/_ordering.py +884 -0
  19. assertpy/_engine/_pairing.py +70 -0
  20. assertpy/_engine/_path.py +158 -0
  21. assertpy/_engine/_poll_typing.py +6039 -0
  22. assertpy/_engine/_require.py +312 -0
  23. assertpy/_engine/_size.py +31 -0
  24. assertpy/_engine/_text.py +39 -0
  25. assertpy/_engine/_typing.py +1226 -0
  26. assertpy/_hints.py +1213 -0
  27. assertpy/_inline.py +484 -0
  28. assertpy/_matcher_impls.py +1876 -0
  29. assertpy/_passes.py +249 -0
  30. assertpy/_renamed.py +64 -0
  31. assertpy/_satisfies.py +835 -0
  32. assertpy/_snapshot_codec.py +290 -0
  33. assertpy/assertpy.py +2640 -0
  34. assertpy/async_assertions.py +864 -0
  35. assertpy/base.py +862 -0
  36. assertpy/behave_matchers.py +96 -0
  37. assertpy/bytes_mixin.py +158 -0
  38. assertpy/collection.py +528 -0
  39. assertpy/contains.py +1068 -0
  40. assertpy/dataframe.py +200 -0
  41. assertpy/date.py +327 -0
  42. assertpy/dict.py +249 -0
  43. assertpy/dynamic.py +222 -0
  44. assertpy/errors.py +782 -0
  45. assertpy/exception.py +838 -0
  46. assertpy/extracting.py +266 -0
  47. assertpy/file.py +324 -0
  48. assertpy/helpers.py +747 -0
  49. assertpy/http_mixin.py +218 -0
  50. assertpy/json_mixin.py +1265 -0
  51. assertpy/matchers.py +639 -0
  52. assertpy/numeric.py +696 -0
  53. assertpy/outcome.py +163 -0
  54. assertpy/py.typed +0 -0
  55. assertpy/pytest_plugin.py +1650 -0
  56. assertpy/snapshot.py +1031 -0
  57. assertpy/string.py +803 -0
  58. assertpy/warning.py +137 -0
  59. assertpy-2.35.0.dist-info/METADATA +324 -0
  60. assertpy-2.35.0.dist-info/RECORD +63 -0
  61. assertpy-2.35.0.dist-info/WHEEL +4 -0
  62. assertpy-2.35.0.dist-info/entry_points.txt +2 -0
  63. assertpy-2.35.0.dist-info/licenses/LICENSE +28 -0
assertpy/json_mixin.py ADDED
@@ -0,0 +1,1265 @@
1
+ from __future__ import annotations
2
+
3
+ import decimal
4
+ import fractions
5
+ import functools
6
+ import json
7
+ import operator
8
+ import re
9
+ from pathlib import Path, PurePath
10
+ from typing import TYPE_CHECKING, Any, Final, cast
11
+
12
+ from ._engine._mixin_base import _MixinBase
13
+ from ._engine._ordering import _exact_real
14
+ from ._engine._require import argument, require_type
15
+ from .errors import DiffEntry, DiffResult
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Iterator
19
+
20
+ from ._engine._compat import Self
21
+
22
+ __tracebackhide__ = True
23
+
24
+
25
+ _OPENAPI_VERSION: Final = re.compile(r"3\.([012])(?:\.\d+)?")
26
+ """The versions whose dialect this reads, matched whole: a patch number is allowed and nothing else is."""
27
+
28
+ _SWAGGER_2: Final = re.compile(r"2\.0(?:\.\d+)?")
29
+ """Swagger's one version, read the same way, so "20" is not it."""
30
+
31
+ _RFC_3339_TIME: Final = r"([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.[0-9]+)?(?:[Zz]|([+-])([0-9]{2}):([0-9]{2}))"
32
+ """A `time` as RFC 3339 writes its ``full-time``: a time to the second, then `Z` or an offset."""
33
+
34
+ _RFC_3339_MOMENT: Final = re.compile(r"([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]" + _RFC_3339_TIME)
35
+ """A `date-time` as RFC 3339 writes it: a full date, a `T`, then `_RFC_3339_TIME`."""
36
+
37
+ # The four below are texts that `re` compiles at first use: compiled at import they cost 0.5 ms.
38
+ _DURATION: Final = (
39
+ r"(?ai:P(?:"
40
+ r"(?:[0-9]+D|[0-9]+M(?:[0-9]+D)?|[0-9]+Y(?:[0-9]+M(?:[0-9]+D)?)?)"
41
+ r"(?:T(?:[0-9]+H(?:[0-9]+M(?:[0-9]+S)?)?|[0-9]+M(?:[0-9]+S)?|[0-9]+S))?"
42
+ r"|T(?:[0-9]+H(?:[0-9]+M(?:[0-9]+S)?)?|[0-9]+M(?:[0-9]+S)?|[0-9]+S)"
43
+ r"|[0-9]+W))"
44
+ )
45
+ """A `duration` by the ABNF of RFC 3339, appendix A, which the format is defined by.
46
+
47
+ Narrower than ISO 8601 where the ABNF is: a unit may be followed only by the next smaller one, so ``P1Y2D`` and
48
+ ``PT1H2S`` are no durations, weeks stand alone, and there is no fraction and no sign. A letter is taken in
49
+ either case: a quoted letter of an ABNF stands for both (RFC 5234), which is the ground the ``t`` and ``z`` of a
50
+ `date-time` are taken on. In either case of ASCII alone: the long ``s`` of Unicode folds to ``S`` and is none.
51
+ """
52
+
53
+ _HOSTNAME: Final = (
54
+ r"(?![\s\S]{254})"
55
+ r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*"
56
+ )
57
+ """A `hostname` by RFC 1123, section 2.1: labels of ASCII letters, digits and hyphens, a hyphen at neither end,
58
+ 63 characters at most, joined by dots with none after the last, and 253 characters in all. What an ``xn--``
59
+ label may decode to is asked apart, where the dialect asks it (`_is_hostname`)."""
60
+
61
+
62
+ def _mailbox_pattern() -> str:
63
+ """An `email` by the ``Mailbox`` of RFC 5321, section 4.1.2: a local part, an ``@``, then a domain or an
64
+ address in brackets.
65
+
66
+ The local part is atoms joined by dots or a quoted string, and the domain is `_HOSTNAME`. ASCII alone: an
67
+ address with other letters is for the format `idn-email`. The grammar has no lengths, and the 64 that SMTP
68
+ allows a local part is not held: the domain has the lengths of a host name, as under the format `hostname`.
69
+
70
+ The atoms are one run of their characters and their dots, and a quoted string one run of what may stand in
71
+ it, with what a backslash quotes there judged apart (`_is_email`).
72
+
73
+ The address is an IPv4 one, or ``IPv6:`` and an IPv6 one as section 4.1.3 writes it, which is narrower than
74
+ RFC 4291: ``::`` stands for two groups at least, so six stand beside it at most, and four before an IPv4
75
+ address. No other tag is registered, so none is taken.
76
+ """
77
+ atoms = r"[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~.]+"
78
+ octet = "(?:25[0-5]|2[0-4][0-9]|[01]?[0-9]?[0-9])"
79
+ ipv4 = rf"{octet}(?:\.{octet}){{3}}"
80
+ group = "[0-9A-Fa-f]{1,4}"
81
+
82
+ def groups(count: int) -> str:
83
+ return f"{group}(?::{group}){{{count - 1}}}" if count else ""
84
+
85
+ ipv6 = "|".join(
86
+ [
87
+ groups(8),
88
+ f"{groups(6)}:{ipv4}",
89
+ *(f"{groups(left)}::(?:{group}(?::{group}){{0,{5 - left}}})?" for left in range(6)),
90
+ f"{groups(6)}::",
91
+ *(f"{groups(left)}::(?:{group}:){{0,{4 - left}}}{ipv4}" for left in range(5)),
92
+ ]
93
+ )
94
+ return (
95
+ # no group repeats: as one, a megabyte of atoms held 63 MB of the engine's stack, and one in quotes 122
96
+ rf'(?:(?!\.)(?![^@]*\.[.@]){atoms}|"(?P<quoted>[ -~]*)")@'
97
+ rf"(?:{_HOSTNAME}|\[(?:{ipv4}|(?ai:IPv6):(?:{ipv6}))\])"
98
+ )
99
+
100
+
101
+ _MAILBOX: Final = _mailbox_pattern()
102
+
103
+
104
+ def _uri_pattern() -> str:
105
+ """A `uri` by the ABNF of RFC 3986, appendix A: a scheme, then an authority and a path, a path alone or
106
+ nothing, then a query and a fragment. What stands in brackets as the host is judged apart (`_is_uri`).
107
+
108
+ A path is written as one run of its characters and its slashes, which is what the segments of the ABNF
109
+ come to, and the two hex digits after a ``%`` are asked for once, ahead of the rest.
110
+ """
111
+ # no group repeats: with a segment or a `%XX` as one, a megabyte of either held 60 to 280 MB of the engine's stack
112
+ plain = r"A-Za-z0-9\-._~!$&'()*+,;=%"
113
+ path = f"[{plain}:@/]*"
114
+ return (
115
+ r"(?![\s\S]*%(?![0-9A-Fa-f]{2}))"
116
+ r"[A-Za-z][A-Za-z0-9+.\-]*:"
117
+ rf"(?://(?:[{plain}:]*@)?(?:\[(?P<literal>[^\]]*)\]|[{plain}]*)(?::[0-9]*)?(?:/{path})?"
118
+ f"|/(?:[{plain}:@]{path})?"
119
+ f"|[{plain}:@]{path}"
120
+ "|)"
121
+ rf"(?:\?[{plain}:@/?]*)?(?:#[{plain}:@/?]*)?"
122
+ )
123
+
124
+
125
+ _URI: Final = _uri_pattern()
126
+
127
+
128
+ _DAYS_IN: Final = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
129
+
130
+ _UNHEARD: Final = frozenset(
131
+ {
132
+ "const",
133
+ "contains",
134
+ "propertyNames",
135
+ "if",
136
+ "dependentRequired",
137
+ "dependentSchemas",
138
+ "unevaluatedProperties",
139
+ "unevaluatedItems",
140
+ "prefixItems",
141
+ "$dynamicRef",
142
+ "$recursiveRef",
143
+ }
144
+ )
145
+ """Keywords of later JSON Schema, drafts 6 to 2020-12, that can forbid a value and that Draft 4 does not have.
146
+
147
+ Draft 4 is the reading of OpenAPI 3.0 and of Swagger 2.0, and a keyword it does not know it passes over: a
148
+ 3.0 schema with ``const: a`` took ``b``. Each one here was measured to pass a value its own draft refuses.
149
+ A schema holding one is refused wherever it stands and whatever it would forbid there: ``if: {}`` forbids
150
+ nothing and is refused as well. ``then``, ``else``, ``minContains`` and ``maxContains`` are left out, since
151
+ they do nothing without ``if`` or ``contains``, which are here.
152
+ """
153
+
154
+
155
+ def _time_read(read: re.Match[str], first: int) -> tuple[int, int] | None:
156
+ """The minute of the day a time stands at counted in UTC, beside its second.
157
+
158
+ Read off the groups of `_RFC_3339_TIME`, which open at *first* in the match. ``None`` where a field is
159
+ past its range. A second of ``60`` is within it: whether a leap second may stand there is for the
160
+ caller, who knows the day. The minute runs from -1439 to 2878, since an offset moves a time out of the
161
+ day it is written in.
162
+ """
163
+ hour, minute, second = int(read[first]), int(read[first + 1]), int(read[first + 2])
164
+ offset_hour, offset_minute = int(read[first + 4] or 0), int(read[first + 5] or 0)
165
+ if hour > 23 or minute > 59 or second > 60 or offset_hour > 23 or offset_minute > 59:
166
+ return None
167
+ offset = (offset_hour * 60 + offset_minute) * (-1 if read[first + 3] == "-" else 1)
168
+ return hour * 60 + minute - offset, second
169
+
170
+
171
+ def _is_rfc_3339_time(value: object) -> bool:
172
+ """Whether a text is an RFC 3339 ``full-time``, which is what the format `time` names.
173
+
174
+ The offset is required, as it is in a `date-time`. A second of ``60`` is taken in the last minute of a
175
+ day counted in UTC: with no date beside it, that is all that can be asked of a leap second.
176
+ """
177
+ if not issubclass(type(value), str):
178
+ return True
179
+ read = re.fullmatch(_RFC_3339_TIME, cast("str", value))
180
+ stands = None if read is None else _time_read(read, 1)
181
+ return stands is not None and (stands[1] < 60 or stands[0] in (1439, -1))
182
+
183
+
184
+ def _is_rfc_3339_moment(value: object) -> bool:
185
+ """Whether a text is an RFC 3339 `date-time`. What is no text is not this format's to judge.
186
+
187
+ The grammar is the RFC's, read off its ABNF: lower-case ``t`` and ``z`` are allowed, a space for the ``T``
188
+ is not, the offset is required, a fraction of a second is of any length, and the year runs from ``0000``.
189
+ A second of ``60`` is taken where the RFC allows a leap second: in the last minute of a month, counted in
190
+ UTC. Which months held one is not asked.
191
+ """
192
+ if not issubclass(type(value), str):
193
+ return True
194
+ read = _RFC_3339_MOMENT.fullmatch(cast("str", value))
195
+ if read is None:
196
+ return False
197
+ year, month, day = int(read[1]), int(read[2]), int(read[3])
198
+ stands = _time_read(read, 4)
199
+ if stands is None or not 1 <= month <= 12:
200
+ return False
201
+ in_utc, second = stands
202
+ leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
203
+ last = _DAYS_IN[month - 1] + (month == 2 and leap)
204
+ ends_a_month = (in_utc == 1439 and day == last) or (in_utc == -1 and day == 1)
205
+ return 1 <= day <= last and (second < 60 or ends_a_month)
206
+
207
+
208
+ def _written_as(pattern: str) -> Any:
209
+ """A check that a text is written whole as *pattern* writes it. What is no text is not a format's to judge.
210
+
211
+ The text is read by the pattern alone, so a class of its own has no say in its length or its letters.
212
+ """
213
+
214
+ def written(value: object) -> bool:
215
+ return not issubclass(type(value), str) or re.fullmatch(pattern, cast("str", value)) is not None
216
+
217
+ return written
218
+
219
+
220
+ _is_duration: Final = _written_as(_DURATION)
221
+
222
+
223
+ def _is_email(value: object) -> bool:
224
+ """Whether a text is a mailbox of RFC 5321: `_MAILBOX`, and a quoted local part one in which each backslash
225
+ quotes the character after it and no quote stands bare.
226
+
227
+ jsonschema asks a text for an ``@`` and no more, so ``@`` and ``a b@c`` passed for an address.
228
+ """
229
+ if not issubclass(type(value), str):
230
+ return True
231
+ read = re.fullmatch(_MAILBOX, cast("str", value))
232
+ if read is None:
233
+ return False
234
+ quoting = False
235
+ for character in read.group("quoted") or "":
236
+ if not quoting and character == '"':
237
+ return False
238
+ quoting = not quoting and character == "\\"
239
+ return not quoting
240
+
241
+
242
+ def _is_hostname(value: object) -> bool:
243
+ """Whether a text is a host name: `_HOSTNAME`, and each label that opens ``xn--`` an A-label of IDNA 2008.
244
+
245
+ The reading of JSON Schema since 2019, which has the format take in "host names produced using the
246
+ Punycode algorithm": such a label has to decode, and to what RFC 5891 allows in a label. Before that
247
+ the format is the syntax alone, and so it is under OpenAPI 3.0 and Swagger 2.0 (`_openapi_formats`).
248
+
249
+ The rules need tables of Unicode, which the `idna` package carries, so the answer for a code point is the
250
+ one of the release installed. A newer release takes what Unicode has assigned since: from 3.7 to 3.20
251
+ that is 21 407 code points more, and none fewer.
252
+ """
253
+ if not issubclass(type(value), str):
254
+ return True
255
+ text = cast("str", value)
256
+ if re.fullmatch(_HOSTNAME, text) is None:
257
+ return False
258
+ encoded = [] if str.find(text, "--") < 0 else re.findall(r"(?<![^.])[Xx][Nn]--[^.]*", text)
259
+ if not encoded:
260
+ return True
261
+ try:
262
+ import idna # 2 ms at the first such label of a process, and nothing for a host name without one
263
+ except ImportError:
264
+ raise ImportError(
265
+ "idna is required to check an `xn--` label of a hostname. Install it with: pip install assertpy[json]"
266
+ ) from None
267
+ try:
268
+ for label in encoded:
269
+ idna.decode(label)
270
+ except UnicodeError:
271
+ # every refusal of `idna` is one, and its releases before 3.3 let the codec's own out as it was
272
+ return False
273
+ return True
274
+
275
+
276
+ def _is_uri(value: object) -> bool:
277
+ """Whether a text is a URI of RFC 3986: `_URI`, and a host in brackets that is an IPv6 address or an IPvFuture.
278
+
279
+ The address is asked of `ipaddress`, once its text is hex digits, colons and dots alone: a zone, which
280
+ `ipaddress` takes, is no part of the address as RFC 3986 writes it.
281
+ """
282
+ if not issubclass(type(value), str):
283
+ return True
284
+ read = re.fullmatch(_URI, cast("str", value))
285
+ if read is None:
286
+ return False
287
+ literal = read.group("literal")
288
+ if literal is None or re.fullmatch(r"[vV][0-9A-Fa-f]+\.[A-Za-z0-9\-._~!$&'()*+,;=:]+", literal) is not None:
289
+ return True
290
+ if re.fullmatch(r"[0-9A-Fa-f:.]+", literal) is None:
291
+ return False
292
+ import ipaddress # 0.8 ms, kept off `import assertpy`: an address in brackets alone asks it
293
+
294
+ try:
295
+ ipaddress.IPv6Address(literal)
296
+ except ValueError:
297
+ return False
298
+ return True
299
+
300
+
301
+ def _fits_in(bits: int) -> Any:
302
+ """The check of OpenAPI's ``int32`` or ``int64``: a whole number within the signed range of *bits*.
303
+
304
+ The number is read through `int` or `float` itself: a class of the caller's may answer ``<=`` or
305
+ ``is_integer`` with code of its own. A bool is an `int` to Python and a truth value to JSON, where it is
306
+ no number.
307
+ """
308
+ low, high = -(2 ** (bits - 1)), 2 ** (bits - 1) - 1
309
+
310
+ def fits(value: Any) -> bool:
311
+ kind = type(value)
312
+ if issubclass(kind, int) and not issubclass(kind, bool):
313
+ return low <= int.__add__(value, 0) <= high
314
+ if issubclass(kind, float) and float.is_integer(value):
315
+ return low <= float.__add__(value, 0.0) <= high
316
+ return True
317
+
318
+ return fits
319
+
320
+
321
+ def _openapi_formats(jsonschema_mod: Any, *, a_labels: bool) -> Any:
322
+ """jsonschema's format checker, with the formats it leaves unchecked that a contract most often declares.
323
+
324
+ `int32` and `int64` are OpenAPI's own, and jsonschema knows neither: ``2**40`` passed for an `int32`.
325
+ `date-time`, `uri`, `hostname` and `duration` it checks only beside a package each, none of which this
326
+ library installs, so ``"yesterday"`` passed for a moment and ``"not a uri"`` for a URI. The checks here
327
+ stand whether such a package is there or not, so a text is not judged by what else is installed. The
328
+ rest of what jsonschema checks beside a package of its own stays unchecked where that package is missing.
329
+
330
+ `time` it checks everywhere, and by the rule of Draft 3: ``HH:MM:SS`` and no more, so ``10:00:00Z``
331
+ failed and ``10:00:00`` passed, where the format has been RFC 3339's ``full-time`` since Draft 7. `email`
332
+ it checks everywhere too, by asking for an ``@``.
333
+
334
+ *a_labels* is whether a `hostname` holds its ``xn--`` labels to IDNA: the dialect of OpenAPI 3.1 does,
335
+ the one of 3.0 and Swagger 2.0 has the format as the syntax of a host name and no more.
336
+ """
337
+ checker = jsonschema_mod.FormatChecker()
338
+ checker.checks("int32")(_fits_in(32))
339
+ checker.checks("int64")(_fits_in(64))
340
+ checker.checks("date-time")(_is_rfc_3339_moment)
341
+ checker.checks("time")(_is_rfc_3339_time)
342
+ checker.checks("duration")(_is_duration)
343
+ checker.checks("email")(_is_email)
344
+ checker.checks("hostname")(_is_hostname if a_labels else _written_as(_HOSTNAME))
345
+ checker.checks("uri")(_is_uri)
346
+ return checker
347
+
348
+
349
+ def _as_written(number: Any) -> fractions.Fraction | None:
350
+ """A number exactly, and a float as the decimal it prints as: ``None`` for one that is not finite.
351
+
352
+ A float prints as the shortest decimal that reads back as it, which is the number a document wrote unless
353
+ that had more digits than a float keeps: ``0.30000000000000004`` stays what it is, and no ``0.3``. The
354
+ print is `float`'s own, whatever the class of the value.
355
+ """
356
+ if issubclass(type(number), float):
357
+ try:
358
+ # through `Decimal`, which reads the text in C: `Fraction` reading it cost a number 0.4 us more
359
+ return fractions.Fraction(decimal.Decimal(float.__repr__(number)))
360
+ except (ValueError, OverflowError):
361
+ return None
362
+ exact = _exact_real(number)
363
+ return exact[1] if isinstance(exact, tuple) and not exact[0] else None
364
+
365
+
366
+ @functools.lru_cache(maxsize=64)
367
+ def _step_written(divisor: float) -> fractions.Fraction | None:
368
+ """`_as_written` of a ``multipleOf`` that is a plain `int` or `float`, kept: read again for each number a
369
+ schema holds to it, it cost the number 0.7 us."""
370
+ return _as_written(divisor)
371
+
372
+
373
+ def _a_reference_alone(schema: Any) -> Any:
374
+ """What Draft 4 reads of a schema: its ``$ref`` alone where it has one, and every keyword where it has none."""
375
+ ref = schema.get("$ref")
376
+ return schema.items() if ref is None else [("$ref", ref)]
377
+
378
+
379
+ @functools.cache
380
+ def _dividing_as_written(validator_cls: Any, *, redeclared: bool = False) -> Any:
381
+ """*validator_cls* with a ``multipleOf`` that divides the numbers as they are written (`_as_written`).
382
+
383
+ jsonschema divides two floats and asks whether the quotient is whole, and ``19.99 / 0.01`` is
384
+ ``1998.9999999999998``: a price failed the ``multipleOf: 0.01`` it meets. The class is made once: made
385
+ for each check it cost the check 213 us.
386
+
387
+ A ``multipleOf`` that is no number above zero is refused, as every draft has the keyword. jsonschema
388
+ raised `ZeroDivisionError` for a zero, took a negative one by its size and passed every number for an
389
+ infinity.
390
+
391
+ jsonschema hands a schema that declares a ``$schema`` to the class it keeps for that dialect, whose
392
+ division is its own. With *redeclared*, the schema goes to the class made here from that one instead,
393
+ whichever of the drafts that have the keyword it declares: the one the document is read by or another.
394
+ That is asked of every schema entered, at 0.1 to 0.2 us each, so it is for a document that may hold a ``$schema``
395
+ and no other.
396
+
397
+ Made by ``create()`` with what Draft 4 reads beside a ``$ref`` handed in. ``extend()`` of jsonschema 4.18,
398
+ the oldest the ``json`` extra takes, makes a class that has lost it and validates what stands there.
399
+ """
400
+ jsonschema_mod = _ensure_jsonschema()
401
+ import attrs
402
+ from jsonschema.validators import create
403
+
404
+ def multiple_of(validator: Any, divisor: Any, instance: Any, schema: Any) -> Iterator[Any]:
405
+ if not validator.is_type(instance, "number"):
406
+ return
407
+ if type(divisor) in (int, float):
408
+ step = _step_written(divisor)
409
+ else:
410
+ step = _as_written(divisor) if validator.is_type(divisor, "number") else None
411
+ if step is None or step <= 0:
412
+ raise ValueError(f"multipleOf <{divisor!r}> is no number above zero, which is what the keyword takes")
413
+ read = _as_written(instance)
414
+ if read is None or read.numerator * step.denominator % (read.denominator * step.numerator):
415
+ yield jsonschema_mod.ValidationError(f"{instance!r} is not a multiple of {divisor}")
416
+
417
+ keywords: dict[str, Any] = {**validator_cls.VALIDATORS, "multipleOf": multiple_of}
418
+ # the drafts that read a `$ref` alone. Draft 3 has no `multipleOf` and is left to jsonschema
419
+ alone = (jsonschema_mod.Draft4Validator, jsonschema_mod.Draft6Validator, jsonschema_mod.Draft7Validator)
420
+ divided = (*alone, jsonschema_mod.Draft201909Validator, jsonschema_mod.Draft202012Validator)
421
+ made: Any = create(
422
+ meta_schema=validator_cls.META_SCHEMA,
423
+ validators=keywords,
424
+ type_checker=validator_cls.TYPE_CHECKER,
425
+ format_checker=validator_cls.FORMAT_CHECKER,
426
+ id_of=validator_cls.ID_OF,
427
+ # named in either case, the later drafts with what jsonschema itself defaults to: every keyword
428
+ applicable_validators=_a_reference_alone if validator_cls in alone else operator.methodcaller("items"),
429
+ )
430
+ as_jsonschema_evolves = made.evolve
431
+
432
+ def evolve(validator: Any, **changes: Any) -> Any:
433
+ evolved = as_jsonschema_evolves(validator, **changes)
434
+ named = type(evolved)
435
+ if named not in divided:
436
+ return evolved
437
+ ours = _dividing_as_written(named, redeclared=True)
438
+ return ours(**{field.alias: getattr(evolved, field.name) for field in attrs.fields(named) if field.init})
439
+
440
+ if redeclared:
441
+ made.evolve = evolve
442
+ return made
443
+
444
+
445
+ def _parsed_json_path(path: str):
446
+ """The compiled expression, refusing a path that is not a string before the cache hashes it.
447
+
448
+ A list reached the cache first and came back as "unhashable type: 'list'", which names the cache
449
+ rather than the argument.
450
+ """
451
+ require_type(path, str, "a string", subject=argument("path"))
452
+ return _compiled_json_path(path)
453
+
454
+
455
+ @functools.lru_cache(maxsize=256)
456
+ def _compiled_json_path(path: str):
457
+ """The compiled form of a JSON path expression, kept across calls.
458
+
459
+ Parsing is most of the cost of a path assertion and the lookup is a rounding error: on a twenty-record
460
+ document a parse measures 0.15 ms against 0.002 ms for the search, and 5.8 ms under a ``jsonpath_ng``
461
+ old enough to build a fresh PLY lexer and parser every time it is asked. Expressions are immutable
462
+ and ``find()`` does not mutate them, so one compiled expression serves every call with that path.
463
+
464
+ Bounded rather than unbounded: a path built from test data (``$.users[7].name``) is a fresh string
465
+ each time, and an unbounded cache would grow with the suite.
466
+ """
467
+ return _ensure_jsonpath_ng().parse(path)
468
+
469
+
470
+ def _ensure_jsonpath_ng():
471
+ try:
472
+ import jsonpath_ng.ext.parser # optional dependency
473
+ except ImportError:
474
+ raise ImportError(
475
+ "jsonpath-ng is required for JSON path assertions. Install it with: pip install assertpy[json]"
476
+ ) from None
477
+ return jsonpath_ng.ext.parser
478
+
479
+
480
+ def _ensure_jsonschema():
481
+ try:
482
+ import jsonschema # optional dependency
483
+ except ImportError:
484
+ raise ImportError(
485
+ "jsonschema is required for JSON schema assertions. Install it with: pip install assertpy[json]"
486
+ ) from None
487
+ return jsonschema
488
+
489
+
490
+ def _nullable_as_null(schema: dict[str, Any], keyword: str, *, by_the_text: bool) -> None:
491
+ """Rewrite ``nullable: true`` of one Schema Object into JSON Schema, in place (jsonschema ignores the keyword).
492
+
493
+ ``"null"`` is added to the ``type`` beside it, which keeps per-keyword error paths precise: a bad ``format``
494
+ on a nullable field still reports ``format``, not a vague union. An ``enum`` is left as written. OpenAPI
495
+ 3.0.3 has ``nullable`` add to the type alone, "other Schema Object constraints retain their defined
496
+ behavior", so ``null`` is allowed where the enum lists it.
497
+
498
+ With no ``type`` of its own the keyword does nothing by that text. One reading is kept wider on purpose: a
499
+ schema that is a ``$ref`` or an ``allOf``, ``anyOf`` or ``oneOf`` becomes a union with ``null``, which is how
500
+ a nullable reference is written in practice. Of a composition the union takes the place of ``allOf``,
501
+ ``anyOf`` and ``oneOf`` alone: what else the schema holds stays beside it and is asked of ``null`` too, so
502
+ an ``enum`` there allows ``null`` only where it lists it, and a ``not`` still refuses what it refuses. A
503
+ ``$ref`` goes into the union with everything beside it: OpenAPI 3.0 ignores what stands beside a reference.
504
+
505
+ *by_the_text* leaves that wider reading out: ``nullable`` with no ``type`` beside it then does nothing.
506
+
507
+ Swagger 2.0 spells the idea ``x-nullable``. It is an extension with no text of its own and keeps the
508
+ reading it had: ``null`` passes its enum, and any schema with no ``type`` becomes the union, whatever
509
+ *by_the_text* says.
510
+ """
511
+ nullable = schema.get(keyword)
512
+ if not isinstance(nullable, bool):
513
+ return
514
+ del schema[keyword]
515
+ if not nullable:
516
+ return
517
+ extension = keyword == "x-nullable"
518
+ held = schema.get("type")
519
+ if isinstance(held, (str, list)):
520
+ schema["type"] = [*([held] if isinstance(held, str) else held), "null"]
521
+ enum = schema.get("enum")
522
+ if extension and isinstance(enum, list) and None not in enum:
523
+ schema["enum"] = [*enum, None]
524
+ elif by_the_text and not extension:
525
+ return
526
+ elif extension or "$ref" in schema:
527
+ inner = dict(schema)
528
+ schema.clear()
529
+ schema["anyOf"] = [inner, {"type": "null"}]
530
+ else:
531
+ composed = {key: schema.pop(key) for key in ("allOf", "anyOf", "oneOf") if key in schema}
532
+ if composed:
533
+ schema["anyOf"] = [composed, {"type": "null"}]
534
+
535
+
536
+ def _stringify_keys(node: Any) -> Any:
537
+ """Recursively convert all mapping keys to strings.
538
+
539
+ YAML parses a numeric-looking key such as a status code (``200:``) as an ``int``, but OpenAPI and
540
+ JSON-Schema keys are semantically strings and the JSON-Pointer resolver matches string segments.
541
+ """
542
+ if isinstance(node, dict):
543
+ return {str(key): _stringify_keys(value) for key, value in node.items()}
544
+ if isinstance(node, list):
545
+ return [_stringify_keys(item) for item in node]
546
+ return node
547
+
548
+
549
+ def _refers_to(part: Any) -> list[str] | None:
550
+ """Every ``$ref`` held anywhere in *part*, as data, or ``None`` where places alone do not say what it reads.
551
+
552
+ That is under JSON Schema 2020-12 a schema with an ``$id``, which is the base of the references under it,
553
+ a ``$dynamicRef``, which is followed like a reference and names its schema another way, and a ``$schema``:
554
+ jsonschema reads a schema by the dialect it declares, and another dialect has other ways to refer, the
555
+ ``$recursiveRef`` of 2019-09 to the root of the document among them. Read as data and not by the keywords
556
+ that hold a schema, so a reference inside an ``example`` counts as well: more is copied than is read, and
557
+ never less.
558
+ """
559
+ found: list[str] = []
560
+ pending = [part]
561
+ while pending:
562
+ node = pending.pop()
563
+ if isinstance(node, dict):
564
+ if "$id" in node or "$dynamicRef" in node or "$schema" in node:
565
+ return None
566
+ ref = node.get("$ref")
567
+ if isinstance(ref, str):
568
+ found.append(ref)
569
+ pending.extend(node.values())
570
+ elif isinstance(node, list):
571
+ pending.extend(node)
572
+ return found
573
+
574
+
575
+ def _named_by_its_keys(holder: Any, sound: set[int]) -> bool:
576
+ """Whether a text key of *holder* names in a part cut out below it what it names in the copy of the whole.
577
+
578
+ Not where a key of it is no text: the copy turns keys to text and keeps the later of two that read alike,
579
+ so ``7`` beside ``"7"`` names another schema there. And not where the mapping is a schema with an ``id``
580
+ or ``$id`` of its own: a reference under it is read from that base, which a part cut out below it does
581
+ not carry. *sound* holds the mappings found so, and each is gone through once a check: a mapping of
582
+ 20 000 schemas costs 0.2 ms.
583
+ """
584
+ if id(holder) in sound:
585
+ return True
586
+ if not isinstance(holder, dict) or isinstance(holder.get("id"), str) or isinstance(holder.get("$id"), str):
587
+ return False
588
+ if not set(map(type, holder)) <= {str}:
589
+ return False
590
+ sound.add(id(holder))
591
+ return True
592
+
593
+
594
+ def _responses_read(spec: Any, path: Any, method: Any, sound: set[int]) -> dict[str, Any] | None:
595
+ """The responses of one operation and what the spec says of itself, copied to the places they have in it.
596
+
597
+ ``None`` where the operation is not found as written, or a mapping on the way to it does not name by its
598
+ keys what it would in the whole (`_named_by_its_keys`).
599
+ """
600
+ if not (isinstance(path, str) and isinstance(method, str)):
601
+ return None
602
+ operation: Any = spec
603
+ for key in ("paths", path, method.lower()):
604
+ if not _named_by_its_keys(operation, sound) or key not in operation:
605
+ return None
606
+ operation = operation[key]
607
+ if not _named_by_its_keys(operation, sound):
608
+ return None
609
+ read = {key: _stringify_keys(operation[key]) for key in ("responses", "produces") if key in operation}
610
+ part = {key: _stringify_keys(spec[key]) for key in ("openapi", "swagger", "produces") if key in spec}
611
+ part["paths"] = {path: {method.lower(): read}}
612
+ return part
613
+
614
+
615
+ def _taken_to(part: dict[str, Any], spec: Any, segments: list[str], sound: set[int]) -> tuple[tuple[str, ...], Any]:
616
+ """Copy what *segments* name in *spec* to the same place in *part*: the place copied whole, and the copy.
617
+
618
+ A list on the way is the place, and is copied whole: a place in a list is a number, and a part of a list
619
+ has other numbers. The copy is ``None`` where a mapping on the way does not name by its keys what it
620
+ would in the whole (`_named_by_its_keys`), and nothing is copied.
621
+ """
622
+ holder, taken, depth = spec, part, 0
623
+ while _named_by_its_keys(holder, sound):
624
+ child = holder[segments[depth]]
625
+ if depth == len(segments) - 1 or isinstance(child, list):
626
+ taken[segments[depth]] = _stringify_keys(child)
627
+ return tuple(segments[: depth + 1]), taken[segments[depth]]
628
+ holder, taken, depth = child, taken.setdefault(segments[depth], {}), depth + 1
629
+ return (), None
630
+
631
+
632
+ def _part_read(spec: Any, path: Any, method: Any) -> dict[str, Any] | None:
633
+ """The part of a spec that a check of one operation reads, copied with its keys as text.
634
+
635
+ The responses of the operation, and whatever they refer to, each at the place it has in the spec, so a
636
+ pointer names in the part what it names in the whole. The rest is never looked at: copied whole on every
637
+ call, a spec of 4 MB cost 13 ms a check, and one of 16 MB cost 60.
638
+
639
+ ``None`` where the part cannot be cut out by places alone (`_refers_to`, `_named_by_its_keys`), or the
640
+ operation or something it refers to is not found as a pointer into this document names it: a reference
641
+ elsewhere, to an anchor or by an address is none. The whole is copied then, as it was, and says what is
642
+ wrong.
643
+ """
644
+ sound: set[int] = set()
645
+ part = _responses_read(spec, path, method, sound)
646
+ if part is None:
647
+ return None
648
+ whole: set[tuple[str, ...]] = {("paths", path, method.lower(), "responses")}
649
+ pending = _refers_to(part)
650
+ while pending:
651
+ target, segments = _resolve_local_ref(spec, pending.pop())
652
+ if target is None:
653
+ return None
654
+ if any(tuple(segments[:depth]) in whole for depth in range(1, len(segments) + 1)):
655
+ continue
656
+ place, copied = _taken_to(part, spec, segments, sound)
657
+ more = _refers_to(copied)
658
+ if copied is None or more is None:
659
+ return None
660
+ whole.add(place)
661
+ pending.extend(more)
662
+ return None if pending is None else part
663
+
664
+
665
+ def _resolve_local_ref(spec: dict[str, Any], ref: str):
666
+ """Follow a local JSON-Pointer ``$ref`` (``#/a/b/c``); return ``(target_node, [a, b, c])``, or
667
+ ``(None, [])`` for a non-local or dangling ref.
668
+
669
+ Read as the validator reads it, or the two part ways on what a reference names: the fragment is
670
+ percent-decoded whole, then split, then unescaped, and a segment into a list is whatever `int` takes for
671
+ an index, ``+0`` and ``-1`` included.
672
+ """
673
+ if not ref.startswith("#/"):
674
+ return None, []
675
+ fragment = ref[2:]
676
+ if "%" in fragment:
677
+ from urllib.parse import unquote # 1.6 ms, kept off `import assertpy`: an encoded reference alone asks it
678
+
679
+ fragment = unquote(fragment)
680
+ segments = [part.replace("~1", "/").replace("~0", "~") for part in fragment.split("/")]
681
+ node: Any = spec
682
+ for segment in segments:
683
+ if isinstance(node, dict) and segment in node:
684
+ node = node[segment]
685
+ elif isinstance(node, list):
686
+ try:
687
+ node = node[int(segment)]
688
+ except (ValueError, IndexError):
689
+ return None, []
690
+ else:
691
+ return None, []
692
+ return node, segments
693
+
694
+
695
+ class _References:
696
+ """How the walk goes through a document: as the validator does, asking its registry and entering a schema.
697
+
698
+ Read by a reader of its own, the two parted on what a reference names: one into a list, one whose
699
+ separators were percent-encoded, one that names the document by its address, one that stands under a
700
+ schema with an ``id`` of its own.
701
+ """
702
+
703
+ def __init__(self, unresolvable: type[Exception], draft: Any) -> None:
704
+ self._unresolvable = unresolvable
705
+ self._draft = draft
706
+
707
+ def named(self, reference: str, resolver: Any) -> tuple[Any, Any]:
708
+ """What *reference* names from the base *resolver* stands at, and the resolver in effect there.
709
+
710
+ ``(None, resolver)`` is a reference that names nothing, which the validator says itself where it comes
711
+ to it: in the registry's own error, or in the `ValueError` it lets out for a segment into a list that
712
+ is no number.
713
+ """
714
+ try:
715
+ found = resolver.lookup(reference)
716
+ except (self._unresolvable, ValueError):
717
+ return None, resolver
718
+ return found.contents, found.resolver
719
+
720
+ def inside(self, schema: dict[str, Any], resolver: Any) -> Any:
721
+ """The resolver in effect inside a schema its parent holds: an ``id`` of its own moves the base there."""
722
+ return resolver.in_subresource(self._draft.create_resource(schema))
723
+
724
+
725
+ def _schemas_reached(reference: str, resolver: Any, references: _References) -> list[tuple[dict[str, Any], Any]]:
726
+ """Every schema the one *reference* names reaches, each once, beside the resolver in effect where it stands.
727
+
728
+ Walked by the keywords that hold a schema for the validator, through the references it follows, and not as
729
+ data: an ``enum`` member or an ``example`` shaped like a schema is a value, and rewritten as a schema it no
730
+ longer matched itself.
731
+
732
+ A schema Draft 4 never asks by the shape of what holds it is not reached: ``additionalItems`` beside an
733
+ ``items`` that is one schema. One it never asks for what a keyword beside it takes, an
734
+ ``additionalProperties`` beside a pattern that takes every name, is reached like any other.
735
+
736
+ A schema is read once, under the resolver it was first reached with.
737
+ """
738
+ reached: list[tuple[dict[str, Any], Any]] = []
739
+ pending, seen = [references.named(reference, resolver)], set()
740
+ while pending:
741
+ schema, within = pending.pop()
742
+ if not isinstance(schema, dict) or id(schema) in seen:
743
+ continue
744
+ seen.add(id(schema))
745
+ reached.append((schema, within))
746
+ ref = schema.get("$ref")
747
+ if isinstance(ref, str):
748
+ pending.append(references.named(ref, within))
749
+ continue
750
+ held: list[Any] = []
751
+ for keyword in ("properties", "patternProperties", "dependencies"):
752
+ mapped = schema.get(keyword)
753
+ if isinstance(mapped, dict):
754
+ held.extend(mapped.values())
755
+ for keyword in ("items", "additionalProperties", "not", "allOf", "anyOf", "oneOf"):
756
+ one = schema.get(keyword)
757
+ held.extend(one if isinstance(one, list) else [one])
758
+ if isinstance(schema.get("items"), list):
759
+ # beside an `items` that is one schema, Draft 4 never asks `additionalItems`
760
+ held.append(schema.get("additionalItems"))
761
+ pending.extend((each, references.inside(each, within)) for each in held if isinstance(each, dict))
762
+ return reached
763
+
764
+
765
+ def _write_only(declared: object, resolver: Any, references: _References) -> bool:
766
+ """Whether the schema a property declares is marked ``writeOnly``, read through the references it is.
767
+
768
+ What stands beside a ``$ref`` is ignored, as OpenAPI 3.0 has it, so the mark is the one on the schema the
769
+ references lead to.
770
+ """
771
+ followed: set[int] = set()
772
+ while isinstance(declared, dict) and id(declared) not in followed:
773
+ followed.add(id(declared))
774
+ ref = declared.get("$ref")
775
+ if not isinstance(ref, str):
776
+ return declared.get("writeOnly") is True
777
+ declared, resolver = references.named(ref, resolver)
778
+ return False
779
+
780
+
781
+ def _unrequire_write_only(schema: dict[str, Any], resolver: Any, references: _References) -> None:
782
+ """Take every ``writeOnly`` property of one Schema Object out of its ``required``, in place.
783
+
784
+ OpenAPI 3.0: "If the property is marked as writeOnly being true and is in the required list, the required
785
+ will take effect on the request only", and a response is what is validated here. Asked before ``nullable``
786
+ is rewritten, so a mark is read off the schema as its author wrote it.
787
+
788
+ A limit of this reading, and not a conclusion of the text: the mark is looked for in the ``properties`` of
789
+ the Schema Object that holds the ``required``. A ``required`` in one branch of an ``allOf`` and the marked
790
+ property in another are not brought together.
791
+
792
+ Asked of the validator instead, as a ``required`` keyword of its own, the class built per call cost a check
793
+ of twenty rows about 600 us, against 294 this way.
794
+ """
795
+ properties, required = schema.get("properties"), schema.get("required")
796
+ if isinstance(properties, dict) and isinstance(required, list):
797
+ schema["required"] = [
798
+ name
799
+ for name in required
800
+ if not (isinstance(name, str) and _write_only(properties.get(name), resolver, references))
801
+ ]
802
+
803
+
804
+ def _unheard(schema: dict[str, Any]) -> list[str]:
805
+ """What one Schema Object holds that Draft 4 does not have: a keyword of `_UNHEARD`, which it passes over, or
806
+ an exclusive bound written as a number. That one Draft 4 has as a flag beside ``minimum`` and ``maximum``:
807
+ it reads any number but zero as the flag set, whatever the number, and passes it over where neither stands.
808
+
809
+ What stands beside a ``$ref`` is ignored by 3.0 itself, so nothing there is lost.
810
+ """
811
+ if isinstance(schema.get("$ref"), str):
812
+ return []
813
+ found = sorted(_UNHEARD.intersection(schema))
814
+ for bound in ("exclusiveMaximum", "exclusiveMinimum"):
815
+ held = schema.get(bound)
816
+ if isinstance(held, (int, float)) and not isinstance(held, bool):
817
+ found.append(f"{bound} as a number")
818
+ return found
819
+
820
+
821
+ def _pointer_to(document: Any, wanted: object) -> str:
822
+ """The JSON pointer of one node of *document*, found by identity. Asked only on the way to a refusal."""
823
+ pending: list[tuple[str, Any]] = []
824
+ where, node = "#", document
825
+ while node is not wanted:
826
+ if isinstance(node, dict):
827
+ pending.extend((f"{where}/{key.replace('~', '~0').replace('/', '~1')}", held) for key, held in node.items())
828
+ elif isinstance(node, list):
829
+ pending.extend((f"{where}/{index}", held) for index, held in enumerate(node))
830
+ where, node = pending.pop()
831
+ return where
832
+
833
+
834
+ def _read_as_json_schema(
835
+ document: dict[str, Any],
836
+ reference: str,
837
+ resolver: Any,
838
+ references: _References,
839
+ *,
840
+ swagger: bool,
841
+ whole: bool,
842
+ strict_nullable: bool,
843
+ ) -> bool:
844
+ """Read the schemas a response reaches into the JSON Schema the validator knows, in the copy handed here.
845
+
846
+ ``False``, with nothing read or refused, where *document* is a part of the spec and a schema reached holds
847
+ an ``id`` of its own: a reference under it is then read from another base than the place it is written at,
848
+ which the part was not cut out for.
849
+
850
+ Every mark is read before any ``nullable`` is rewritten: a schema one property names may be rewritten for
851
+ another first, and the union it becomes holds the mark a level down.
852
+
853
+ A ``$schema`` a schema declares is taken out. jsonschema reads a schema by the dialect it names, and an
854
+ OpenAPI 3.0 or Swagger 2.0 document is one dialect, with no ``$schema`` in a Schema Object. It goes before
855
+ ``nullable`` is rewritten, which may move what a schema holds a level down.
856
+ """
857
+ reached = _schemas_reached(reference, resolver, references)
858
+ if not whole and any("id" in schema for schema, _ in reached):
859
+ return False
860
+ for schema, _ in reached:
861
+ unheard = _unheard(schema)
862
+ if unheard:
863
+ dialect = "Swagger 2.0" if swagger else "OpenAPI 3.0"
864
+ raise ValueError(
865
+ f"Schema <{_pointer_to(document, schema)}> holds <{'>, <'.join(unheard)}>, which {dialect} does not"
866
+ " have, so the response would not be held to it as written."
867
+ " Leave it out, or move the spec to OpenAPI 3.1 as a whole: with the version line alone changed,"
868
+ " `nullable` and a boolean exclusive bound are what is not read."
869
+ )
870
+ for schema, within in () if swagger else reached:
871
+ _unrequire_write_only(schema, within, references)
872
+ for schema, _ in reached:
873
+ schema.pop("$schema", None)
874
+ _nullable_as_null(schema, "x-nullable" if swagger else "nullable", by_the_text=strict_nullable)
875
+ return True
876
+
877
+
878
+ def _status_text(status: Any) -> str:
879
+ """A status as a Responses Object spells it. Read off `int` where it is one: below 3.11 the `str` of
880
+ `HTTPStatus.NOT_FOUND` was its name, and the response it named was not found."""
881
+ kind = type(status)
882
+ return str(int.__index__(status)) if issubclass(kind, int) and not issubclass(kind, bool) else str(status)
883
+
884
+
885
+ def _declared_for(status: str, responses: Any, *, ranged: bool) -> str | None:
886
+ """The key under which an operation declares the response for *status*, or ``None`` where it declares none.
887
+
888
+ Its own code first, then its range, then ``default``. OpenAPI 3: "2XX represents all response codes between
889
+ [200-299]", "only the following range definitions are allowed: 1XX, 2XX, 3XX, 4XX, and 5XX", and "the
890
+ explicit code definition takes precedence over the range definition for that code". ``default`` is "the
891
+ documentation of responses other than the ones declared for specific HTTP response codes". Swagger 2.0 has
892
+ codes and ``default``, and no ranges.
893
+ """
894
+ known = [status, "default"]
895
+ if ranged and len(status) == 3 and status.isascii() and status.isdigit() and status[0] in "12345":
896
+ known.insert(1, f"{status[0]}XX")
897
+ return next((key for key in known if key in responses), None)
898
+
899
+
900
+ def _openapi_resolve(spec: dict[str, Any], path: str, method: str, status: str | int | None, content_type: str):
901
+ """Resolve an operation's response-body schema to a JSON Pointer into the spec document.
902
+
903
+ Returns ``(status_key, pointer)``. Raises ``ValueError`` for any structural miss (unknown path,
904
+ method, status, or content type) - those are test-authoring mistakes, not contract violations.
905
+ """
906
+ # named rather than left to `.lower()`, which answered from three frames down without naming the argument
907
+ require_type(method, str, "a string", subject=argument("method"))
908
+ method_key = method.lower()
909
+ try:
910
+ operation = spec["paths"][path][method_key]
911
+ responses = operation["responses"]
912
+ except (KeyError, TypeError):
913
+ raise ValueError(f"OpenAPI spec has no operation <{method.upper()} {path}>.") from None
914
+ ranged = not str(spec.get("swagger", "")).startswith("2")
915
+ if status is not None:
916
+ asked = _status_text(status)
917
+ status_key = _declared_for(asked, responses, ranged=ranged)
918
+ if status_key is None:
919
+ raise ValueError(f"Operation <{method.upper()} {path}> declares no response <{asked}>.")
920
+ else:
921
+ success = ("200", "201", "2XX", "default") if ranged else ("200", "201", "default")
922
+ status_key = next((code for code in success if code in responses), "")
923
+ if not status_key:
924
+ raise ValueError(f"Specify status: <{method.upper()} {path}> declares responses {sorted(responses)}.")
925
+ response = responses[status_key]
926
+ response_segments = ["paths", path, method_key, "responses", status_key]
927
+ if isinstance(response, dict) and "$ref" in response:
928
+ response, response_segments = _resolve_local_ref(spec, response["$ref"])
929
+ if response is None:
930
+ raise ValueError(f"Response <{status_key}> of <{method.upper()} {path}> has an unresolvable $ref.")
931
+ if not ranged:
932
+ # Swagger 2.0 lists media types in `produces`, checked the way the 3.x content lookup does
933
+ produces = operation.get("produces") or spec.get("produces")
934
+ if produces and content_type not in produces:
935
+ raise ValueError(
936
+ f"Response <{status_key}> of <{method.upper()} {path}> declares no <{content_type}> schema."
937
+ )
938
+ if not (isinstance(response, dict) and "schema" in response):
939
+ raise ValueError(f"Response <{status_key}> of <{method.upper()} {path}> declares no schema.")
940
+ segments = [*response_segments, "schema"]
941
+ else:
942
+ content = response.get("content", {}) if isinstance(response, dict) else {}
943
+ if content_type not in content or "schema" not in content[content_type]:
944
+ raise ValueError(
945
+ f"Response <{status_key}> of <{method.upper()} {path}> declares no <{content_type}> schema."
946
+ )
947
+ segments = [*response_segments, "content", content_type, "schema"]
948
+ pointer = "#/" + "/".join(segment.replace("~", "~0").replace("/", "~1") for segment in segments)
949
+ return status_key, pointer
950
+
951
+
952
+ def _validator_over(
953
+ document: Any,
954
+ path: str,
955
+ method: str,
956
+ status: str | int | None,
957
+ content_type: str,
958
+ *,
959
+ whole: bool,
960
+ strict_nullable: bool,
961
+ ) -> tuple[str, Any] | None:
962
+ """The key of the response asked for and a validator of its schema, over *document*, which is its to rewrite.
963
+
964
+ ``None`` where *document* is a part of the spec that turns out too small to be read alone.
965
+ """
966
+ jsonschema_mod = _ensure_jsonschema()
967
+ import referencing
968
+ from referencing.exceptions import Unresolvable
969
+ from referencing.jsonschema import DRAFT4, DRAFT202012
970
+
971
+ version = str(document.get("openapi", ""))
972
+ is_swagger_2 = _SWAGGER_2.fullmatch(str(document.get("swagger", ""))) is not None
973
+ # matched whole, not by prefix: "3.10.0" starts with "3.1", and "3.1.garbage" is no version at all
974
+ read = _OPENAPI_VERSION.fullmatch(version)
975
+ if version and read is None:
976
+ # read as 3.0 an unknown version validates against Draft 4, which passes anything it cannot spell
977
+ raise ValueError(f"openapi version <{version}> is not one this can validate (3.0, 3.1 and 3.2 are)")
978
+ # 3.2 keeps 3.1's dialect: the schema object is JSON Schema 2020-12 in both
979
+ is_openapi_31 = read is not None and read.group(1) in ("1", "2")
980
+ status_key, pointer = _openapi_resolve(document, path, method, status, content_type)
981
+ specification = DRAFT202012 if is_openapi_31 else DRAFT4
982
+ # a part holds no `$schema` (`_refers_to`), and under 3.0 each one is taken out
983
+ validator_cls = _dividing_as_written(
984
+ jsonschema_mod.Draft202012Validator if is_openapi_31 else jsonschema_mod.Draft4Validator,
985
+ redeclared=whole and is_openapi_31,
986
+ )
987
+ base = "urn:assertpy-openapi"
988
+ registry = referencing.Registry().with_resource(
989
+ uri=base, resource=referencing.Resource(contents=document, specification=specification)
990
+ )
991
+ # 3.1 is JSON Schema 2020-12 already
992
+ if not is_openapi_31 and not _read_as_json_schema(
993
+ document,
994
+ base + pointer,
995
+ registry.resolver(base_uri=base),
996
+ _References(Unresolvable, DRAFT4),
997
+ swagger=is_swagger_2,
998
+ whole=whole,
999
+ strict_nullable=strict_nullable,
1000
+ ):
1001
+ return None
1002
+ return status_key, validator_cls(
1003
+ {"$ref": base + pointer},
1004
+ registry=registry,
1005
+ format_checker=_openapi_formats(jsonschema_mod, a_labels=is_openapi_31),
1006
+ )
1007
+
1008
+
1009
+ def _openapi_expected(error: Any) -> str:
1010
+ """Render a jsonschema validation error's constraint as a short 'expected' description."""
1011
+ validator, value = error.validator, error.validator_value
1012
+ if validator == "required":
1013
+ return "all required properties present"
1014
+ if validator == "type":
1015
+ return f"type {value}"
1016
+ if validator == "enum":
1017
+ return f"one of {value}"
1018
+ if validator == "oneOf":
1019
+ return "exactly one of the declared schemas"
1020
+ if validator == "anyOf":
1021
+ return "one of the declared schemas"
1022
+ if validator == "format":
1023
+ return f"{value} format"
1024
+ return error.message
1025
+
1026
+
1027
+ class JsonMixin(_MixinBase):
1028
+ """JSON path navigation and schema validation mixin."""
1029
+
1030
+ def at_json_path(self, path: str) -> Self:
1031
+ """Navigate to a JSON path and return a new builder with the matched value.
1032
+
1033
+ Uses JSONPath syntax (e.g. ``$.users[0].name``). If multiple matches are found,
1034
+ the value is a list of all matches. If exactly one match is found, the value is
1035
+ unwrapped from the list.
1036
+
1037
+ Args:
1038
+ path: JSONPath expression.
1039
+
1040
+ Examples:
1041
+ Usage:
1042
+
1043
+ data = {"users": [{"name": "Alice"}, {"name": "Bob"}]}
1044
+ assert_that(data).at_json_path("$.users[0].name").is_equal_to("Alice")
1045
+ assert_that(data).at_json_path("$.users[*].name").is_equal_to(["Alice", "Bob"])
1046
+
1047
+ Returns:
1048
+ AssertionBuilder: a new instance with the extracted value
1049
+
1050
+ Raises:
1051
+ ValueError: if no match is found at the given path
1052
+ """
1053
+ expr = _parsed_json_path(path)
1054
+ # handed a scalar, jsonpath answers about its own indexing rather than about the value under assertion
1055
+ require_type(self.val, (dict, list), "a decoded JSON document (a dict or a list)")
1056
+ matches = expr.find(self.val)
1057
+ if not matches:
1058
+ raise ValueError(f"Expected JSON path <{path}> to exist, but it did not.")
1059
+ if len(matches) == 1:
1060
+ return self.builder(matches[0].value, self.description, self.kind, logger=self.logger)
1061
+ return self.builder([match.value for match in matches], self.description, self.kind, logger=self.logger)
1062
+
1063
+ def has_json_path(self, path: str) -> Self:
1064
+ """Assert that the given JSON path exists in val.
1065
+
1066
+ Args:
1067
+ path: JSONPath expression.
1068
+
1069
+ Examples:
1070
+ Usage:
1071
+
1072
+ data = {"meta": {"total": 5}}
1073
+ assert_that(data).has_json_path("$.meta.total")
1074
+
1075
+ Returns:
1076
+ AssertionBuilder: returns this instance to chain to the next assertion
1077
+
1078
+ Raises:
1079
+ AssertionError: if the path does not exist
1080
+ """
1081
+ expr = _parsed_json_path(path)
1082
+ # handed a scalar, jsonpath answers about its own indexing rather than about the value under assertion
1083
+ require_type(self.val, (dict, list), "a decoded JSON document (a dict or a list)")
1084
+ matches = expr.find(self.val)
1085
+ if not matches:
1086
+ return self.error(f"Expected JSON path <{path}> to exist, but it did not.", expected=path)
1087
+ return self
1088
+
1089
+ def does_not_have_json_path(self, path: str) -> Self:
1090
+ """Assert that the given JSON path does not exist in val.
1091
+
1092
+ Args:
1093
+ path: JSONPath expression.
1094
+
1095
+ Examples:
1096
+ Usage:
1097
+
1098
+ data = {"status": "ok"}
1099
+ assert_that(data).does_not_have_json_path("$.error")
1100
+
1101
+ Returns:
1102
+ AssertionBuilder: returns this instance to chain to the next assertion
1103
+
1104
+ Raises:
1105
+ AssertionError: if the path exists
1106
+ """
1107
+ expr = _parsed_json_path(path)
1108
+ # handed a scalar, jsonpath answers about its own indexing rather than about the value under assertion
1109
+ require_type(self.val, (dict, list), "a decoded JSON document (a dict or a list)")
1110
+ matches = expr.find(self.val)
1111
+ if matches:
1112
+ return self.error(f"Expected JSON path <{path}> to not exist, but it did.")
1113
+ return self
1114
+
1115
+ def matches_json_schema(self, schema: dict[str, Any]) -> Self:
1116
+ """Assert that val conforms to the given JSON Schema.
1117
+
1118
+ Args:
1119
+ schema: a JSON Schema as a dict.
1120
+
1121
+ Examples:
1122
+ Usage:
1123
+
1124
+ schema = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}
1125
+ assert_that({"name": "Alice"}).matches_json_schema(schema)
1126
+
1127
+ Returns:
1128
+ AssertionBuilder: returns this instance to chain to the next assertion
1129
+
1130
+ Raises:
1131
+ AssertionError: if val does not conform to the schema
1132
+ """
1133
+ jsonschema_mod = _ensure_jsonschema()
1134
+ try:
1135
+ require_type(
1136
+ schema,
1137
+ (dict, bool),
1138
+ "a JSON Schema (a dict, or a bool for the trivial schema)",
1139
+ subject=argument("schema"),
1140
+ )
1141
+ jsonschema_mod.validate(self.val, schema)
1142
+ except jsonschema_mod.ValidationError as exc:
1143
+ # the path is the only part of jsonschema's text the message does not already have
1144
+ return self.error(
1145
+ f"Expected val to match JSON schema, but validation failed at {exc.json_path}: {exc.message}",
1146
+ suppress_context=True,
1147
+ expected=schema,
1148
+ )
1149
+ return self
1150
+
1151
+ def matches_json_schema_from_file(self, path: str | Path) -> Self:
1152
+ """Assert that val conforms to a JSON Schema loaded from a file.
1153
+
1154
+ Args:
1155
+ path: path to a JSON file containing the schema.
1156
+
1157
+ Examples:
1158
+ Usage:
1159
+
1160
+ assert_that(data).matches_json_schema_from_file("schemas/order.json")
1161
+
1162
+ Returns:
1163
+ AssertionBuilder: returns this instance to chain to the next assertion
1164
+
1165
+ Raises:
1166
+ AssertionError: if val does not conform to the schema
1167
+ """
1168
+ require_type(path, (str, PurePath), "a path", subject=argument("path"))
1169
+ schema = json.loads(Path(path).read_text(encoding="utf-8"))
1170
+ return self.matches_json_schema(schema)
1171
+
1172
+ def conforms_to_openapi(
1173
+ self,
1174
+ spec: dict[str, Any],
1175
+ path: str,
1176
+ method: str,
1177
+ *,
1178
+ status: str | int | None = None,
1179
+ content_type: str = "application/json",
1180
+ strict_nullable: bool = False,
1181
+ ) -> Self:
1182
+ """Assert that val conforms to an OpenAPI operation's response-body schema.
1183
+
1184
+ val is validated against the schema declared for the ``application/json`` response of the
1185
+ ``method``/``path`` operation in ``spec``. This checks only the response body of that one
1186
+ operation - not request bodies, parameters, headers, or the spec as a whole.
1187
+
1188
+ OpenAPI 3.0 (its ``nullable`` keyword is honoured), 3.1, and Swagger 2.0 (schema declared directly
1189
+ on the response, its ``x-nullable`` extension honoured) are all supported. ``$ref``,
1190
+ ``oneOf``/``allOf``/``anyOf`` and ``enum`` all validate with full JSON-Schema semantics, and every
1191
+ violation is reported with its JSON path.
1192
+
1193
+ Under OpenAPI 3.0, ``nullable: true`` adds ``null`` to the ``type`` beside it and leaves an ``enum``
1194
+ as written, so ``null`` passes an enum only where the enum lists it. A property marked
1195
+ ``writeOnly: true`` is not asked for by ``required``, since val is a response.
1196
+
1197
+ A ``multipleOf`` divides the numbers as they are written, so ``19.99`` is a multiple of ``0.01``,
1198
+ which it is not as two floats divided. A float is read as the decimal it prints as: digits a float
1199
+ does not keep are gone before val is handed in, and a `decimal.Decimal` keeps them.
1200
+
1201
+ A ``format`` is checked where there is a check for it: ``date``, ``time``, ``date-time``, ``email``,
1202
+ ``ipv4``, ``ipv6``, ``uuid``, ``regex``, ``uri``, ``hostname``, ``duration``, and OpenAPI's ``int32``
1203
+ and ``int64``. A ``time`` is RFC 3339's, with its offset: ``10:00:00Z``, and not ``10:00:00``. A
1204
+ ``hostname`` under OpenAPI 3.1 has its ``xn--`` labels held to IDNA by the ``idna`` package. An
1205
+ ``email`` is the mailbox of RFC 5321, and a ``duration`` has no fraction: ``PT0.5S`` is none. The
1206
+ others jsonschema checks only beside a package of their own, ``uri-reference``,
1207
+ ``iri``, ``json-pointer`` and ``uri-template`` among them, are checked where that package is
1208
+ installed (``jsonschema[format-nongpl]`` brings them all), and pass unchecked where it is not.
1209
+
1210
+ Args:
1211
+ spec: a parsed OpenAPI document (dict); loading YAML/JSON is the caller's job.
1212
+ path: the operation's path template, e.g. ``"/orders/{id}"``.
1213
+ method: the HTTP method, e.g. ``"get"`` (case-insensitive).
1214
+ status: response status to validate against; defaults to ``200``, then ``201``, then the range
1215
+ ``2XX``, then ``default``. A status is looked up by its own code, then by its range
1216
+ (``404`` under ``4XX``), then under ``default``. Swagger 2.0 has no ranges.
1217
+ content_type: response content type; defaults to ``"application/json"``. Swagger 2.0 has no
1218
+ content-type layer, so it is checked against the operation's ``produces`` list instead
1219
+ (and skipped when the spec declares none).
1220
+ strict_nullable: hold OpenAPI 3.0's ``nullable`` to its text, where it adds ``null`` to the
1221
+ ``type`` beside it and does nothing with no ``type`` there. By default ``nullable: true``
1222
+ beside a ``$ref``, an ``allOf``, an ``anyOf`` or a ``oneOf`` allows ``null`` as well, which is
1223
+ how a nullable reference is usually written. Swagger 2.0's ``x-nullable`` and OpenAPI 3.1 are
1224
+ read the same either way.
1225
+
1226
+ Examples:
1227
+ Usage:
1228
+
1229
+ spec = {...} # your parsed OpenAPI document
1230
+ assert_that(response.json()).conforms_to_openapi(spec, "/orders/{id}", "get")
1231
+
1232
+ Returns:
1233
+ AssertionBuilder: returns this instance to chain to the next assertion
1234
+
1235
+ Raises:
1236
+ AssertionError: if val does not conform to the response schema
1237
+ ValueError: if the operation, status, or content type is not found in the spec, a schema of
1238
+ OpenAPI 3.0 or Swagger 2.0 holds a keyword of later JSON Schema, which would be passed over,
1239
+ or a ``multipleOf`` that val is held to is no number above zero
1240
+ """
1241
+ part = _part_read(spec, path, method)
1242
+ over = None
1243
+ if part is not None:
1244
+ over = _validator_over(
1245
+ part, path, method, status, content_type, whole=False, strict_nullable=strict_nullable
1246
+ )
1247
+ if over is None:
1248
+ # YAML may parse numeric-looking keys (e.g. status 200) as ints, so the copy has them as text
1249
+ over = _validator_over(
1250
+ _stringify_keys(spec), path, method, status, content_type, whole=True, strict_nullable=strict_nullable
1251
+ )
1252
+ status_key, validator = cast("tuple[str, Any]", over)
1253
+ errors = sorted(validator.iter_errors(self.val), key=lambda error: (error.json_path, str(error.validator)))
1254
+ if not errors:
1255
+ return self
1256
+ entries = [
1257
+ DiffEntry(path=error.json_path, actual=error.instance, expected=_openapi_expected(error))
1258
+ for error in errors
1259
+ ]
1260
+ plural = "" if len(entries) == 1 else "s"
1261
+ return self.error(
1262
+ f"Expected the value to conform to the OpenAPI schema for <{method.upper()} {path}> response"
1263
+ f" <{status_key}>, but found {len(entries)} violation{plural}.",
1264
+ diff=DiffResult(kind="openapi", entries=entries),
1265
+ )