schemathesis 3.35.0__py3-none-any.whl → 3.35.2__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 +35 -11
- schemathesis/cli/__init__.py +352 -357
- schemathesis/cli/cassettes.py +2 -0
- schemathesis/cli/context.py +7 -3
- schemathesis/cli/output/default.py +13 -2
- schemathesis/generation/coverage.py +170 -100
- schemathesis/models.py +14 -1
- schemathesis/runner/events.py +3 -1
- schemathesis/runner/serialization.py +3 -1
- schemathesis/schemas.py +3 -9
- schemathesis/specs/graphql/loaders.py +2 -1
- schemathesis/specs/openapi/_hypothesis.py +3 -1
- schemathesis/specs/openapi/examples.py +3 -1
- schemathesis/specs/openapi/loaders.py +3 -1
- schemathesis/specs/openapi/parameters.py +2 -0
- schemathesis/stateful/context.py +3 -0
- schemathesis/stateful/runner.py +8 -1
- schemathesis/types.py +8 -0
- {schemathesis-3.35.0.dist-info → schemathesis-3.35.2.dist-info}/METADATA +69 -168
- {schemathesis-3.35.0.dist-info → schemathesis-3.35.2.dist-info}/RECORD +23 -23
- {schemathesis-3.35.0.dist-info → schemathesis-3.35.2.dist-info}/WHEEL +0 -0
- {schemathesis-3.35.0.dist-info → schemathesis-3.35.2.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.35.0.dist-info → schemathesis-3.35.2.dist-info}/licenses/LICENSE +0 -0
schemathesis/cli/cassettes.py
CHANGED
|
@@ -226,6 +226,7 @@ http_interactions:"""
|
|
|
226
226
|
for interaction in item.interactions:
|
|
227
227
|
status = interaction.status.name.upper()
|
|
228
228
|
# Body payloads are handled via separate `stream.write` calls to avoid some allocations
|
|
229
|
+
phase = f"'{interaction.phase.value}'" if interaction.phase is not None else "null"
|
|
229
230
|
stream.write(
|
|
230
231
|
f"""\n- id: '{current_id}'
|
|
231
232
|
status: '{status}'
|
|
@@ -233,6 +234,7 @@ http_interactions:"""
|
|
|
233
234
|
thread_id: {item.thread_id}
|
|
234
235
|
correlation_id: '{item.correlation_id}'
|
|
235
236
|
data_generation_method: '{interaction.data_generation_method.value}'
|
|
237
|
+
phase: {phase}
|
|
236
238
|
elapsed: '{interaction.response.elapsed}'
|
|
237
239
|
recorded_at: '{interaction.recorded_at}'
|
|
238
240
|
checks:
|
schemathesis/cli/context.py
CHANGED
|
@@ -4,7 +4,7 @@ import os
|
|
|
4
4
|
import shutil
|
|
5
5
|
from dataclasses import dataclass, field
|
|
6
6
|
from queue import Queue
|
|
7
|
-
from typing import TYPE_CHECKING
|
|
7
|
+
from typing import TYPE_CHECKING, Generator
|
|
8
8
|
|
|
9
9
|
from ..code_samples import CodeSampleStyle
|
|
10
10
|
from ..internal.deprecation import deprecated_property
|
|
@@ -60,11 +60,15 @@ class ExecutionContext:
|
|
|
60
60
|
analysis: Result[AnalysisResult, Exception] | None = None
|
|
61
61
|
output_config: OutputConfig = field(default_factory=OutputConfig)
|
|
62
62
|
state_machine_sink: StateMachineSink | None = None
|
|
63
|
-
|
|
63
|
+
initialization_lines: list[str | Generator[str, None, None]] = field(default_factory=list)
|
|
64
|
+
summary_lines: list[str | Generator[str, None, None]] = field(default_factory=list)
|
|
64
65
|
|
|
65
66
|
@deprecated_property(removed_in="4.0", replacement="show_trace")
|
|
66
67
|
def show_errors_tracebacks(self) -> bool:
|
|
67
68
|
return self.show_trace
|
|
68
69
|
|
|
69
|
-
def
|
|
70
|
+
def add_initialization_line(self, line: str | Generator[str, None, None]) -> None:
|
|
71
|
+
self.initialization_lines.append(line)
|
|
72
|
+
|
|
73
|
+
def add_summary_line(self, line: str | Generator[str, None, None]) -> None:
|
|
70
74
|
self.summary_lines.append(line)
|
|
@@ -7,6 +7,7 @@ import textwrap
|
|
|
7
7
|
import time
|
|
8
8
|
from importlib import metadata
|
|
9
9
|
from queue import Queue
|
|
10
|
+
from types import GeneratorType
|
|
10
11
|
from typing import TYPE_CHECKING, Any, Generator, Literal, cast
|
|
11
12
|
|
|
12
13
|
import click
|
|
@@ -776,6 +777,8 @@ def handle_initialized(context: ExecutionContext, event: events.Initialized) ->
|
|
|
776
777
|
click.secho(f"Collected API links: {links_count}", bold=True)
|
|
777
778
|
if isinstance(context.report, ServiceReportContext):
|
|
778
779
|
click.secho("Report to Schemathesis.io: ENABLED", bold=True)
|
|
780
|
+
if context.initialization_lines:
|
|
781
|
+
_print_lines(context.initialization_lines)
|
|
779
782
|
|
|
780
783
|
|
|
781
784
|
def handle_before_probing(context: ExecutionContext, event: events.BeforeProbing) -> None:
|
|
@@ -852,12 +855,20 @@ def handle_finished(context: ExecutionContext, event: events.Finished) -> None:
|
|
|
852
855
|
display_statistic(context, event)
|
|
853
856
|
if context.summary_lines:
|
|
854
857
|
click.echo()
|
|
855
|
-
|
|
856
|
-
click.echo(line)
|
|
858
|
+
_print_lines(context.summary_lines)
|
|
857
859
|
click.echo()
|
|
858
860
|
display_summary(event)
|
|
859
861
|
|
|
860
862
|
|
|
863
|
+
def _print_lines(lines: list[str | Generator[str, None, None]]) -> None:
|
|
864
|
+
for entry in lines:
|
|
865
|
+
if isinstance(entry, str):
|
|
866
|
+
click.echo(entry)
|
|
867
|
+
elif isinstance(entry, GeneratorType):
|
|
868
|
+
for line in entry:
|
|
869
|
+
click.echo(line)
|
|
870
|
+
|
|
871
|
+
|
|
861
872
|
def handle_interrupted(context: ExecutionContext, event: events.Interrupted) -> None:
|
|
862
873
|
click.echo()
|
|
863
874
|
_handle_interrupted(context)
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
-
from contextlib import suppress
|
|
4
|
+
from contextlib import contextmanager, suppress
|
|
5
5
|
from dataclasses import dataclass, field
|
|
6
6
|
from functools import lru_cache
|
|
7
|
-
from typing import Any, Generator, Set, TypeVar, cast
|
|
7
|
+
from typing import Any, Generator, Set, Type, TypeVar, cast
|
|
8
8
|
|
|
9
9
|
import jsonschema
|
|
10
10
|
from hypothesis import strategies as st
|
|
11
|
-
from hypothesis.errors import Unsatisfiable
|
|
11
|
+
from hypothesis.errors import InvalidArgument, Unsatisfiable
|
|
12
12
|
from hypothesis_jsonschema import from_schema
|
|
13
13
|
from hypothesis_jsonschema._canonicalise import canonicalish
|
|
14
14
|
|
|
15
|
+
from schemathesis.constants import NOT_SET
|
|
16
|
+
|
|
15
17
|
from ._hypothesis import combine_strategies, get_single_example
|
|
16
18
|
from ._methods import DataGenerationMethod
|
|
17
19
|
|
|
@@ -29,6 +31,26 @@ UNKNOWN_PROPERTY_KEY = "x-schemathesis-unknown-property"
|
|
|
29
31
|
UNKNOWN_PROPERTY_VALUE = 42
|
|
30
32
|
|
|
31
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
|
+
|
|
32
54
|
@lru_cache(maxsize=128)
|
|
33
55
|
def cached_draw(strategy: st.SearchStrategy) -> Any:
|
|
34
56
|
return get_single_example(strategy)
|
|
@@ -67,11 +89,9 @@ def _to_hashable_key(value: T) -> T | tuple[type, str]:
|
|
|
67
89
|
return value
|
|
68
90
|
|
|
69
91
|
|
|
70
|
-
def
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def _cover_positive_for_type(ctx: CoverageContext, schema: dict, ty: str | None) -> Generator:
|
|
92
|
+
def _cover_positive_for_type(
|
|
93
|
+
ctx: CoverageContext, schema: dict, ty: str | None
|
|
94
|
+
) -> Generator[GeneratedValue, None, None]:
|
|
75
95
|
if ty == "object" or ty == "array":
|
|
76
96
|
template_schema = _get_template_schema(schema, ty)
|
|
77
97
|
template = ctx.generate_from_schema(template_schema)
|
|
@@ -79,8 +99,8 @@ def _cover_positive_for_type(ctx: CoverageContext, schema: dict, ty: str | None)
|
|
|
79
99
|
template = None
|
|
80
100
|
if DataGenerationMethod.positive in ctx.data_generation_methods:
|
|
81
101
|
ctx = ctx.with_positive()
|
|
82
|
-
enum = schema.get("enum")
|
|
83
|
-
const = schema.get("const")
|
|
102
|
+
enum = schema.get("enum", NOT_SET)
|
|
103
|
+
const = schema.get("const", NOT_SET)
|
|
84
104
|
for key in ("anyOf", "oneOf"):
|
|
85
105
|
sub_schemas = schema.get(key)
|
|
86
106
|
if sub_schemas is not None:
|
|
@@ -91,43 +111,70 @@ def _cover_positive_for_type(ctx: CoverageContext, schema: dict, ty: str | None)
|
|
|
91
111
|
if len(all_of) == 1:
|
|
92
112
|
yield from cover_schema_iter(ctx, all_of[0])
|
|
93
113
|
else:
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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)
|
|
100
122
|
elif ty is not None:
|
|
101
123
|
if ty == "null":
|
|
102
|
-
yield None
|
|
103
|
-
|
|
104
|
-
yield True
|
|
105
|
-
yield False
|
|
106
|
-
|
|
124
|
+
yield PositiveValue(None)
|
|
125
|
+
elif ty == "boolean":
|
|
126
|
+
yield PositiveValue(True)
|
|
127
|
+
yield PositiveValue(False)
|
|
128
|
+
elif ty == "string":
|
|
107
129
|
yield from _positive_string(ctx, schema)
|
|
108
|
-
|
|
130
|
+
elif ty == "integer" or ty == "number":
|
|
109
131
|
yield from _positive_number(ctx, schema)
|
|
110
|
-
|
|
132
|
+
elif ty == "array":
|
|
111
133
|
yield from _positive_array(ctx, schema, cast(list, template))
|
|
112
|
-
|
|
134
|
+
elif ty == "object":
|
|
113
135
|
yield from _positive_object(ctx, schema, cast(dict, template))
|
|
114
136
|
|
|
115
137
|
|
|
116
|
-
|
|
117
|
-
|
|
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", [])
|
|
118
165
|
if not isinstance(types, list):
|
|
119
|
-
types = [types]
|
|
166
|
+
types = [types] # type: ignore[unreachable]
|
|
120
167
|
if not types:
|
|
121
|
-
with
|
|
168
|
+
with _ignore_unfixable():
|
|
122
169
|
yield from _cover_positive_for_type(ctx, schema, None)
|
|
123
170
|
for ty in types:
|
|
124
|
-
with
|
|
171
|
+
with _ignore_unfixable():
|
|
125
172
|
yield from _cover_positive_for_type(ctx, schema, ty)
|
|
126
173
|
if DataGenerationMethod.negative in ctx.data_generation_methods:
|
|
127
174
|
template = None
|
|
128
175
|
seen: Set[Any | tuple[type, str]] = set()
|
|
129
176
|
for key, value in schema.items():
|
|
130
|
-
with
|
|
177
|
+
with _ignore_unfixable():
|
|
131
178
|
if key == "enum":
|
|
132
179
|
yield from _negative_enum(ctx, value)
|
|
133
180
|
elif key == "const":
|
|
@@ -143,21 +190,27 @@ def cover_schema_iter(ctx: CoverageContext, schema: dict) -> Generator:
|
|
|
143
190
|
yield from _negative_format(ctx, schema, value)
|
|
144
191
|
elif key == "maximum":
|
|
145
192
|
next = value + 1
|
|
146
|
-
yield next
|
|
193
|
+
yield NegativeValue(next)
|
|
147
194
|
seen.add(next)
|
|
148
195
|
elif key == "minimum":
|
|
149
196
|
next = value - 1
|
|
150
|
-
yield next
|
|
197
|
+
yield NegativeValue(next)
|
|
151
198
|
seen.add(next)
|
|
152
199
|
elif key == "exclusiveMaximum" or key == "exclusiveMinimum" and value not in seen:
|
|
153
|
-
yield value
|
|
200
|
+
yield NegativeValue(value)
|
|
154
201
|
seen.add(value)
|
|
155
202
|
elif key == "multipleOf":
|
|
156
203
|
yield from _negative_multiple_of(ctx, schema, value)
|
|
157
204
|
elif key == "minLength" and 0 < value < BUFFER_SIZE and "pattern" not in schema:
|
|
158
|
-
|
|
205
|
+
with suppress(InvalidArgument):
|
|
206
|
+
yield NegativeValue(
|
|
207
|
+
ctx.generate_from_schema({**schema, "minLength": value - 1, "maxLength": value - 1})
|
|
208
|
+
)
|
|
159
209
|
elif key == "maxLength" and value < BUFFER_SIZE and "pattern" not in schema:
|
|
160
|
-
|
|
210
|
+
with suppress(InvalidArgument):
|
|
211
|
+
yield NegativeValue(
|
|
212
|
+
ctx.generate_from_schema({**schema, "minLength": value + 1, "maxLength": value + 1})
|
|
213
|
+
)
|
|
161
214
|
elif key == "uniqueItems" and value:
|
|
162
215
|
yield from _negative_unique_items(ctx, schema)
|
|
163
216
|
elif key == "required":
|
|
@@ -165,14 +218,15 @@ def cover_schema_iter(ctx: CoverageContext, schema: dict) -> Generator:
|
|
|
165
218
|
yield from _negative_required(ctx, template, value)
|
|
166
219
|
elif key == "additionalProperties" and not value:
|
|
167
220
|
template = template or ctx.generate_from_schema(_get_template_schema(schema, "object"))
|
|
168
|
-
yield {**template, UNKNOWN_PROPERTY_KEY: UNKNOWN_PROPERTY_VALUE}
|
|
221
|
+
yield NegativeValue({**template, UNKNOWN_PROPERTY_KEY: UNKNOWN_PROPERTY_VALUE})
|
|
169
222
|
elif key == "allOf":
|
|
170
223
|
nctx = ctx.with_negative()
|
|
171
224
|
if len(value) == 1:
|
|
172
225
|
yield from cover_schema_iter(nctx, value[0])
|
|
173
226
|
else:
|
|
174
|
-
|
|
175
|
-
|
|
227
|
+
with _ignore_unfixable():
|
|
228
|
+
canonical = canonicalish(schema)
|
|
229
|
+
yield from cover_schema_iter(nctx, canonical)
|
|
176
230
|
elif key == "anyOf" or key == "oneOf":
|
|
177
231
|
nctx = ctx.with_negative()
|
|
178
232
|
# NOTE: Other sub-schemas are not filtered out
|
|
@@ -180,6 +234,15 @@ def cover_schema_iter(ctx: CoverageContext, schema: dict) -> Generator:
|
|
|
180
234
|
yield from cover_schema_iter(nctx, sub_schema)
|
|
181
235
|
|
|
182
236
|
|
|
237
|
+
def _get_properties(schema: dict | bool) -> dict | bool:
|
|
238
|
+
if isinstance(schema, dict):
|
|
239
|
+
if "example" in schema:
|
|
240
|
+
return {"const": schema["example"]}
|
|
241
|
+
if schema.get("type") == "object":
|
|
242
|
+
return _get_template_schema(schema, "object")
|
|
243
|
+
return schema
|
|
244
|
+
|
|
245
|
+
|
|
183
246
|
def _get_template_schema(schema: dict, ty: str) -> dict:
|
|
184
247
|
if ty == "object":
|
|
185
248
|
properties = schema.get("properties")
|
|
@@ -188,41 +251,40 @@ def _get_template_schema(schema: dict, ty: str) -> dict:
|
|
|
188
251
|
**schema,
|
|
189
252
|
"required": list(properties),
|
|
190
253
|
"type": ty,
|
|
191
|
-
"properties": {
|
|
192
|
-
k: _get_template_schema(v, "object") if v.get("type") == "object" else v
|
|
193
|
-
for k, v in properties.items()
|
|
194
|
-
},
|
|
254
|
+
"properties": {k: _get_properties(v) for k, v in properties.items()},
|
|
195
255
|
}
|
|
196
256
|
return {**schema, "type": ty}
|
|
197
257
|
|
|
198
258
|
|
|
199
|
-
def _positive_string(ctx: CoverageContext, schema: dict) -> Generator:
|
|
259
|
+
def _positive_string(ctx: CoverageContext, schema: dict) -> Generator[GeneratedValue, None, None]:
|
|
200
260
|
"""Generate positive string values."""
|
|
201
261
|
# Boundary and near boundary values
|
|
202
262
|
min_length = schema.get("minLength")
|
|
203
263
|
max_length = schema.get("maxLength")
|
|
204
264
|
|
|
205
|
-
if
|
|
265
|
+
if "example" in schema:
|
|
266
|
+
yield PositiveValue(schema["example"])
|
|
267
|
+
elif not min_length and not max_length:
|
|
206
268
|
# Default positive value
|
|
207
|
-
yield ctx.generate_from_schema(schema)
|
|
269
|
+
yield PositiveValue(ctx.generate_from_schema(schema))
|
|
208
270
|
|
|
209
271
|
seen = set()
|
|
210
272
|
|
|
211
273
|
if min_length is not None and min_length < BUFFER_SIZE and "pattern" not in schema:
|
|
212
274
|
# Exactly the minimum length
|
|
213
|
-
yield ctx.generate_from_schema({**schema, "maxLength": min_length})
|
|
275
|
+
yield PositiveValue(ctx.generate_from_schema({**schema, "maxLength": min_length}))
|
|
214
276
|
seen.add(min_length)
|
|
215
277
|
|
|
216
278
|
# One character more than minimum if possible
|
|
217
279
|
larger = min_length + 1
|
|
218
280
|
if larger < BUFFER_SIZE and larger not in seen and (not max_length or larger <= max_length):
|
|
219
|
-
yield ctx.generate_from_schema({**schema, "minLength": larger, "maxLength": larger})
|
|
281
|
+
yield PositiveValue(ctx.generate_from_schema({**schema, "minLength": larger, "maxLength": larger}))
|
|
220
282
|
seen.add(larger)
|
|
221
283
|
|
|
222
284
|
if max_length is not None and "pattern" not in schema:
|
|
223
285
|
# Exactly the maximum length
|
|
224
286
|
if max_length < BUFFER_SIZE and max_length not in seen:
|
|
225
|
-
yield ctx.generate_from_schema({**schema, "minLength": max_length})
|
|
287
|
+
yield PositiveValue(ctx.generate_from_schema({**schema, "minLength": max_length}))
|
|
226
288
|
seen.add(max_length)
|
|
227
289
|
|
|
228
290
|
# One character less than maximum if possible
|
|
@@ -232,7 +294,7 @@ def _positive_string(ctx: CoverageContext, schema: dict) -> Generator:
|
|
|
232
294
|
and smaller not in seen
|
|
233
295
|
and (smaller > 0 and (min_length is None or smaller >= min_length))
|
|
234
296
|
):
|
|
235
|
-
yield ctx.generate_from_schema({**schema, "minLength": smaller, "maxLength": smaller})
|
|
297
|
+
yield PositiveValue(ctx.generate_from_schema({**schema, "minLength": smaller, "maxLength": smaller}))
|
|
236
298
|
seen.add(smaller)
|
|
237
299
|
|
|
238
300
|
|
|
@@ -244,7 +306,7 @@ def closest_multiple_greater_than(y: int, x: int) -> int:
|
|
|
244
306
|
return x * (quotient + 1)
|
|
245
307
|
|
|
246
308
|
|
|
247
|
-
def _positive_number(ctx: CoverageContext, schema: dict) -> Generator:
|
|
309
|
+
def _positive_number(ctx: CoverageContext, schema: dict) -> Generator[GeneratedValue, None, None]:
|
|
248
310
|
"""Generate positive integer values."""
|
|
249
311
|
# Boundary and near boundary values
|
|
250
312
|
minimum = schema.get("minimum")
|
|
@@ -257,9 +319,11 @@ def _positive_number(ctx: CoverageContext, schema: dict) -> Generator:
|
|
|
257
319
|
maximum = exclusive_maximum - 1
|
|
258
320
|
multiple_of = schema.get("multipleOf")
|
|
259
321
|
|
|
260
|
-
if
|
|
322
|
+
if "example" in schema:
|
|
323
|
+
yield PositiveValue(schema["example"])
|
|
324
|
+
elif not minimum and not maximum:
|
|
261
325
|
# Default positive value
|
|
262
|
-
yield ctx.generate_from_schema(schema)
|
|
326
|
+
yield PositiveValue(ctx.generate_from_schema(schema))
|
|
263
327
|
|
|
264
328
|
seen = set()
|
|
265
329
|
|
|
@@ -270,7 +334,7 @@ def _positive_number(ctx: CoverageContext, schema: dict) -> Generator:
|
|
|
270
334
|
else:
|
|
271
335
|
smallest = minimum
|
|
272
336
|
seen.add(smallest)
|
|
273
|
-
yield smallest
|
|
337
|
+
yield PositiveValue(smallest)
|
|
274
338
|
|
|
275
339
|
# One more than minimum if possible
|
|
276
340
|
if multiple_of is not None:
|
|
@@ -279,7 +343,7 @@ def _positive_number(ctx: CoverageContext, schema: dict) -> Generator:
|
|
|
279
343
|
larger = minimum + 1
|
|
280
344
|
if larger not in seen and (not maximum or larger <= maximum):
|
|
281
345
|
seen.add(larger)
|
|
282
|
-
yield larger
|
|
346
|
+
yield PositiveValue(larger)
|
|
283
347
|
|
|
284
348
|
if maximum is not None:
|
|
285
349
|
# Exactly the maximum
|
|
@@ -289,7 +353,7 @@ def _positive_number(ctx: CoverageContext, schema: dict) -> Generator:
|
|
|
289
353
|
largest = maximum
|
|
290
354
|
if largest not in seen:
|
|
291
355
|
seen.add(largest)
|
|
292
|
-
yield largest
|
|
356
|
+
yield PositiveValue(largest)
|
|
293
357
|
|
|
294
358
|
# One less than maximum if possible
|
|
295
359
|
if multiple_of is not None:
|
|
@@ -298,12 +362,15 @@ def _positive_number(ctx: CoverageContext, schema: dict) -> Generator:
|
|
|
298
362
|
smaller = maximum - 1
|
|
299
363
|
if smaller not in seen and (smaller > 0 and (minimum is None or smaller >= minimum)):
|
|
300
364
|
seen.add(smaller)
|
|
301
|
-
yield smaller
|
|
365
|
+
yield PositiveValue(smaller)
|
|
302
366
|
|
|
303
367
|
|
|
304
|
-
def _positive_array(ctx: CoverageContext, schema: dict, template: list) -> Generator:
|
|
368
|
+
def _positive_array(ctx: CoverageContext, schema: dict, template: list) -> Generator[GeneratedValue, None, None]:
|
|
305
369
|
seen = set()
|
|
306
|
-
|
|
370
|
+
if "example" in schema:
|
|
371
|
+
yield PositiveValue(schema["example"])
|
|
372
|
+
else:
|
|
373
|
+
yield PositiveValue(template)
|
|
307
374
|
seen.add(len(template))
|
|
308
375
|
|
|
309
376
|
# Boundary and near-boundary sizes
|
|
@@ -315,99 +382,102 @@ def _positive_array(ctx: CoverageContext, schema: dict, template: list) -> Gener
|
|
|
315
382
|
# One item more than minimum if possible
|
|
316
383
|
larger = min_items + 1
|
|
317
384
|
if larger not in seen and (max_items is None or larger <= max_items):
|
|
318
|
-
yield ctx.generate_from_schema({**schema, "minItems": larger, "maxItems": larger})
|
|
385
|
+
yield PositiveValue(ctx.generate_from_schema({**schema, "minItems": larger, "maxItems": larger}))
|
|
319
386
|
seen.add(larger)
|
|
320
387
|
|
|
321
388
|
if max_items is not None:
|
|
322
|
-
if max_items not in seen:
|
|
323
|
-
yield ctx.generate_from_schema({**schema, "minItems": max_items})
|
|
389
|
+
if max_items < BUFFER_SIZE and max_items not in seen:
|
|
390
|
+
yield PositiveValue(ctx.generate_from_schema({**schema, "minItems": max_items}))
|
|
324
391
|
seen.add(max_items)
|
|
325
392
|
|
|
326
393
|
# One item smaller than maximum if possible
|
|
327
394
|
smaller = max_items - 1
|
|
328
|
-
if
|
|
329
|
-
|
|
395
|
+
if (
|
|
396
|
+
smaller < BUFFER_SIZE
|
|
397
|
+
and smaller > 0
|
|
398
|
+
and smaller not in seen
|
|
399
|
+
and (min_items is None or smaller >= min_items)
|
|
400
|
+
):
|
|
401
|
+
yield PositiveValue(ctx.generate_from_schema({**schema, "minItems": smaller, "maxItems": smaller}))
|
|
330
402
|
seen.add(smaller)
|
|
331
403
|
|
|
332
404
|
|
|
333
|
-
def _positive_object(ctx: CoverageContext, schema: dict, template: dict) -> Generator:
|
|
334
|
-
|
|
405
|
+
def _positive_object(ctx: CoverageContext, schema: dict, template: dict) -> Generator[GeneratedValue, None, None]:
|
|
406
|
+
if "example" in schema:
|
|
407
|
+
yield PositiveValue(schema["example"])
|
|
408
|
+
else:
|
|
409
|
+
yield PositiveValue(template)
|
|
335
410
|
# Only required properties
|
|
336
411
|
properties = schema.get("properties", {})
|
|
337
412
|
if set(properties) != set(schema.get("required", {})):
|
|
338
413
|
only_required = {k: v for k, v in template.items() if k in schema.get("required", [])}
|
|
339
|
-
yield only_required
|
|
414
|
+
yield PositiveValue(only_required)
|
|
340
415
|
seen = set()
|
|
341
416
|
for name, sub_schema in properties.items():
|
|
342
417
|
seen.add(_to_hashable_key(template.get(name)))
|
|
343
418
|
for new in cover_schema_iter(ctx, sub_schema):
|
|
344
|
-
key = _to_hashable_key(new)
|
|
419
|
+
key = _to_hashable_key(new.value)
|
|
345
420
|
if key not in seen:
|
|
346
|
-
yield {**template, name: new}
|
|
421
|
+
yield PositiveValue({**template, name: new.value})
|
|
347
422
|
seen.add(key)
|
|
348
423
|
seen.clear()
|
|
349
424
|
|
|
350
425
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
return JSON_STRATEGY.filter(lambda x: x not in value)
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
def _negative_enum(ctx: CoverageContext, value: list) -> Generator:
|
|
357
|
-
try:
|
|
358
|
-
strategy = _get_negative_enum_strategy(tuple(value))
|
|
359
|
-
except TypeError:
|
|
360
|
-
# The value is not hashable
|
|
361
|
-
strategy = JSON_STRATEGY.filter(lambda x: x not in value)
|
|
426
|
+
def _negative_enum(ctx: CoverageContext, value: list) -> Generator[GeneratedValue, None, None]:
|
|
427
|
+
strategy = JSON_STRATEGY.filter(lambda x: x not in value)
|
|
362
428
|
# The exact negative value is not important here
|
|
363
|
-
yield ctx.generate_from(strategy, cached=True)
|
|
429
|
+
yield NegativeValue(ctx.generate_from(strategy, cached=True))
|
|
364
430
|
|
|
365
431
|
|
|
366
|
-
def _negative_properties(
|
|
432
|
+
def _negative_properties(
|
|
433
|
+
ctx: CoverageContext, template: dict, properties: dict
|
|
434
|
+
) -> Generator[GeneratedValue, None, None]:
|
|
367
435
|
nctx = ctx.with_negative()
|
|
368
436
|
for key, sub_schema in properties.items():
|
|
369
437
|
for value in cover_schema_iter(nctx, sub_schema):
|
|
370
|
-
yield {**template, key: value}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
@lru_cache(maxsize=128)
|
|
374
|
-
def _get_negative_pattern_strategy(value: str) -> st.SearchStrategy:
|
|
375
|
-
return st.text().filter(lambda x: x != value)
|
|
438
|
+
yield NegativeValue({**template, key: value.value})
|
|
376
439
|
|
|
377
440
|
|
|
378
|
-
def _negative_pattern(ctx: CoverageContext, pattern: str) -> Generator:
|
|
379
|
-
yield ctx.generate_from(
|
|
441
|
+
def _negative_pattern(ctx: CoverageContext, pattern: str) -> Generator[GeneratedValue, None, None]:
|
|
442
|
+
yield NegativeValue(ctx.generate_from(st.text().filter(lambda x: x != pattern), cached=True))
|
|
380
443
|
|
|
381
444
|
|
|
382
445
|
def _with_negated_key(schema: dict, key: str, value: Any) -> dict:
|
|
383
446
|
return {"allOf": [{k: v for k, v in schema.items() if k != key}, {"not": {key: value}}]}
|
|
384
447
|
|
|
385
448
|
|
|
386
|
-
def _negative_multiple_of(
|
|
387
|
-
|
|
449
|
+
def _negative_multiple_of(
|
|
450
|
+
ctx: CoverageContext, schema: dict, multiple_of: int | float
|
|
451
|
+
) -> Generator[GeneratedValue, None, None]:
|
|
452
|
+
yield NegativeValue(ctx.generate_from_schema(_with_negated_key(schema, "multipleOf", multiple_of)))
|
|
388
453
|
|
|
389
454
|
|
|
390
|
-
def _negative_unique_items(ctx: CoverageContext, schema: dict) -> Generator:
|
|
455
|
+
def _negative_unique_items(ctx: CoverageContext, schema: dict) -> Generator[GeneratedValue, None, None]:
|
|
391
456
|
unique = ctx.generate_from_schema({**schema, "type": "array", "minItems": 1, "maxItems": 1})
|
|
392
|
-
yield unique + unique
|
|
457
|
+
yield NegativeValue(unique + unique)
|
|
393
458
|
|
|
394
459
|
|
|
395
|
-
def _negative_required(
|
|
460
|
+
def _negative_required(
|
|
461
|
+
ctx: CoverageContext, template: dict, required: list[str]
|
|
462
|
+
) -> Generator[GeneratedValue, None, None]:
|
|
396
463
|
for key in required:
|
|
397
|
-
yield {k: v for k, v in template.items() if k != key}
|
|
464
|
+
yield NegativeValue({k: v for k, v in template.items() if k != key})
|
|
398
465
|
|
|
399
466
|
|
|
400
|
-
def _negative_format(ctx: CoverageContext, schema: dict, format: str) -> Generator:
|
|
467
|
+
def _negative_format(ctx: CoverageContext, schema: dict, format: str) -> Generator[GeneratedValue, None, None]:
|
|
401
468
|
# Hypothesis-jsonschema does not canonicalise it properly right now, which leads to unsatisfiable schema
|
|
402
469
|
without_format = {k: v for k, v in schema.items() if k != "format"}
|
|
403
470
|
without_format.setdefault("type", "string")
|
|
404
471
|
strategy = from_schema(without_format)
|
|
405
472
|
if format in jsonschema.Draft202012Validator.FORMAT_CHECKER.checkers:
|
|
406
|
-
strategy = strategy.filter(
|
|
407
|
-
|
|
473
|
+
strategy = strategy.filter(
|
|
474
|
+
lambda v: (format == "hostname" and v == "")
|
|
475
|
+
or not jsonschema.Draft202012Validator.FORMAT_CHECKER.conforms(v, format)
|
|
476
|
+
)
|
|
477
|
+
yield NegativeValue(ctx.generate_from(strategy))
|
|
408
478
|
|
|
409
479
|
|
|
410
|
-
def _negative_type(ctx: CoverageContext, seen: set, ty: str | list[str]) -> Generator:
|
|
480
|
+
def _negative_type(ctx: CoverageContext, seen: set, ty: str | list[str]) -> Generator[GeneratedValue, None, None]:
|
|
411
481
|
strategies = {
|
|
412
482
|
"integer": st.integers(),
|
|
413
483
|
"number": NUMERIC_STRATEGY,
|
|
@@ -429,5 +499,5 @@ def _negative_type(ctx: CoverageContext, seen: set, ty: str | list[str]) -> Gene
|
|
|
429
499
|
strategies["number"] = FLOAT_STRATEGY.filter(lambda x: x != int(x))
|
|
430
500
|
negative_strategy = combine_strategies(tuple(strategies.values())).filter(lambda x: _to_hashable_key(x) not in seen)
|
|
431
501
|
value = ctx.generate_from(negative_strategy, cached=True)
|
|
432
|
-
yield value
|
|
502
|
+
yield NegativeValue(value)
|
|
433
503
|
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
|
|
schemathesis/runner/events.py
CHANGED
|
@@ -14,7 +14,7 @@ from .serialization import SerializedError, SerializedTestResult
|
|
|
14
14
|
|
|
15
15
|
if TYPE_CHECKING:
|
|
16
16
|
from ..models import APIOperation, Status, TestResult, TestResultSet
|
|
17
|
-
from ..schemas import BaseSchema
|
|
17
|
+
from ..schemas import BaseSchema, Specification
|
|
18
18
|
from ..service.models import AnalysisResult
|
|
19
19
|
from ..stateful import events
|
|
20
20
|
from . import probes
|
|
@@ -39,6 +39,7 @@ class Initialized(ExecutionEvent):
|
|
|
39
39
|
"""Runner is initialized, settings are prepared, requests session is ready."""
|
|
40
40
|
|
|
41
41
|
schema: dict[str, Any]
|
|
42
|
+
specification: Specification
|
|
42
43
|
# Total number of operations in the schema
|
|
43
44
|
operations_count: int | None
|
|
44
45
|
# Total number of links in the schema
|
|
@@ -71,6 +72,7 @@ class Initialized(ExecutionEvent):
|
|
|
71
72
|
"""Computes all needed data from a schema instance."""
|
|
72
73
|
return cls(
|
|
73
74
|
schema=schema.raw_schema,
|
|
75
|
+
specification=schema.specification,
|
|
74
76
|
operations_count=schema.operations_count if count_operations else None,
|
|
75
77
|
links_count=schema.links_count if count_links else None,
|
|
76
78
|
location=schema.location,
|