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.
- assertpy/__init__.py +87 -0
- assertpy/_clustering.py +511 -0
- assertpy/_dangling.py +505 -0
- assertpy/_engine/__init__.py +1 -0
- assertpy/_engine/_builder_check_typing.py +1365 -0
- assertpy/_engine/_capable_typing.py +772 -0
- assertpy/_engine/_check_typing.py +753 -0
- assertpy/_engine/_compare.py +983 -0
- assertpy/_engine/_compat.py +38 -0
- assertpy/_engine/_contract.py +1926 -0
- assertpy/_engine/_diff.py +1058 -0
- assertpy/_engine/_equality.py +843 -0
- assertpy/_engine/_introspection.py +729 -0
- assertpy/_engine/_membership.py +522 -0
- assertpy/_engine/_mixin_base.py +258 -0
- assertpy/_engine/_negated_typing.py +2176 -0
- assertpy/_engine/_operations.py +75 -0
- assertpy/_engine/_ordering.py +884 -0
- assertpy/_engine/_pairing.py +70 -0
- assertpy/_engine/_path.py +158 -0
- assertpy/_engine/_poll_typing.py +6039 -0
- assertpy/_engine/_require.py +312 -0
- assertpy/_engine/_size.py +31 -0
- assertpy/_engine/_text.py +39 -0
- assertpy/_engine/_typing.py +1226 -0
- assertpy/_hints.py +1213 -0
- assertpy/_inline.py +484 -0
- assertpy/_matcher_impls.py +1876 -0
- assertpy/_passes.py +249 -0
- assertpy/_renamed.py +64 -0
- assertpy/_satisfies.py +835 -0
- assertpy/_snapshot_codec.py +290 -0
- assertpy/assertpy.py +2640 -0
- assertpy/async_assertions.py +864 -0
- assertpy/base.py +862 -0
- assertpy/behave_matchers.py +96 -0
- assertpy/bytes_mixin.py +158 -0
- assertpy/collection.py +528 -0
- assertpy/contains.py +1068 -0
- assertpy/dataframe.py +200 -0
- assertpy/date.py +327 -0
- assertpy/dict.py +249 -0
- assertpy/dynamic.py +222 -0
- assertpy/errors.py +782 -0
- assertpy/exception.py +838 -0
- assertpy/extracting.py +266 -0
- assertpy/file.py +324 -0
- assertpy/helpers.py +747 -0
- assertpy/http_mixin.py +218 -0
- assertpy/json_mixin.py +1265 -0
- assertpy/matchers.py +639 -0
- assertpy/numeric.py +696 -0
- assertpy/outcome.py +163 -0
- assertpy/py.typed +0 -0
- assertpy/pytest_plugin.py +1650 -0
- assertpy/snapshot.py +1031 -0
- assertpy/string.py +803 -0
- assertpy/warning.py +137 -0
- assertpy-2.35.0.dist-info/METADATA +324 -0
- assertpy-2.35.0.dist-info/RECORD +63 -0
- assertpy-2.35.0.dist-info/WHEEL +4 -0
- assertpy-2.35.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|