schemathesis 3.34.3__py3-none-any.whl → 3.35.1__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 +82 -27
- schemathesis/cli/__init__.py +25 -4
- schemathesis/cli/cassettes.py +2 -0
- schemathesis/cli/context.py +4 -0
- schemathesis/cli/handlers.py +4 -1
- schemathesis/cli/output/default.py +4 -0
- schemathesis/contrib/openapi/fill_missing_examples.py +1 -1
- schemathesis/experimental/__init__.py +7 -0
- schemathesis/generation/__init__.py +4 -37
- schemathesis/generation/_hypothesis.py +51 -0
- schemathesis/generation/_methods.py +40 -0
- schemathesis/generation/coverage.py +487 -0
- schemathesis/models.py +14 -1
- schemathesis/runner/serialization.py +3 -1
- schemathesis/schemas.py +2 -1
- schemathesis/service/extensions.py +1 -1
- schemathesis/specs/openapi/_hypothesis.py +3 -1
- schemathesis/specs/openapi/examples.py +4 -2
- schemathesis/specs/openapi/schemas.py +2 -1
- schemathesis/specs/openapi/stateful/__init__.py +1 -2
- schemathesis/utils.py +0 -10
- {schemathesis-3.34.3.dist-info → schemathesis-3.35.1.dist-info}/METADATA +1 -1
- {schemathesis-3.34.3.dist-info → schemathesis-3.35.1.dist-info}/RECORD +26 -23
- {schemathesis-3.34.3.dist-info → schemathesis-3.35.1.dist-info}/WHEEL +0 -0
- {schemathesis-3.34.3.dist-info → schemathesis-3.35.1.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.34.3.dist-info → schemathesis-3.35.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from contextlib import contextmanager, suppress
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from typing import Any, Generator, Set, Type, TypeVar, cast
|
|
8
|
+
|
|
9
|
+
import jsonschema
|
|
10
|
+
from hypothesis import strategies as st
|
|
11
|
+
from hypothesis.errors import InvalidArgument, Unsatisfiable
|
|
12
|
+
from hypothesis_jsonschema import from_schema
|
|
13
|
+
from hypothesis_jsonschema._canonicalise import canonicalish
|
|
14
|
+
|
|
15
|
+
from schemathesis.constants import NOT_SET
|
|
16
|
+
|
|
17
|
+
from ._hypothesis import combine_strategies, get_single_example
|
|
18
|
+
from ._methods import DataGenerationMethod
|
|
19
|
+
|
|
20
|
+
BUFFER_SIZE = 8 * 1024
|
|
21
|
+
FLOAT_STRATEGY: st.SearchStrategy = st.floats(allow_nan=False, allow_infinity=False).map(lambda x: x or 0.0)
|
|
22
|
+
NUMERIC_STRATEGY: st.SearchStrategy = st.integers() | FLOAT_STRATEGY
|
|
23
|
+
JSON_STRATEGY: st.SearchStrategy = st.recursive(
|
|
24
|
+
st.none() | st.booleans() | NUMERIC_STRATEGY | st.text(),
|
|
25
|
+
lambda strategy: st.lists(strategy, max_size=3) | st.dictionaries(st.text(), strategy, max_size=3),
|
|
26
|
+
)
|
|
27
|
+
ARRAY_STRATEGY: st.SearchStrategy = st.lists(JSON_STRATEGY)
|
|
28
|
+
OBJECT_STRATEGY: st.SearchStrategy = st.dictionaries(st.text(), JSON_STRATEGY)
|
|
29
|
+
|
|
30
|
+
UNKNOWN_PROPERTY_KEY = "x-schemathesis-unknown-property"
|
|
31
|
+
UNKNOWN_PROPERTY_VALUE = 42
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class GeneratedValue:
|
|
36
|
+
value: Any
|
|
37
|
+
data_generation_method: DataGenerationMethod
|
|
38
|
+
|
|
39
|
+
__slots__ = ("value", "data_generation_method")
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def with_positive(cls, value: Any) -> GeneratedValue:
|
|
43
|
+
return cls(value, DataGenerationMethod.positive)
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def with_negative(cls, value: Any) -> GeneratedValue:
|
|
47
|
+
return cls(value, DataGenerationMethod.negative)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
PositiveValue = GeneratedValue.with_positive
|
|
51
|
+
NegativeValue = GeneratedValue.with_negative
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@lru_cache(maxsize=128)
|
|
55
|
+
def cached_draw(strategy: st.SearchStrategy) -> Any:
|
|
56
|
+
return get_single_example(strategy)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class CoverageContext:
|
|
61
|
+
data_generation_methods: list[DataGenerationMethod] = field(default_factory=DataGenerationMethod.all)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def with_positive(cls) -> CoverageContext:
|
|
65
|
+
return CoverageContext(data_generation_methods=[DataGenerationMethod.positive])
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def with_negative(cls) -> CoverageContext:
|
|
69
|
+
return CoverageContext(data_generation_methods=[DataGenerationMethod.negative])
|
|
70
|
+
|
|
71
|
+
def generate_from(self, strategy: st.SearchStrategy, cached: bool = False) -> Any:
|
|
72
|
+
if cached:
|
|
73
|
+
value = cached_draw(strategy)
|
|
74
|
+
else:
|
|
75
|
+
value = get_single_example(strategy)
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
def generate_from_schema(self, schema: dict) -> Any:
|
|
79
|
+
return self.generate_from(from_schema(schema))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
T = TypeVar("T")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _to_hashable_key(value: T) -> T | tuple[type, str]:
|
|
86
|
+
if isinstance(value, (dict, list)):
|
|
87
|
+
serialized = json.dumps(value, sort_keys=True)
|
|
88
|
+
return (type(value), serialized)
|
|
89
|
+
return value
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _cover_positive_for_type(
|
|
93
|
+
ctx: CoverageContext, schema: dict, ty: str | None
|
|
94
|
+
) -> Generator[GeneratedValue, None, None]:
|
|
95
|
+
if ty == "object" or ty == "array":
|
|
96
|
+
template_schema = _get_template_schema(schema, ty)
|
|
97
|
+
template = ctx.generate_from_schema(template_schema)
|
|
98
|
+
else:
|
|
99
|
+
template = None
|
|
100
|
+
if DataGenerationMethod.positive in ctx.data_generation_methods:
|
|
101
|
+
ctx = ctx.with_positive()
|
|
102
|
+
enum = schema.get("enum", NOT_SET)
|
|
103
|
+
const = schema.get("const", NOT_SET)
|
|
104
|
+
for key in ("anyOf", "oneOf"):
|
|
105
|
+
sub_schemas = schema.get(key)
|
|
106
|
+
if sub_schemas is not None:
|
|
107
|
+
for sub_schema in sub_schemas:
|
|
108
|
+
yield from cover_schema_iter(ctx, sub_schema)
|
|
109
|
+
all_of = schema.get("allOf")
|
|
110
|
+
if all_of is not None:
|
|
111
|
+
if len(all_of) == 1:
|
|
112
|
+
yield from cover_schema_iter(ctx, all_of[0])
|
|
113
|
+
else:
|
|
114
|
+
with suppress(jsonschema.SchemaError):
|
|
115
|
+
canonical = canonicalish(schema)
|
|
116
|
+
yield from cover_schema_iter(ctx, canonical)
|
|
117
|
+
if enum is not NOT_SET:
|
|
118
|
+
for value in enum:
|
|
119
|
+
yield PositiveValue(value)
|
|
120
|
+
elif const is not NOT_SET:
|
|
121
|
+
yield PositiveValue(const)
|
|
122
|
+
elif ty is not None:
|
|
123
|
+
if ty == "null":
|
|
124
|
+
yield PositiveValue(None)
|
|
125
|
+
elif ty == "boolean":
|
|
126
|
+
yield PositiveValue(True)
|
|
127
|
+
yield PositiveValue(False)
|
|
128
|
+
elif ty == "string":
|
|
129
|
+
yield from _positive_string(ctx, schema)
|
|
130
|
+
elif ty == "integer" or ty == "number":
|
|
131
|
+
yield from _positive_number(ctx, schema)
|
|
132
|
+
elif ty == "array":
|
|
133
|
+
yield from _positive_array(ctx, schema, cast(list, template))
|
|
134
|
+
elif ty == "object":
|
|
135
|
+
yield from _positive_object(ctx, schema, cast(dict, template))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@contextmanager
|
|
139
|
+
def _ignore_unfixable(
|
|
140
|
+
*,
|
|
141
|
+
# Cache exception types here as `jsonschema` uses a custom `__getattr__` on the module level
|
|
142
|
+
# and it may cause errors during the interpreter shutdown
|
|
143
|
+
ref_error: Type[Exception] = jsonschema.RefResolutionError,
|
|
144
|
+
schema_error: Type[Exception] = jsonschema.SchemaError,
|
|
145
|
+
) -> Generator:
|
|
146
|
+
try:
|
|
147
|
+
yield
|
|
148
|
+
except (Unsatisfiable, ref_error, schema_error):
|
|
149
|
+
pass
|
|
150
|
+
except InvalidArgument as exc:
|
|
151
|
+
message = str(exc)
|
|
152
|
+
if "Cannot create non-empty" not in message and "is not in the specified alphabet" not in message:
|
|
153
|
+
raise
|
|
154
|
+
except TypeError as exc:
|
|
155
|
+
if "first argument must be string or compiled pattern" not in str(exc):
|
|
156
|
+
raise
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def cover_schema_iter(ctx: CoverageContext, schema: dict | bool) -> Generator[GeneratedValue, None, None]:
|
|
160
|
+
if isinstance(schema, bool):
|
|
161
|
+
types = ["null", "boolean", "string", "number", "array", "object"]
|
|
162
|
+
schema = {}
|
|
163
|
+
else:
|
|
164
|
+
types = schema.get("type", [])
|
|
165
|
+
if not isinstance(types, list):
|
|
166
|
+
types = [types] # type: ignore[unreachable]
|
|
167
|
+
if not types:
|
|
168
|
+
with _ignore_unfixable():
|
|
169
|
+
yield from _cover_positive_for_type(ctx, schema, None)
|
|
170
|
+
for ty in types:
|
|
171
|
+
with _ignore_unfixable():
|
|
172
|
+
yield from _cover_positive_for_type(ctx, schema, ty)
|
|
173
|
+
if DataGenerationMethod.negative in ctx.data_generation_methods:
|
|
174
|
+
template = None
|
|
175
|
+
seen: Set[Any | tuple[type, str]] = set()
|
|
176
|
+
for key, value in schema.items():
|
|
177
|
+
with _ignore_unfixable():
|
|
178
|
+
if key == "enum":
|
|
179
|
+
yield from _negative_enum(ctx, value)
|
|
180
|
+
elif key == "const":
|
|
181
|
+
yield from _negative_enum(ctx, [value])
|
|
182
|
+
elif key == "type":
|
|
183
|
+
yield from _negative_type(ctx, seen, value)
|
|
184
|
+
elif key == "properties":
|
|
185
|
+
template = template or ctx.generate_from_schema(_get_template_schema(schema, "object"))
|
|
186
|
+
yield from _negative_properties(ctx, template, value)
|
|
187
|
+
elif key == "pattern":
|
|
188
|
+
yield from _negative_pattern(ctx, value)
|
|
189
|
+
elif key == "format" and ("string" in types or not types):
|
|
190
|
+
yield from _negative_format(ctx, schema, value)
|
|
191
|
+
elif key == "maximum":
|
|
192
|
+
next = value + 1
|
|
193
|
+
yield NegativeValue(next)
|
|
194
|
+
seen.add(next)
|
|
195
|
+
elif key == "minimum":
|
|
196
|
+
next = value - 1
|
|
197
|
+
yield NegativeValue(next)
|
|
198
|
+
seen.add(next)
|
|
199
|
+
elif key == "exclusiveMaximum" or key == "exclusiveMinimum" and value not in seen:
|
|
200
|
+
yield NegativeValue(value)
|
|
201
|
+
seen.add(value)
|
|
202
|
+
elif key == "multipleOf":
|
|
203
|
+
yield from _negative_multiple_of(ctx, schema, value)
|
|
204
|
+
elif key == "minLength" and 0 < value < BUFFER_SIZE and "pattern" not in schema:
|
|
205
|
+
with suppress(InvalidArgument):
|
|
206
|
+
yield NegativeValue(
|
|
207
|
+
ctx.generate_from_schema({**schema, "minLength": value - 1, "maxLength": value - 1})
|
|
208
|
+
)
|
|
209
|
+
elif key == "maxLength" and value < BUFFER_SIZE and "pattern" not in schema:
|
|
210
|
+
with suppress(InvalidArgument):
|
|
211
|
+
yield NegativeValue(
|
|
212
|
+
ctx.generate_from_schema({**schema, "minLength": value + 1, "maxLength": value + 1})
|
|
213
|
+
)
|
|
214
|
+
elif key == "uniqueItems" and value:
|
|
215
|
+
yield from _negative_unique_items(ctx, schema)
|
|
216
|
+
elif key == "required":
|
|
217
|
+
template = template or ctx.generate_from_schema(_get_template_schema(schema, "object"))
|
|
218
|
+
yield from _negative_required(ctx, template, value)
|
|
219
|
+
elif key == "additionalProperties" and not value:
|
|
220
|
+
template = template or ctx.generate_from_schema(_get_template_schema(schema, "object"))
|
|
221
|
+
yield NegativeValue({**template, UNKNOWN_PROPERTY_KEY: UNKNOWN_PROPERTY_VALUE})
|
|
222
|
+
elif key == "allOf":
|
|
223
|
+
nctx = ctx.with_negative()
|
|
224
|
+
if len(value) == 1:
|
|
225
|
+
yield from cover_schema_iter(nctx, value[0])
|
|
226
|
+
else:
|
|
227
|
+
with _ignore_unfixable():
|
|
228
|
+
canonical = canonicalish(schema)
|
|
229
|
+
yield from cover_schema_iter(nctx, canonical)
|
|
230
|
+
elif key == "anyOf" or key == "oneOf":
|
|
231
|
+
nctx = ctx.with_negative()
|
|
232
|
+
# NOTE: Other sub-schemas are not filtered out
|
|
233
|
+
for sub_schema in value:
|
|
234
|
+
yield from cover_schema_iter(nctx, sub_schema)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _get_template_schema(schema: dict, ty: str) -> dict:
|
|
238
|
+
if ty == "object":
|
|
239
|
+
properties = schema.get("properties")
|
|
240
|
+
if properties is not None:
|
|
241
|
+
return {
|
|
242
|
+
**schema,
|
|
243
|
+
"required": list(properties),
|
|
244
|
+
"type": ty,
|
|
245
|
+
"properties": {
|
|
246
|
+
k: _get_template_schema(v, "object") if isinstance(v, dict) and v.get("type") == "object" else v
|
|
247
|
+
for k, v in properties.items()
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
return {**schema, "type": ty}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _positive_string(ctx: CoverageContext, schema: dict) -> Generator[GeneratedValue, None, None]:
|
|
254
|
+
"""Generate positive string values."""
|
|
255
|
+
# Boundary and near boundary values
|
|
256
|
+
min_length = schema.get("minLength")
|
|
257
|
+
max_length = schema.get("maxLength")
|
|
258
|
+
|
|
259
|
+
if not min_length and not max_length:
|
|
260
|
+
# Default positive value
|
|
261
|
+
yield PositiveValue(ctx.generate_from_schema(schema))
|
|
262
|
+
|
|
263
|
+
seen = set()
|
|
264
|
+
|
|
265
|
+
if min_length is not None and min_length < BUFFER_SIZE and "pattern" not in schema:
|
|
266
|
+
# Exactly the minimum length
|
|
267
|
+
yield PositiveValue(ctx.generate_from_schema({**schema, "maxLength": min_length}))
|
|
268
|
+
seen.add(min_length)
|
|
269
|
+
|
|
270
|
+
# One character more than minimum if possible
|
|
271
|
+
larger = min_length + 1
|
|
272
|
+
if larger < BUFFER_SIZE and larger not in seen and (not max_length or larger <= max_length):
|
|
273
|
+
yield PositiveValue(ctx.generate_from_schema({**schema, "minLength": larger, "maxLength": larger}))
|
|
274
|
+
seen.add(larger)
|
|
275
|
+
|
|
276
|
+
if max_length is not None and "pattern" not in schema:
|
|
277
|
+
# Exactly the maximum length
|
|
278
|
+
if max_length < BUFFER_SIZE and max_length not in seen:
|
|
279
|
+
yield PositiveValue(ctx.generate_from_schema({**schema, "minLength": max_length}))
|
|
280
|
+
seen.add(max_length)
|
|
281
|
+
|
|
282
|
+
# One character less than maximum if possible
|
|
283
|
+
smaller = max_length - 1
|
|
284
|
+
if (
|
|
285
|
+
smaller < BUFFER_SIZE
|
|
286
|
+
and smaller not in seen
|
|
287
|
+
and (smaller > 0 and (min_length is None or smaller >= min_length))
|
|
288
|
+
):
|
|
289
|
+
yield PositiveValue(ctx.generate_from_schema({**schema, "minLength": smaller, "maxLength": smaller}))
|
|
290
|
+
seen.add(smaller)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def closest_multiple_greater_than(y: int, x: int) -> int:
|
|
294
|
+
"""Find the closest multiple of X that is greater than Y."""
|
|
295
|
+
quotient, remainder = divmod(y, x)
|
|
296
|
+
if remainder == 0:
|
|
297
|
+
return y
|
|
298
|
+
return x * (quotient + 1)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _positive_number(ctx: CoverageContext, schema: dict) -> Generator[GeneratedValue, None, None]:
|
|
302
|
+
"""Generate positive integer values."""
|
|
303
|
+
# Boundary and near boundary values
|
|
304
|
+
minimum = schema.get("minimum")
|
|
305
|
+
maximum = schema.get("maximum")
|
|
306
|
+
exclusive_minimum = schema.get("exclusiveMinimum")
|
|
307
|
+
exclusive_maximum = schema.get("exclusiveMaximum")
|
|
308
|
+
if exclusive_minimum is not None:
|
|
309
|
+
minimum = exclusive_minimum + 1
|
|
310
|
+
if exclusive_maximum is not None:
|
|
311
|
+
maximum = exclusive_maximum - 1
|
|
312
|
+
multiple_of = schema.get("multipleOf")
|
|
313
|
+
|
|
314
|
+
if not minimum and not maximum:
|
|
315
|
+
# Default positive value
|
|
316
|
+
yield PositiveValue(ctx.generate_from_schema(schema))
|
|
317
|
+
|
|
318
|
+
seen = set()
|
|
319
|
+
|
|
320
|
+
if minimum is not None:
|
|
321
|
+
# Exactly the minimum
|
|
322
|
+
if multiple_of is not None:
|
|
323
|
+
smallest = closest_multiple_greater_than(minimum, multiple_of)
|
|
324
|
+
else:
|
|
325
|
+
smallest = minimum
|
|
326
|
+
seen.add(smallest)
|
|
327
|
+
yield PositiveValue(smallest)
|
|
328
|
+
|
|
329
|
+
# One more than minimum if possible
|
|
330
|
+
if multiple_of is not None:
|
|
331
|
+
larger = smallest + multiple_of
|
|
332
|
+
else:
|
|
333
|
+
larger = minimum + 1
|
|
334
|
+
if larger not in seen and (not maximum or larger <= maximum):
|
|
335
|
+
seen.add(larger)
|
|
336
|
+
yield PositiveValue(larger)
|
|
337
|
+
|
|
338
|
+
if maximum is not None:
|
|
339
|
+
# Exactly the maximum
|
|
340
|
+
if multiple_of is not None:
|
|
341
|
+
largest = maximum - (maximum % multiple_of)
|
|
342
|
+
else:
|
|
343
|
+
largest = maximum
|
|
344
|
+
if largest not in seen:
|
|
345
|
+
seen.add(largest)
|
|
346
|
+
yield PositiveValue(largest)
|
|
347
|
+
|
|
348
|
+
# One less than maximum if possible
|
|
349
|
+
if multiple_of is not None:
|
|
350
|
+
smaller = largest - multiple_of
|
|
351
|
+
else:
|
|
352
|
+
smaller = maximum - 1
|
|
353
|
+
if smaller not in seen and (smaller > 0 and (minimum is None or smaller >= minimum)):
|
|
354
|
+
seen.add(smaller)
|
|
355
|
+
yield PositiveValue(smaller)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _positive_array(ctx: CoverageContext, schema: dict, template: list) -> Generator[GeneratedValue, None, None]:
|
|
359
|
+
seen = set()
|
|
360
|
+
yield PositiveValue(template)
|
|
361
|
+
seen.add(len(template))
|
|
362
|
+
|
|
363
|
+
# Boundary and near-boundary sizes
|
|
364
|
+
min_items = schema.get("minItems")
|
|
365
|
+
max_items = schema.get("maxItems")
|
|
366
|
+
if min_items is not None:
|
|
367
|
+
# Do not generate an array with `minItems` length, because it is already covered by `template`
|
|
368
|
+
|
|
369
|
+
# One item more than minimum if possible
|
|
370
|
+
larger = min_items + 1
|
|
371
|
+
if larger not in seen and (max_items is None or larger <= max_items):
|
|
372
|
+
yield PositiveValue(ctx.generate_from_schema({**schema, "minItems": larger, "maxItems": larger}))
|
|
373
|
+
seen.add(larger)
|
|
374
|
+
|
|
375
|
+
if max_items is not None:
|
|
376
|
+
if max_items < BUFFER_SIZE and max_items not in seen:
|
|
377
|
+
yield PositiveValue(ctx.generate_from_schema({**schema, "minItems": max_items}))
|
|
378
|
+
seen.add(max_items)
|
|
379
|
+
|
|
380
|
+
# One item smaller than maximum if possible
|
|
381
|
+
smaller = max_items - 1
|
|
382
|
+
if (
|
|
383
|
+
smaller < BUFFER_SIZE
|
|
384
|
+
and smaller > 0
|
|
385
|
+
and smaller not in seen
|
|
386
|
+
and (min_items is None or smaller >= min_items)
|
|
387
|
+
):
|
|
388
|
+
yield PositiveValue(ctx.generate_from_schema({**schema, "minItems": smaller, "maxItems": smaller}))
|
|
389
|
+
seen.add(smaller)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _positive_object(ctx: CoverageContext, schema: dict, template: dict) -> Generator[GeneratedValue, None, None]:
|
|
393
|
+
yield PositiveValue(template)
|
|
394
|
+
# Only required properties
|
|
395
|
+
properties = schema.get("properties", {})
|
|
396
|
+
if set(properties) != set(schema.get("required", {})):
|
|
397
|
+
only_required = {k: v for k, v in template.items() if k in schema.get("required", [])}
|
|
398
|
+
yield PositiveValue(only_required)
|
|
399
|
+
seen = set()
|
|
400
|
+
for name, sub_schema in properties.items():
|
|
401
|
+
seen.add(_to_hashable_key(template.get(name)))
|
|
402
|
+
for new in cover_schema_iter(ctx, sub_schema):
|
|
403
|
+
key = _to_hashable_key(new.value)
|
|
404
|
+
if key not in seen:
|
|
405
|
+
yield PositiveValue({**template, name: new.value})
|
|
406
|
+
seen.add(key)
|
|
407
|
+
seen.clear()
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _negative_enum(ctx: CoverageContext, value: list) -> Generator[GeneratedValue, None, None]:
|
|
411
|
+
strategy = JSON_STRATEGY.filter(lambda x: x not in value)
|
|
412
|
+
# The exact negative value is not important here
|
|
413
|
+
yield NegativeValue(ctx.generate_from(strategy, cached=True))
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _negative_properties(
|
|
417
|
+
ctx: CoverageContext, template: dict, properties: dict
|
|
418
|
+
) -> Generator[GeneratedValue, None, None]:
|
|
419
|
+
nctx = ctx.with_negative()
|
|
420
|
+
for key, sub_schema in properties.items():
|
|
421
|
+
for value in cover_schema_iter(nctx, sub_schema):
|
|
422
|
+
yield NegativeValue({**template, key: value.value})
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _negative_pattern(ctx: CoverageContext, pattern: str) -> Generator[GeneratedValue, None, None]:
|
|
426
|
+
yield NegativeValue(ctx.generate_from(st.text().filter(lambda x: x != pattern), cached=True))
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _with_negated_key(schema: dict, key: str, value: Any) -> dict:
|
|
430
|
+
return {"allOf": [{k: v for k, v in schema.items() if k != key}, {"not": {key: value}}]}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _negative_multiple_of(
|
|
434
|
+
ctx: CoverageContext, schema: dict, multiple_of: int | float
|
|
435
|
+
) -> Generator[GeneratedValue, None, None]:
|
|
436
|
+
yield NegativeValue(ctx.generate_from_schema(_with_negated_key(schema, "multipleOf", multiple_of)))
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _negative_unique_items(ctx: CoverageContext, schema: dict) -> Generator[GeneratedValue, None, None]:
|
|
440
|
+
unique = ctx.generate_from_schema({**schema, "type": "array", "minItems": 1, "maxItems": 1})
|
|
441
|
+
yield NegativeValue(unique + unique)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _negative_required(
|
|
445
|
+
ctx: CoverageContext, template: dict, required: list[str]
|
|
446
|
+
) -> Generator[GeneratedValue, None, None]:
|
|
447
|
+
for key in required:
|
|
448
|
+
yield NegativeValue({k: v for k, v in template.items() if k != key})
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _negative_format(ctx: CoverageContext, schema: dict, format: str) -> Generator[GeneratedValue, None, None]:
|
|
452
|
+
# Hypothesis-jsonschema does not canonicalise it properly right now, which leads to unsatisfiable schema
|
|
453
|
+
without_format = {k: v for k, v in schema.items() if k != "format"}
|
|
454
|
+
without_format.setdefault("type", "string")
|
|
455
|
+
strategy = from_schema(without_format)
|
|
456
|
+
if format in jsonschema.Draft202012Validator.FORMAT_CHECKER.checkers:
|
|
457
|
+
strategy = strategy.filter(
|
|
458
|
+
lambda v: (format == "hostname" and v == "")
|
|
459
|
+
or not jsonschema.Draft202012Validator.FORMAT_CHECKER.conforms(v, format)
|
|
460
|
+
)
|
|
461
|
+
yield NegativeValue(ctx.generate_from(strategy))
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _negative_type(ctx: CoverageContext, seen: set, ty: str | list[str]) -> Generator[GeneratedValue, None, None]:
|
|
465
|
+
strategies = {
|
|
466
|
+
"integer": st.integers(),
|
|
467
|
+
"number": NUMERIC_STRATEGY,
|
|
468
|
+
"boolean": st.booleans(),
|
|
469
|
+
"null": st.none(),
|
|
470
|
+
"string": st.text(),
|
|
471
|
+
"array": ARRAY_STRATEGY,
|
|
472
|
+
"object": OBJECT_STRATEGY,
|
|
473
|
+
}
|
|
474
|
+
if isinstance(ty, str):
|
|
475
|
+
types = [ty]
|
|
476
|
+
else:
|
|
477
|
+
types = ty
|
|
478
|
+
for ty_ in types:
|
|
479
|
+
strategies.pop(ty_)
|
|
480
|
+
if "number" in types:
|
|
481
|
+
del strategies["integer"]
|
|
482
|
+
if "integer" in types:
|
|
483
|
+
strategies["number"] = FLOAT_STRATEGY.filter(lambda x: x != int(x))
|
|
484
|
+
negative_strategy = combine_strategies(tuple(strategies.values())).filter(lambda x: _to_hashable_key(x) not in seen)
|
|
485
|
+
value = ctx.generate_from(negative_strategy, cached=True)
|
|
486
|
+
yield NegativeValue(value)
|
|
487
|
+
seen.add(_to_hashable_key(value))
|
schemathesis/models.py
CHANGED
|
@@ -125,6 +125,15 @@ def prepare_request_data(kwargs: dict[str, Any]) -> PreparedRequestData:
|
|
|
125
125
|
)
|
|
126
126
|
|
|
127
127
|
|
|
128
|
+
@dataclass
|
|
129
|
+
class TestPhase(Enum):
|
|
130
|
+
__test__ = False
|
|
131
|
+
|
|
132
|
+
EXPLICIT = "explicit"
|
|
133
|
+
COVERAGE = "coverage"
|
|
134
|
+
GENERATE = "generate"
|
|
135
|
+
|
|
136
|
+
|
|
128
137
|
@dataclass
|
|
129
138
|
class GenerationMetadata:
|
|
130
139
|
"""Stores various information about how data is generated."""
|
|
@@ -134,8 +143,9 @@ class GenerationMetadata:
|
|
|
134
143
|
headers: DataGenerationMethod | None
|
|
135
144
|
cookies: DataGenerationMethod | None
|
|
136
145
|
body: DataGenerationMethod | None
|
|
146
|
+
phase: TestPhase
|
|
137
147
|
|
|
138
|
-
__slots__ = ("query", "path_parameters", "headers", "cookies", "body")
|
|
148
|
+
__slots__ = ("query", "path_parameters", "headers", "cookies", "body", "phase")
|
|
139
149
|
|
|
140
150
|
|
|
141
151
|
@dataclass(repr=False)
|
|
@@ -968,6 +978,7 @@ class Interaction:
|
|
|
968
978
|
checks: list[Check]
|
|
969
979
|
status: Status
|
|
970
980
|
data_generation_method: DataGenerationMethod
|
|
981
|
+
phase: TestPhase | None
|
|
971
982
|
recorded_at: str = field(default_factory=lambda: datetime.datetime.now(TIMEZONE).isoformat())
|
|
972
983
|
|
|
973
984
|
@classmethod
|
|
@@ -978,6 +989,7 @@ class Interaction:
|
|
|
978
989
|
status=status,
|
|
979
990
|
checks=checks,
|
|
980
991
|
data_generation_method=cast(DataGenerationMethod, case.data_generation_method),
|
|
992
|
+
phase=case.meta.phase if case.meta is not None else None,
|
|
981
993
|
)
|
|
982
994
|
|
|
983
995
|
@classmethod
|
|
@@ -1000,6 +1012,7 @@ class Interaction:
|
|
|
1000
1012
|
status=status,
|
|
1001
1013
|
checks=checks,
|
|
1002
1014
|
data_generation_method=cast(DataGenerationMethod, case.data_generation_method),
|
|
1015
|
+
phase=case.meta.phase if case.meta is not None else None,
|
|
1003
1016
|
)
|
|
1004
1017
|
|
|
1005
1018
|
|
|
@@ -28,7 +28,7 @@ from ..exceptions import (
|
|
|
28
28
|
make_unique_by_key,
|
|
29
29
|
)
|
|
30
30
|
from ..generation import DataGenerationMethod
|
|
31
|
-
from ..models import Case, Check, Interaction, Request, Response, Status, TestResult
|
|
31
|
+
from ..models import Case, Check, Interaction, Request, Response, Status, TestPhase, TestResult
|
|
32
32
|
from ..transports import deserialize_payload, serialize_payload
|
|
33
33
|
|
|
34
34
|
if TYPE_CHECKING:
|
|
@@ -385,6 +385,7 @@ class SerializedInteraction:
|
|
|
385
385
|
checks: list[SerializedCheck]
|
|
386
386
|
status: Status
|
|
387
387
|
data_generation_method: DataGenerationMethod
|
|
388
|
+
phase: TestPhase | None
|
|
388
389
|
recorded_at: str
|
|
389
390
|
|
|
390
391
|
@classmethod
|
|
@@ -395,6 +396,7 @@ class SerializedInteraction:
|
|
|
395
396
|
checks=[SerializedCheck.from_check(check) for check in interaction.checks],
|
|
396
397
|
status=interaction.status,
|
|
397
398
|
data_generation_method=interaction.data_generation_method,
|
|
399
|
+
phase=interaction.phase,
|
|
398
400
|
recorded_at=interaction.recorded_at,
|
|
399
401
|
)
|
|
400
402
|
|
schemathesis/schemas.py
CHANGED
|
@@ -50,6 +50,7 @@ from .generation import (
|
|
|
50
50
|
DataGenerationMethod,
|
|
51
51
|
DataGenerationMethodInput,
|
|
52
52
|
GenerationConfig,
|
|
53
|
+
combine_strategies,
|
|
53
54
|
)
|
|
54
55
|
from .hooks import HookContext, HookDispatcher, HookScope, dispatch, to_filterable_hook
|
|
55
56
|
from .internal.deprecation import warn_filtration_arguments
|
|
@@ -69,7 +70,7 @@ from .types import (
|
|
|
69
70
|
PathParameters,
|
|
70
71
|
Query,
|
|
71
72
|
)
|
|
72
|
-
from .utils import PARAMETRIZE_MARKER, GivenInput,
|
|
73
|
+
from .utils import PARAMETRIZE_MARKER, GivenInput, given_proxy
|
|
73
74
|
|
|
74
75
|
if TYPE_CHECKING:
|
|
75
76
|
from .transports import Transport
|
|
@@ -128,7 +128,7 @@ def _apply_schema_patches_extension(extension: SchemaPatchesExtension, schema: B
|
|
|
128
128
|
|
|
129
129
|
|
|
130
130
|
def strategy_from_definitions(definitions: list[StrategyDefinition]) -> Result[st.SearchStrategy, Exception]:
|
|
131
|
-
from ..
|
|
131
|
+
from ..generation import combine_strategies
|
|
132
132
|
|
|
133
133
|
strategies = []
|
|
134
134
|
for definition in definitions:
|
|
@@ -25,7 +25,7 @@ from ...generation import DataGenerationMethod, GenerationConfig
|
|
|
25
25
|
from ...hooks import HookContext, HookDispatcher, apply_to_all_dispatchers
|
|
26
26
|
from ...internal.copy import fast_deepcopy
|
|
27
27
|
from ...internal.validation import is_illegal_surrogate
|
|
28
|
-
from ...models import APIOperation, Case, GenerationMetadata, cant_serialize
|
|
28
|
+
from ...models import APIOperation, Case, GenerationMetadata, TestPhase, cant_serialize
|
|
29
29
|
from ...serializers import Binary
|
|
30
30
|
from ...transports.content_types import parse_content_type
|
|
31
31
|
from ...transports.headers import has_invalid_characters, is_latin_1_encodable
|
|
@@ -123,6 +123,7 @@ def get_case_strategy(
|
|
|
123
123
|
body: Any = NOT_SET,
|
|
124
124
|
media_type: str | None = None,
|
|
125
125
|
skip_on_not_negated: bool = True,
|
|
126
|
+
phase: TestPhase = TestPhase.GENERATE,
|
|
126
127
|
) -> Any:
|
|
127
128
|
"""A strategy that creates `Case` instances.
|
|
128
129
|
|
|
@@ -213,6 +214,7 @@ def get_case_strategy(
|
|
|
213
214
|
headers=headers_.generator,
|
|
214
215
|
cookies=cookies_.generator,
|
|
215
216
|
body=body_.generator,
|
|
217
|
+
phase=phase,
|
|
216
218
|
),
|
|
217
219
|
)
|
|
218
220
|
auth_context = auths.AuthContext(
|
|
@@ -10,10 +10,10 @@ import requests
|
|
|
10
10
|
from hypothesis.strategies import SearchStrategy
|
|
11
11
|
from hypothesis_jsonschema import from_schema
|
|
12
12
|
|
|
13
|
-
from ..._hypothesis import get_single_example
|
|
14
13
|
from ...constants import DEFAULT_RESPONSE_TIMEOUT
|
|
14
|
+
from ...generation import get_single_example
|
|
15
15
|
from ...internal.copy import fast_deepcopy
|
|
16
|
-
from ...models import APIOperation, Case
|
|
16
|
+
from ...models import APIOperation, Case, TestPhase
|
|
17
17
|
from ._hypothesis import get_case_strategy, get_default_format_strategies
|
|
18
18
|
from .constants import LOCATION_TO_CONTAINER
|
|
19
19
|
from .formats import STRING_FORMATS
|
|
@@ -67,6 +67,8 @@ def get_strategies_from_examples(
|
|
|
67
67
|
examples = list(extract_top_level(operation))
|
|
68
68
|
# Add examples from parameter's schemas
|
|
69
69
|
examples.extend(extract_from_schemas(operation))
|
|
70
|
+
as_strategy_kwargs = as_strategy_kwargs or {}
|
|
71
|
+
as_strategy_kwargs["phase"] = TestPhase.EXPLICIT
|
|
70
72
|
return [
|
|
71
73
|
get_case_strategy(operation=operation, **{**parameters, **(as_strategy_kwargs or {})}).map(serialize_components)
|
|
72
74
|
for parameters in produce_combinations(examples)
|
|
@@ -1032,6 +1032,7 @@ class SwaggerV20(BaseOpenAPISchema):
|
|
|
1032
1032
|
query: Query | None = None,
|
|
1033
1033
|
body: Body | NotSet = NOT_SET,
|
|
1034
1034
|
media_type: str | None = None,
|
|
1035
|
+
generation_time: float = 0.0,
|
|
1035
1036
|
) -> C:
|
|
1036
1037
|
if body is not NOT_SET and media_type is None:
|
|
1037
1038
|
media_type = operation._get_default_media_type()
|
|
@@ -1043,7 +1044,7 @@ class SwaggerV20(BaseOpenAPISchema):
|
|
|
1043
1044
|
query=query,
|
|
1044
1045
|
body=body,
|
|
1045
1046
|
media_type=media_type,
|
|
1046
|
-
generation_time=
|
|
1047
|
+
generation_time=generation_time,
|
|
1047
1048
|
)
|
|
1048
1049
|
|
|
1049
1050
|
def _get_consumes_for_operation(self, definition: dict[str, Any]) -> list[str]:
|
|
@@ -8,11 +8,10 @@ from hypothesis import strategies as st
|
|
|
8
8
|
from hypothesis.stateful import Bundle, Rule, precondition, rule
|
|
9
9
|
|
|
10
10
|
from ....constants import NOT_SET
|
|
11
|
-
from ....generation import DataGenerationMethod
|
|
11
|
+
from ....generation import DataGenerationMethod, combine_strategies
|
|
12
12
|
from ....internal.result import Ok
|
|
13
13
|
from ....stateful.state_machine import APIStateMachine, Direction, StepResult
|
|
14
14
|
from ....types import NotSet
|
|
15
|
-
from ....utils import combine_strategies
|
|
16
15
|
from .. import expressions
|
|
17
16
|
from ..links import get_all_links
|
|
18
17
|
from ..utils import expand_status_code
|