schemathesis 3.37.1__py3-none-any.whl → 3.38.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.
- schemathesis/_hypothesis.py +18 -8
- schemathesis/_patches.py +21 -0
- schemathesis/cli/__init__.py +1 -1
- schemathesis/cli/cassettes.py +6 -0
- schemathesis/extra/pytest_plugin.py +1 -1
- schemathesis/generation/_hypothesis.py +2 -0
- schemathesis/generation/coverage.py +257 -59
- schemathesis/internal/checks.py +4 -2
- schemathesis/internal/diff.py +15 -0
- schemathesis/models.py +55 -3
- schemathesis/runner/impl/context.py +5 -1
- schemathesis/runner/impl/core.py +14 -4
- schemathesis/runner/serialization.py +6 -3
- schemathesis/serializers.py +3 -0
- schemathesis/service/extensions.py +1 -1
- schemathesis/service/metadata.py +3 -3
- schemathesis/specs/openapi/_hypothesis.py +7 -46
- schemathesis/specs/openapi/checks.py +7 -2
- schemathesis/specs/openapi/converter.py +27 -11
- schemathesis/specs/openapi/formats.py +44 -0
- schemathesis/specs/openapi/negative/mutations.py +5 -0
- schemathesis/specs/openapi/parameters.py +16 -14
- schemathesis/specs/openapi/schemas.py +6 -2
- schemathesis/stateful/context.py +1 -1
- schemathesis/stateful/runner.py +6 -2
- schemathesis/utils.py +6 -4
- {schemathesis-3.37.1.dist-info → schemathesis-3.38.0.dist-info}/METADATA +2 -1
- {schemathesis-3.37.1.dist-info → schemathesis-3.38.0.dist-info}/RECORD +31 -29
- {schemathesis-3.37.1.dist-info → schemathesis-3.38.0.dist-info}/WHEEL +0 -0
- {schemathesis-3.37.1.dist-info → schemathesis-3.38.0.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.37.1.dist-info → schemathesis-3.38.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import functools
|
|
4
|
-
import json
|
|
5
4
|
import re
|
|
6
5
|
from contextlib import contextmanager, suppress
|
|
7
6
|
from dataclasses import dataclass
|
|
8
7
|
from functools import lru_cache, partial
|
|
9
8
|
from itertools import combinations
|
|
10
|
-
from
|
|
9
|
+
from json.encoder import _make_iterencode, c_make_encoder, encode_basestring_ascii # type: ignore
|
|
10
|
+
from typing import Any, Callable, Generator, Iterator, TypeVar, cast
|
|
11
11
|
|
|
12
12
|
import jsonschema
|
|
13
13
|
from hypothesis import strategies as st
|
|
14
14
|
from hypothesis.errors import InvalidArgument, Unsatisfiable
|
|
15
15
|
from hypothesis_jsonschema import from_schema
|
|
16
16
|
from hypothesis_jsonschema._canonicalise import canonicalish
|
|
17
|
+
from hypothesis_jsonschema._from_schema import STRING_FORMATS as BUILT_IN_STRING_FORMATS
|
|
17
18
|
|
|
18
19
|
from ..constants import NOT_SET
|
|
20
|
+
from ..internal.copy import fast_deepcopy
|
|
21
|
+
from ..specs.openapi.converter import update_pattern_in_schema
|
|
22
|
+
from ..specs.openapi.formats import STRING_FORMATS, get_default_format_strategies
|
|
19
23
|
from ..specs.openapi.patterns import update_quantifier
|
|
20
24
|
from ._hypothesis import get_single_example
|
|
21
25
|
from ._methods import DataGenerationMethod
|
|
@@ -38,6 +42,18 @@ JSON_STRATEGY: st.SearchStrategy = st.recursive(
|
|
|
38
42
|
ARRAY_STRATEGY: st.SearchStrategy = st.lists(JSON_STRATEGY)
|
|
39
43
|
OBJECT_STRATEGY: st.SearchStrategy = st.dictionaries(st.text(), JSON_STRATEGY)
|
|
40
44
|
|
|
45
|
+
|
|
46
|
+
STRATEGIES_FOR_TYPE = {
|
|
47
|
+
"integer": st.integers(),
|
|
48
|
+
"number": NUMERIC_STRATEGY,
|
|
49
|
+
"boolean": st.booleans(),
|
|
50
|
+
"null": st.none(),
|
|
51
|
+
"string": st.text(),
|
|
52
|
+
"array": ARRAY_STRATEGY,
|
|
53
|
+
"object": OBJECT_STRATEGY,
|
|
54
|
+
}
|
|
55
|
+
FORMAT_STRATEGIES = {**BUILT_IN_STRING_FORMATS, **get_default_format_strategies(), **STRING_FORMATS}
|
|
56
|
+
|
|
41
57
|
UNKNOWN_PROPERTY_KEY = "x-schemathesis-unknown-property"
|
|
42
58
|
UNKNOWN_PROPERTY_VALUE = 42
|
|
43
59
|
|
|
@@ -47,16 +63,24 @@ class GeneratedValue:
|
|
|
47
63
|
value: Any
|
|
48
64
|
data_generation_method: DataGenerationMethod
|
|
49
65
|
description: str
|
|
66
|
+
location: str | None
|
|
50
67
|
|
|
51
|
-
__slots__ = ("value", "data_generation_method", "description")
|
|
68
|
+
__slots__ = ("value", "data_generation_method", "description", "location")
|
|
52
69
|
|
|
53
70
|
@classmethod
|
|
54
71
|
def with_positive(cls, value: Any, *, description: str) -> GeneratedValue:
|
|
55
|
-
return cls(
|
|
72
|
+
return cls(
|
|
73
|
+
value=value, data_generation_method=DataGenerationMethod.positive, description=description, location=None
|
|
74
|
+
)
|
|
56
75
|
|
|
57
76
|
@classmethod
|
|
58
|
-
def with_negative(cls, value: Any, *, description: str) -> GeneratedValue:
|
|
59
|
-
return cls(
|
|
77
|
+
def with_negative(cls, value: Any, *, description: str, location: str) -> GeneratedValue:
|
|
78
|
+
return cls(
|
|
79
|
+
value=value,
|
|
80
|
+
data_generation_method=DataGenerationMethod.negative,
|
|
81
|
+
description=description,
|
|
82
|
+
location=location,
|
|
83
|
+
)
|
|
60
84
|
|
|
61
85
|
|
|
62
86
|
PositiveValue = GeneratedValue.with_positive
|
|
@@ -71,35 +95,132 @@ def cached_draw(strategy: st.SearchStrategy) -> Any:
|
|
|
71
95
|
@dataclass
|
|
72
96
|
class CoverageContext:
|
|
73
97
|
data_generation_methods: list[DataGenerationMethod]
|
|
98
|
+
location_stack: list[str | int]
|
|
74
99
|
|
|
75
|
-
__slots__ = ("data_generation_methods",)
|
|
100
|
+
__slots__ = ("data_generation_methods", "location_stack")
|
|
76
101
|
|
|
77
|
-
def __init__(
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
data_generation_methods: list[DataGenerationMethod] | None = None,
|
|
105
|
+
location_stack: list[str | int] | None = None,
|
|
106
|
+
) -> None:
|
|
78
107
|
self.data_generation_methods = (
|
|
79
108
|
data_generation_methods if data_generation_methods is not None else DataGenerationMethod.all()
|
|
80
109
|
)
|
|
110
|
+
self.location_stack = location_stack or []
|
|
111
|
+
|
|
112
|
+
@contextmanager
|
|
113
|
+
def location(self, key: str | int) -> Generator[None, None, None]:
|
|
114
|
+
self.location_stack.append(key)
|
|
115
|
+
try:
|
|
116
|
+
yield
|
|
117
|
+
finally:
|
|
118
|
+
self.location_stack.pop()
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def current_location(self) -> str:
|
|
122
|
+
return "/" + "/".join(str(key) for key in self.location_stack)
|
|
123
|
+
|
|
124
|
+
def with_positive(self) -> CoverageContext:
|
|
125
|
+
return CoverageContext(
|
|
126
|
+
data_generation_methods=[DataGenerationMethod.positive], location_stack=self.location_stack
|
|
127
|
+
)
|
|
81
128
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
@classmethod
|
|
87
|
-
def with_negative(cls) -> CoverageContext:
|
|
88
|
-
return CoverageContext(data_generation_methods=[DataGenerationMethod.negative])
|
|
129
|
+
def with_negative(self) -> CoverageContext:
|
|
130
|
+
return CoverageContext(
|
|
131
|
+
data_generation_methods=[DataGenerationMethod.negative], location_stack=self.location_stack
|
|
132
|
+
)
|
|
89
133
|
|
|
90
134
|
def generate_from(self, strategy: st.SearchStrategy) -> Any:
|
|
91
135
|
return cached_draw(strategy)
|
|
92
136
|
|
|
93
|
-
def generate_from_schema(self, schema: dict) -> Any:
|
|
137
|
+
def generate_from_schema(self, schema: dict | bool) -> Any:
|
|
138
|
+
if isinstance(schema, bool):
|
|
139
|
+
return 0
|
|
140
|
+
keys = sorted([k for k in schema if not k.startswith("x-") and k not in ["description", "example"]])
|
|
141
|
+
if keys == ["type"] and isinstance(schema["type"], str) and schema["type"] in STRATEGIES_FOR_TYPE:
|
|
142
|
+
return cached_draw(STRATEGIES_FOR_TYPE[schema["type"]])
|
|
143
|
+
if keys == ["format", "type"]:
|
|
144
|
+
if schema["type"] != "string":
|
|
145
|
+
return cached_draw(STRATEGIES_FOR_TYPE[schema["type"]])
|
|
146
|
+
elif schema["format"] in FORMAT_STRATEGIES:
|
|
147
|
+
return cached_draw(FORMAT_STRATEGIES[schema["format"]])
|
|
148
|
+
if (keys == ["maxLength", "minLength", "type"] or keys == ["maxLength", "type"]) and schema["type"] == "string":
|
|
149
|
+
return cached_draw(st.text(min_size=schema.get("minLength", 0), max_size=schema["maxLength"]))
|
|
150
|
+
if (
|
|
151
|
+
keys == ["properties", "required", "type"]
|
|
152
|
+
or keys == ["properties", "required"]
|
|
153
|
+
or keys == ["properties", "type"]
|
|
154
|
+
or keys == ["properties"]
|
|
155
|
+
):
|
|
156
|
+
obj = {}
|
|
157
|
+
for key, sub_schema in schema["properties"].items():
|
|
158
|
+
if isinstance(sub_schema, dict) and "const" in sub_schema:
|
|
159
|
+
obj[key] = sub_schema["const"]
|
|
160
|
+
else:
|
|
161
|
+
obj[key] = self.generate_from_schema(sub_schema)
|
|
162
|
+
return obj
|
|
163
|
+
if (
|
|
164
|
+
keys == ["maximum", "minimum", "type"] or keys == ["maximum", "type"] or keys == ["minimum", "type"]
|
|
165
|
+
) and schema["type"] == "integer":
|
|
166
|
+
return cached_draw(st.integers(min_value=schema.get("minimum"), max_value=schema.get("maximum")))
|
|
167
|
+
if "enum" in schema:
|
|
168
|
+
return cached_draw(st.sampled_from(schema["enum"]))
|
|
169
|
+
if "pattern" in schema:
|
|
170
|
+
pattern = schema["pattern"]
|
|
171
|
+
try:
|
|
172
|
+
re.compile(pattern)
|
|
173
|
+
except re.error:
|
|
174
|
+
raise Unsatisfiable from None
|
|
175
|
+
return cached_draw(st.from_regex(pattern))
|
|
176
|
+
if (keys == ["items", "type"] or keys == ["items", "minItems", "type"]) and isinstance(schema["items"], dict):
|
|
177
|
+
items = schema["items"]
|
|
178
|
+
min_items = schema.get("minItems", 0)
|
|
179
|
+
if "enum" in items:
|
|
180
|
+
return cached_draw(st.lists(st.sampled_from(items["enum"]), min_size=min_items))
|
|
181
|
+
sub_keys = sorted([k for k in items if not k.startswith("x-") and k not in ["description", "example"]])
|
|
182
|
+
if sub_keys == ["type"] and items["type"] == "string":
|
|
183
|
+
return cached_draw(st.lists(st.text(), min_size=min_items))
|
|
184
|
+
if (
|
|
185
|
+
sub_keys == ["properties", "required", "type"]
|
|
186
|
+
or sub_keys == ["properties", "type"]
|
|
187
|
+
or sub_keys == ["properties"]
|
|
188
|
+
):
|
|
189
|
+
return cached_draw(
|
|
190
|
+
st.lists(
|
|
191
|
+
st.fixed_dictionaries(
|
|
192
|
+
{key: from_schema(sub_schema) for key, sub_schema in items["properties"].items()}
|
|
193
|
+
),
|
|
194
|
+
min_size=min_items,
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if keys == ["allOf"]:
|
|
199
|
+
schema = canonicalish(schema)
|
|
200
|
+
if isinstance(schema, dict) and "allOf" not in schema:
|
|
201
|
+
return self.generate_from_schema(schema)
|
|
202
|
+
|
|
94
203
|
return self.generate_from(from_schema(schema))
|
|
95
204
|
|
|
96
205
|
|
|
97
206
|
T = TypeVar("T")
|
|
98
207
|
|
|
99
208
|
|
|
100
|
-
|
|
209
|
+
if c_make_encoder is not None:
|
|
210
|
+
_iterencode = c_make_encoder(None, None, encode_basestring_ascii, None, ":", ",", True, False, False)
|
|
211
|
+
else:
|
|
212
|
+
_iterencode = _make_iterencode(
|
|
213
|
+
None, None, encode_basestring_ascii, None, float.__repr__, ":", ",", True, False, True
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _encode(o: Any) -> str:
|
|
218
|
+
return "".join(_iterencode(o, 0))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _to_hashable_key(value: T, _encode: Callable = _encode) -> T | tuple[type, str]:
|
|
101
222
|
if isinstance(value, (dict, list)):
|
|
102
|
-
serialized =
|
|
223
|
+
serialized = _encode(value)
|
|
103
224
|
return (type(value), serialized)
|
|
104
225
|
return value
|
|
105
226
|
|
|
@@ -193,7 +314,7 @@ def cover_schema_iter(
|
|
|
193
314
|
if DataGenerationMethod.negative in ctx.data_generation_methods:
|
|
194
315
|
template = None
|
|
195
316
|
for key, value in schema.items():
|
|
196
|
-
with _ignore_unfixable():
|
|
317
|
+
with _ignore_unfixable(), ctx.location(key):
|
|
197
318
|
if key == "enum":
|
|
198
319
|
yield from _negative_enum(ctx, value)
|
|
199
320
|
elif key == "const":
|
|
@@ -207,24 +328,35 @@ def cover_schema_iter(
|
|
|
207
328
|
elif key == "properties":
|
|
208
329
|
template = template or ctx.generate_from_schema(_get_template_schema(schema, "object"))
|
|
209
330
|
yield from _negative_properties(ctx, template, value)
|
|
331
|
+
elif key == "patternProperties":
|
|
332
|
+
template = template or ctx.generate_from_schema(_get_template_schema(schema, "object"))
|
|
333
|
+
yield from _negative_pattern_properties(ctx, template, value)
|
|
334
|
+
elif key == "items" and isinstance(value, dict):
|
|
335
|
+
yield from _negative_items(ctx, value)
|
|
210
336
|
elif key == "pattern":
|
|
211
|
-
|
|
337
|
+
min_length = schema.get("minLength")
|
|
338
|
+
max_length = schema.get("maxLength")
|
|
339
|
+
yield from _negative_pattern(ctx, value, min_length=min_length, max_length=max_length)
|
|
212
340
|
elif key == "format" and ("string" in types or not types):
|
|
213
341
|
yield from _negative_format(ctx, schema, value)
|
|
214
342
|
elif key == "maximum":
|
|
215
343
|
next = value + 1
|
|
216
344
|
if next not in seen:
|
|
217
|
-
yield NegativeValue(
|
|
345
|
+
yield NegativeValue(
|
|
346
|
+
next, description="Value greater than maximum", location=ctx.current_location
|
|
347
|
+
)
|
|
218
348
|
seen.add(next)
|
|
219
349
|
elif key == "minimum":
|
|
220
350
|
next = value - 1
|
|
221
351
|
if next not in seen:
|
|
222
|
-
yield NegativeValue(
|
|
352
|
+
yield NegativeValue(
|
|
353
|
+
next, description="Value smaller than minimum", location=ctx.current_location
|
|
354
|
+
)
|
|
223
355
|
seen.add(next)
|
|
224
356
|
elif key == "exclusiveMaximum" or key == "exclusiveMinimum" and value not in seen:
|
|
225
357
|
verb = "greater" if key == "exclusiveMaximum" else "smaller"
|
|
226
358
|
limit = "maximum" if key == "exclusiveMaximum" else "minimum"
|
|
227
|
-
yield NegativeValue(value, description=f"Value {verb} than {limit}")
|
|
359
|
+
yield NegativeValue(value, description=f"Value {verb} than {limit}", location=ctx.current_location)
|
|
228
360
|
seen.add(value)
|
|
229
361
|
elif key == "multipleOf":
|
|
230
362
|
for value_ in _negative_multiple_of(ctx, schema, value):
|
|
@@ -236,35 +368,59 @@ def cover_schema_iter(
|
|
|
236
368
|
with suppress(InvalidArgument):
|
|
237
369
|
min_length = max_length = value - 1
|
|
238
370
|
new_schema = {**schema, "minLength": min_length, "maxLength": max_length}
|
|
371
|
+
new_schema.setdefault("type", "string")
|
|
239
372
|
if "pattern" in new_schema:
|
|
240
373
|
new_schema["pattern"] = update_quantifier(schema["pattern"], min_length, max_length)
|
|
241
|
-
|
|
374
|
+
if new_schema["pattern"] == schema["pattern"]:
|
|
375
|
+
# Pattern wasn't updated, try to generate a valid value then shrink the string to the required length
|
|
376
|
+
del new_schema["minLength"]
|
|
377
|
+
del new_schema["maxLength"]
|
|
378
|
+
value = ctx.generate_from_schema(new_schema)[:max_length]
|
|
379
|
+
else:
|
|
380
|
+
value = ctx.generate_from_schema(new_schema)
|
|
381
|
+
else:
|
|
382
|
+
value = ctx.generate_from_schema(new_schema)
|
|
242
383
|
k = _to_hashable_key(value)
|
|
243
384
|
if k not in seen:
|
|
244
|
-
yield NegativeValue(
|
|
385
|
+
yield NegativeValue(
|
|
386
|
+
value, description="String smaller than minLength", location=ctx.current_location
|
|
387
|
+
)
|
|
245
388
|
seen.add(k)
|
|
246
389
|
elif key == "maxLength" and value < BUFFER_SIZE:
|
|
247
|
-
|
|
248
|
-
min_length = value + 1
|
|
249
|
-
max_length = value + 1
|
|
390
|
+
try:
|
|
391
|
+
min_length = max_length = value + 1
|
|
250
392
|
new_schema = {**schema, "minLength": min_length, "maxLength": max_length}
|
|
393
|
+
new_schema.setdefault("type", "string")
|
|
251
394
|
if "pattern" in new_schema:
|
|
252
395
|
new_schema["pattern"] = update_quantifier(schema["pattern"], min_length, max_length)
|
|
253
|
-
|
|
396
|
+
if new_schema["pattern"] == schema["pattern"]:
|
|
397
|
+
# Pattern wasn't updated, try to generate a valid value then extend the string to the required length
|
|
398
|
+
del new_schema["minLength"]
|
|
399
|
+
del new_schema["maxLength"]
|
|
400
|
+
value = ctx.generate_from_schema(new_schema).ljust(max_length, "0")
|
|
401
|
+
else:
|
|
402
|
+
value = ctx.generate_from_schema(new_schema)
|
|
403
|
+
else:
|
|
404
|
+
value = ctx.generate_from_schema(new_schema)
|
|
254
405
|
k = _to_hashable_key(value)
|
|
255
406
|
if k not in seen:
|
|
256
|
-
yield NegativeValue(
|
|
407
|
+
yield NegativeValue(
|
|
408
|
+
value, description="String larger than maxLength", location=ctx.current_location
|
|
409
|
+
)
|
|
257
410
|
seen.add(k)
|
|
411
|
+
except (InvalidArgument, Unsatisfiable):
|
|
412
|
+
pass
|
|
258
413
|
elif key == "uniqueItems" and value:
|
|
259
414
|
yield from _negative_unique_items(ctx, schema)
|
|
260
415
|
elif key == "required":
|
|
261
416
|
template = template or ctx.generate_from_schema(_get_template_schema(schema, "object"))
|
|
262
417
|
yield from _negative_required(ctx, template, value)
|
|
263
|
-
elif key == "additionalProperties" and not value:
|
|
418
|
+
elif key == "additionalProperties" and not value and "pattern" not in schema:
|
|
264
419
|
template = template or ctx.generate_from_schema(_get_template_schema(schema, "object"))
|
|
265
420
|
yield NegativeValue(
|
|
266
421
|
{**template, UNKNOWN_PROPERTY_KEY: UNKNOWN_PROPERTY_VALUE},
|
|
267
422
|
description="Object with unexpected properties",
|
|
423
|
+
location=ctx.current_location,
|
|
268
424
|
)
|
|
269
425
|
elif key == "allOf":
|
|
270
426
|
nctx = ctx.with_negative()
|
|
@@ -277,8 +433,9 @@ def cover_schema_iter(
|
|
|
277
433
|
elif key == "anyOf" or key == "oneOf":
|
|
278
434
|
nctx = ctx.with_negative()
|
|
279
435
|
# NOTE: Other sub-schemas are not filtered out
|
|
280
|
-
for sub_schema in value:
|
|
281
|
-
|
|
436
|
+
for idx, sub_schema in enumerate(value):
|
|
437
|
+
with nctx.location(idx):
|
|
438
|
+
yield from cover_schema_iter(nctx, sub_schema, seen)
|
|
282
439
|
|
|
283
440
|
|
|
284
441
|
def _get_properties(schema: dict | bool) -> dict | bool:
|
|
@@ -291,6 +448,9 @@ def _get_properties(schema: dict | bool) -> dict | bool:
|
|
|
291
448
|
return {"enum": schema["examples"]}
|
|
292
449
|
if schema.get("type") == "object":
|
|
293
450
|
return _get_template_schema(schema, "object")
|
|
451
|
+
_schema = fast_deepcopy(schema)
|
|
452
|
+
update_pattern_in_schema(_schema)
|
|
453
|
+
return _schema
|
|
294
454
|
return schema
|
|
295
455
|
|
|
296
456
|
|
|
@@ -581,7 +741,7 @@ def _negative_enum(ctx: CoverageContext, value: list) -> Generator[GeneratedValu
|
|
|
581
741
|
|
|
582
742
|
strategy = JSON_STRATEGY.filter(is_not_in_value)
|
|
583
743
|
# The exact negative value is not important here
|
|
584
|
-
yield NegativeValue(ctx.generate_from(strategy), description="Invalid enum value")
|
|
744
|
+
yield NegativeValue(ctx.generate_from(strategy), description="Invalid enum value", location=ctx.current_location)
|
|
585
745
|
|
|
586
746
|
|
|
587
747
|
def _negative_properties(
|
|
@@ -589,21 +749,63 @@ def _negative_properties(
|
|
|
589
749
|
) -> Generator[GeneratedValue, None, None]:
|
|
590
750
|
nctx = ctx.with_negative()
|
|
591
751
|
for key, sub_schema in properties.items():
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
752
|
+
with nctx.location(key):
|
|
753
|
+
for value in cover_schema_iter(nctx, sub_schema):
|
|
754
|
+
yield NegativeValue(
|
|
755
|
+
{**template, key: value.value},
|
|
756
|
+
description=f"Object with invalid '{key}' value: {value.description}",
|
|
757
|
+
location=nctx.current_location,
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _negative_pattern_properties(
|
|
762
|
+
ctx: CoverageContext, template: dict, pattern_properties: dict
|
|
763
|
+
) -> Generator[GeneratedValue, None, None]:
|
|
764
|
+
nctx = ctx.with_negative()
|
|
765
|
+
for pattern, sub_schema in pattern_properties.items():
|
|
766
|
+
try:
|
|
767
|
+
key = ctx.generate_from(st.from_regex(pattern))
|
|
768
|
+
except re.error:
|
|
769
|
+
continue
|
|
770
|
+
with nctx.location(pattern):
|
|
771
|
+
for value in cover_schema_iter(nctx, sub_schema):
|
|
772
|
+
yield NegativeValue(
|
|
773
|
+
{**template, key: value.value},
|
|
774
|
+
description=f"Object with invalid pattern key '{key}' ('{pattern}') value: {value.description}",
|
|
775
|
+
location=nctx.current_location,
|
|
776
|
+
)
|
|
777
|
+
|
|
597
778
|
|
|
779
|
+
def _negative_items(ctx: CoverageContext, schema: dict[str, Any] | bool) -> Generator[GeneratedValue, None, None]:
|
|
780
|
+
"""Arrays not matching the schema."""
|
|
781
|
+
nctx = ctx.with_negative()
|
|
782
|
+
for value in cover_schema_iter(nctx, schema):
|
|
783
|
+
yield NegativeValue(
|
|
784
|
+
[value.value],
|
|
785
|
+
description=f"Array with invalid items: {value.description}",
|
|
786
|
+
location=nctx.current_location,
|
|
787
|
+
)
|
|
598
788
|
|
|
599
|
-
def _not_matching_pattern(value: str, pattern: str) -> bool:
|
|
600
|
-
return re.search(pattern, value) is None
|
|
601
789
|
|
|
790
|
+
def _not_matching_pattern(value: str, pattern: re.Pattern) -> bool:
|
|
791
|
+
return pattern.search(value) is None
|
|
602
792
|
|
|
603
|
-
|
|
793
|
+
|
|
794
|
+
def _negative_pattern(
|
|
795
|
+
ctx: CoverageContext, pattern: str, min_length: int | None = None, max_length: int | None = None
|
|
796
|
+
) -> Generator[GeneratedValue, None, None]:
|
|
797
|
+
try:
|
|
798
|
+
compiled = re.compile(pattern)
|
|
799
|
+
except re.error:
|
|
800
|
+
return
|
|
604
801
|
yield NegativeValue(
|
|
605
|
-
ctx.generate_from(
|
|
802
|
+
ctx.generate_from(
|
|
803
|
+
st.text(min_size=min_length or 0, max_size=max_length).filter(
|
|
804
|
+
partial(_not_matching_pattern, pattern=compiled)
|
|
805
|
+
)
|
|
806
|
+
),
|
|
606
807
|
description=f"Value not matching the '{pattern}' pattern",
|
|
808
|
+
location=ctx.current_location,
|
|
607
809
|
)
|
|
608
810
|
|
|
609
811
|
|
|
@@ -617,12 +819,13 @@ def _negative_multiple_of(
|
|
|
617
819
|
yield NegativeValue(
|
|
618
820
|
ctx.generate_from_schema(_with_negated_key(schema, "multipleOf", multiple_of)),
|
|
619
821
|
description=f"Non-multiple of {multiple_of}",
|
|
822
|
+
location=ctx.current_location,
|
|
620
823
|
)
|
|
621
824
|
|
|
622
825
|
|
|
623
826
|
def _negative_unique_items(ctx: CoverageContext, schema: dict) -> Generator[GeneratedValue, None, None]:
|
|
624
827
|
unique = ctx.generate_from_schema({**schema, "type": "array", "minItems": 1, "maxItems": 1})
|
|
625
|
-
yield NegativeValue(unique + unique, description="Non-unique items")
|
|
828
|
+
yield NegativeValue(unique + unique, description="Non-unique items", location=ctx.current_location)
|
|
626
829
|
|
|
627
830
|
|
|
628
831
|
def _negative_required(
|
|
@@ -632,6 +835,7 @@ def _negative_required(
|
|
|
632
835
|
yield NegativeValue(
|
|
633
836
|
{k: v for k, v in template.items() if k != key},
|
|
634
837
|
description=f"Missing required property: {key}",
|
|
838
|
+
location=ctx.current_location,
|
|
635
839
|
)
|
|
636
840
|
|
|
637
841
|
|
|
@@ -653,7 +857,11 @@ def _negative_format(ctx: CoverageContext, schema: dict, format: str) -> Generat
|
|
|
653
857
|
strategy = strategy.filter(_is_invalid_hostname)
|
|
654
858
|
else:
|
|
655
859
|
strategy = strategy.filter(functools.partial(_is_invalid_format, format=format))
|
|
656
|
-
yield NegativeValue(
|
|
860
|
+
yield NegativeValue(
|
|
861
|
+
ctx.generate_from(strategy),
|
|
862
|
+
description=f"Value not matching the '{format}' format",
|
|
863
|
+
location=ctx.current_location,
|
|
864
|
+
)
|
|
657
865
|
|
|
658
866
|
|
|
659
867
|
def _is_non_integer_float(x: float) -> bool:
|
|
@@ -661,31 +869,21 @@ def _is_non_integer_float(x: float) -> bool:
|
|
|
661
869
|
|
|
662
870
|
|
|
663
871
|
def _negative_type(ctx: CoverageContext, seen: set, ty: str | list[str]) -> Generator[GeneratedValue, None, None]:
|
|
664
|
-
strategies = {
|
|
665
|
-
"integer": st.integers(),
|
|
666
|
-
"number": NUMERIC_STRATEGY,
|
|
667
|
-
"boolean": st.booleans(),
|
|
668
|
-
"null": st.none(),
|
|
669
|
-
"string": st.text(),
|
|
670
|
-
"array": ARRAY_STRATEGY,
|
|
671
|
-
"object": OBJECT_STRATEGY,
|
|
672
|
-
}
|
|
673
872
|
if isinstance(ty, str):
|
|
674
873
|
types = [ty]
|
|
675
874
|
else:
|
|
676
875
|
types = ty
|
|
677
|
-
for
|
|
678
|
-
strategies.pop(ty_)
|
|
876
|
+
strategies = {ty: strategy for ty, strategy in STRATEGIES_FOR_TYPE.items() if ty not in types}
|
|
679
877
|
if "number" in types:
|
|
680
878
|
del strategies["integer"]
|
|
681
879
|
if "integer" in types:
|
|
682
880
|
strategies["number"] = FLOAT_STRATEGY.filter(_is_non_integer_float)
|
|
683
|
-
for
|
|
684
|
-
value = ctx.generate_from(
|
|
881
|
+
for strategy in strategies.values():
|
|
882
|
+
value = ctx.generate_from(strategy)
|
|
685
883
|
hashed = _to_hashable_key(value)
|
|
686
884
|
if hashed in seen:
|
|
687
885
|
continue
|
|
688
|
-
yield NegativeValue(value, description="Incorrect type")
|
|
886
|
+
yield NegativeValue(value, description="Incorrect type", location=ctx.current_location)
|
|
689
887
|
seen.add(hashed)
|
|
690
888
|
|
|
691
889
|
|
schemathesis/internal/checks.py
CHANGED
|
@@ -9,6 +9,7 @@ if TYPE_CHECKING:
|
|
|
9
9
|
from requests.auth import HTTPDigestAuth
|
|
10
10
|
from requests.structures import CaseInsensitiveDict
|
|
11
11
|
|
|
12
|
+
from .._override import CaseOverride
|
|
12
13
|
from ..models import Case
|
|
13
14
|
from ..transports.responses import GenericResponse
|
|
14
15
|
from ..types import RawAuth
|
|
@@ -41,8 +42,9 @@ class CheckContext:
|
|
|
41
42
|
Provides access to broader test execution data beyond individual test cases.
|
|
42
43
|
"""
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
override: CaseOverride | None
|
|
46
|
+
auth: HTTPDigestAuth | RawAuth | None
|
|
47
|
+
headers: CaseInsensitiveDict | None
|
|
46
48
|
config: CheckConfig = field(default_factory=CheckConfig)
|
|
47
49
|
|
|
48
50
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Mapping
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def diff(left: Mapping[str, Any], right: Mapping[str, Any]) -> dict[str, Any]:
|
|
7
|
+
"""Calculate the difference between two dictionaries."""
|
|
8
|
+
diff = {}
|
|
9
|
+
for key, value in right.items():
|
|
10
|
+
if key not in left or left[key] != value:
|
|
11
|
+
diff[key] = value
|
|
12
|
+
for key in left:
|
|
13
|
+
if key not in right:
|
|
14
|
+
diff[key] = None # Mark deleted items as None
|
|
15
|
+
return diff
|