schemathesis 4.1.3__py3-none-any.whl → 4.2.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/cli/commands/run/executor.py +1 -1
- schemathesis/cli/commands/run/handlers/base.py +28 -1
- schemathesis/cli/commands/run/handlers/cassettes.py +10 -12
- schemathesis/cli/commands/run/handlers/junitxml.py +5 -6
- schemathesis/cli/commands/run/handlers/output.py +7 -1
- schemathesis/cli/ext/fs.py +1 -1
- schemathesis/config/_diff_base.py +3 -1
- schemathesis/config/_operations.py +2 -0
- schemathesis/config/_phases.py +21 -4
- schemathesis/config/_projects.py +10 -2
- schemathesis/core/adapter.py +34 -0
- schemathesis/core/errors.py +29 -5
- schemathesis/core/jsonschema/__init__.py +13 -0
- schemathesis/core/jsonschema/bundler.py +163 -0
- schemathesis/{specs/openapi/constants.py → core/jsonschema/keywords.py} +0 -8
- schemathesis/core/jsonschema/references.py +122 -0
- schemathesis/core/jsonschema/types.py +41 -0
- schemathesis/core/media_types.py +6 -4
- schemathesis/core/parameters.py +37 -0
- schemathesis/core/transforms.py +25 -2
- schemathesis/core/validation.py +19 -0
- schemathesis/engine/context.py +1 -1
- schemathesis/engine/errors.py +11 -18
- schemathesis/engine/phases/stateful/_executor.py +1 -1
- schemathesis/engine/phases/unit/_executor.py +30 -13
- schemathesis/errors.py +4 -0
- schemathesis/filters.py +2 -2
- schemathesis/generation/coverage.py +89 -13
- schemathesis/generation/hypothesis/__init__.py +4 -1
- schemathesis/generation/hypothesis/builder.py +108 -70
- schemathesis/generation/meta.py +5 -14
- schemathesis/generation/overrides.py +17 -17
- schemathesis/pytest/lazy.py +1 -1
- schemathesis/pytest/plugin.py +1 -6
- schemathesis/schemas.py +22 -72
- schemathesis/specs/graphql/schemas.py +27 -16
- schemathesis/specs/openapi/_hypothesis.py +83 -68
- schemathesis/specs/openapi/adapter/__init__.py +10 -0
- schemathesis/specs/openapi/adapter/parameters.py +504 -0
- schemathesis/specs/openapi/adapter/protocol.py +57 -0
- schemathesis/specs/openapi/adapter/references.py +19 -0
- schemathesis/specs/openapi/adapter/responses.py +329 -0
- schemathesis/specs/openapi/adapter/security.py +141 -0
- schemathesis/specs/openapi/adapter/v2.py +28 -0
- schemathesis/specs/openapi/adapter/v3_0.py +28 -0
- schemathesis/specs/openapi/adapter/v3_1.py +28 -0
- schemathesis/specs/openapi/checks.py +99 -90
- schemathesis/specs/openapi/converter.py +114 -27
- schemathesis/specs/openapi/examples.py +210 -168
- schemathesis/specs/openapi/negative/__init__.py +12 -7
- schemathesis/specs/openapi/negative/mutations.py +68 -40
- schemathesis/specs/openapi/references.py +2 -175
- schemathesis/specs/openapi/schemas.py +142 -490
- schemathesis/specs/openapi/serialization.py +15 -7
- schemathesis/specs/openapi/stateful/__init__.py +17 -12
- schemathesis/specs/openapi/stateful/inference.py +13 -11
- schemathesis/specs/openapi/stateful/links.py +5 -20
- schemathesis/specs/openapi/types/__init__.py +3 -0
- schemathesis/specs/openapi/types/v3.py +68 -0
- schemathesis/specs/openapi/utils.py +1 -13
- schemathesis/transport/requests.py +3 -11
- schemathesis/transport/serialization.py +63 -27
- schemathesis/transport/wsgi.py +1 -8
- {schemathesis-4.1.3.dist-info → schemathesis-4.2.0.dist-info}/METADATA +2 -2
- {schemathesis-4.1.3.dist-info → schemathesis-4.2.0.dist-info}/RECORD +68 -53
- schemathesis/specs/openapi/parameters.py +0 -405
- schemathesis/specs/openapi/security.py +0 -162
- {schemathesis-4.1.3.dist-info → schemathesis-4.2.0.dist-info}/WHEEL +0 -0
- {schemathesis-4.1.3.dist-info → schemathesis-4.2.0.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.1.3.dist-info → schemathesis-4.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -105,7 +105,7 @@ def initialize_handlers(
|
|
105
105
|
if report.enabled:
|
106
106
|
path = config.reports.get_path(format)
|
107
107
|
open_file(path)
|
108
|
-
handlers.append(CassetteWriter(format=format,
|
108
|
+
handlers.append(CassetteWriter(format=format, output=path, config=config))
|
109
109
|
|
110
110
|
for custom_handler in CUSTOM_HANDLERS:
|
111
111
|
handlers.append(custom_handler(*args, **params))
|
@@ -1,6 +1,9 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
from
|
3
|
+
from contextlib import contextmanager
|
4
|
+
from io import StringIO
|
5
|
+
from pathlib import Path
|
6
|
+
from typing import IO, TYPE_CHECKING, Any, Generator, Protocol, Union
|
4
7
|
|
5
8
|
if TYPE_CHECKING:
|
6
9
|
from schemathesis.cli.commands.run.context import ExecutionContext
|
@@ -16,3 +19,27 @@ class EventHandler:
|
|
16
19
|
def start(self, ctx: ExecutionContext) -> None: ...
|
17
20
|
|
18
21
|
def shutdown(self, ctx: ExecutionContext) -> None: ...
|
22
|
+
|
23
|
+
|
24
|
+
class WritableText(Protocol):
|
25
|
+
"""Protocol for text-writable file-like objects."""
|
26
|
+
|
27
|
+
def write(self, s: str) -> int: ... # pragma: no cover
|
28
|
+
def flush(self) -> None: ... # pragma: no cover
|
29
|
+
|
30
|
+
|
31
|
+
TextOutput = Union[IO[str], StringIO, Path]
|
32
|
+
|
33
|
+
|
34
|
+
@contextmanager
|
35
|
+
def open_text_output(output: TextOutput) -> Generator[IO[str]]:
|
36
|
+
"""Open a text output, handling both Path and file-like objects."""
|
37
|
+
if isinstance(output, Path):
|
38
|
+
f = open(output, "w", encoding="utf-8")
|
39
|
+
try:
|
40
|
+
yield f
|
41
|
+
finally:
|
42
|
+
f.close()
|
43
|
+
else:
|
44
|
+
# Assume it's already a file-like object
|
45
|
+
yield output # type: ignore[misc]
|
@@ -6,7 +6,6 @@ import sys
|
|
6
6
|
import threading
|
7
7
|
from dataclasses import dataclass
|
8
8
|
from http.cookies import SimpleCookie
|
9
|
-
from pathlib import Path
|
10
9
|
from queue import Queue
|
11
10
|
from typing import IO, Callable, Iterator
|
12
11
|
from urllib.parse import parse_qsl, urlparse
|
@@ -14,7 +13,7 @@ from urllib.parse import parse_qsl, urlparse
|
|
14
13
|
import harfile
|
15
14
|
|
16
15
|
from schemathesis.cli.commands.run.context import ExecutionContext
|
17
|
-
from schemathesis.cli.commands.run.handlers.base import EventHandler
|
16
|
+
from schemathesis.cli.commands.run.handlers.base import EventHandler, TextOutput, open_text_output
|
18
17
|
from schemathesis.config import ProjectConfig, ReportFormat, SchemathesisConfig
|
19
18
|
from schemathesis.core.output.sanitization import sanitize_url, sanitize_value
|
20
19
|
from schemathesis.core.transforms import deepclone
|
@@ -33,27 +32,27 @@ class CassetteWriter(EventHandler):
|
|
33
32
|
"""Write network interactions to a cassette."""
|
34
33
|
|
35
34
|
format: ReportFormat
|
36
|
-
|
35
|
+
output: TextOutput
|
37
36
|
config: ProjectConfig
|
38
37
|
queue: Queue
|
39
38
|
worker: threading.Thread
|
40
39
|
|
41
|
-
__slots__ = ("format", "
|
40
|
+
__slots__ = ("format", "output", "config", "queue", "worker")
|
42
41
|
|
43
42
|
def __init__(
|
44
43
|
self,
|
45
44
|
format: ReportFormat,
|
46
|
-
|
45
|
+
output: TextOutput,
|
47
46
|
config: ProjectConfig,
|
48
47
|
queue: Queue | None = None,
|
49
48
|
) -> None:
|
50
49
|
self.format = format
|
51
|
-
self.
|
50
|
+
self.output = output
|
52
51
|
self.config = config
|
53
52
|
self.queue = queue or Queue()
|
54
53
|
|
55
54
|
kwargs = {
|
56
|
-
"
|
55
|
+
"output": self.output,
|
57
56
|
"config": self.config,
|
58
57
|
"queue": self.queue,
|
59
58
|
}
|
@@ -119,7 +118,7 @@ def get_command_representation() -> str:
|
|
119
118
|
return f"st {args}"
|
120
119
|
|
121
120
|
|
122
|
-
def vcr_writer(
|
121
|
+
def vcr_writer(output: TextOutput, config: ProjectConfig, queue: Queue) -> None:
|
123
122
|
"""Write YAML to a file in an incremental manner.
|
124
123
|
|
125
124
|
This implementation doesn't use `pyyaml` package and composes YAML manually as string due to the following reasons:
|
@@ -203,7 +202,7 @@ def vcr_writer(path: Path, config: ProjectConfig, queue: Queue) -> None:
|
|
203
202
|
)
|
204
203
|
write_double_quoted(output, string)
|
205
204
|
|
206
|
-
with
|
205
|
+
with open_text_output(output) as stream:
|
207
206
|
while True:
|
208
207
|
item = queue.get()
|
209
208
|
if isinstance(item, Initialize):
|
@@ -367,8 +366,8 @@ def write_double_quoted(stream: IO, text: str | None) -> None:
|
|
367
366
|
stream.write('"')
|
368
367
|
|
369
368
|
|
370
|
-
def har_writer(
|
371
|
-
with harfile.open(
|
369
|
+
def har_writer(output: TextOutput, config: SchemathesisConfig, queue: Queue) -> None:
|
370
|
+
with harfile.open(output) as har:
|
372
371
|
while True:
|
373
372
|
item = queue.get()
|
374
373
|
if isinstance(item, Process):
|
@@ -454,7 +453,6 @@ def har_writer(path: Path, config: SchemathesisConfig, queue: Queue) -> None:
|
|
454
453
|
)
|
455
454
|
elif isinstance(item, Finalize):
|
456
455
|
break
|
457
|
-
har.flush()
|
458
456
|
|
459
457
|
|
460
458
|
HARFILE_NO_RESPONSE = harfile.Response(
|
@@ -2,26 +2,25 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
import platform
|
4
4
|
from dataclasses import dataclass
|
5
|
-
from pathlib import Path
|
6
5
|
from typing import Iterable
|
7
6
|
|
8
7
|
from junit_xml import TestCase, TestSuite, to_xml_report_file
|
9
8
|
|
10
9
|
from schemathesis.cli.commands.run.context import ExecutionContext, GroupedFailures
|
11
|
-
from schemathesis.cli.commands.run.handlers.base import EventHandler
|
10
|
+
from schemathesis.cli.commands.run.handlers.base import EventHandler, TextOutput, open_text_output
|
12
11
|
from schemathesis.core.failures import format_failures
|
13
12
|
from schemathesis.engine import Status, events
|
14
13
|
|
15
14
|
|
16
15
|
@dataclass
|
17
16
|
class JunitXMLHandler(EventHandler):
|
18
|
-
|
17
|
+
output: TextOutput
|
19
18
|
test_cases: dict
|
20
19
|
|
21
20
|
__slots__ = ("path", "test_cases")
|
22
21
|
|
23
|
-
def __init__(self,
|
24
|
-
self.
|
22
|
+
def __init__(self, output: TextOutput, test_cases: dict | None = None) -> None:
|
23
|
+
self.output = output
|
25
24
|
self.test_cases = test_cases or {}
|
26
25
|
|
27
26
|
def handle_event(self, ctx: ExecutionContext, event: events.EngineEvent) -> None:
|
@@ -40,7 +39,7 @@ class JunitXMLHandler(EventHandler):
|
|
40
39
|
test_suites = [
|
41
40
|
TestSuite("schemathesis", test_cases=list(self.test_cases.values()), hostname=platform.node())
|
42
41
|
]
|
43
|
-
with
|
42
|
+
with open_text_output(self.output) as fd:
|
44
43
|
to_xml_report_file(file_descriptor=fd, test_suites=test_suites, prettyprint=True, encoding="utf-8")
|
45
44
|
|
46
45
|
def get_or_create_test_case(self, label: str) -> TestCase:
|
@@ -1053,6 +1053,8 @@ class OutputHandler(EventHandler):
|
|
1053
1053
|
self._check_stateful_warnings(ctx, event)
|
1054
1054
|
|
1055
1055
|
def _check_warnings(self, ctx: ExecutionContext, event: events.ScenarioFinished) -> None:
|
1056
|
+
from schemathesis.core.compat import RefResolutionError
|
1057
|
+
|
1056
1058
|
statistic = aggregate_status_codes(event.recorder.interactions.values())
|
1057
1059
|
|
1058
1060
|
if statistic.total == 0:
|
@@ -1060,7 +1062,11 @@ class OutputHandler(EventHandler):
|
|
1060
1062
|
|
1061
1063
|
assert ctx.find_operation_by_label is not None
|
1062
1064
|
assert event.label is not None
|
1063
|
-
|
1065
|
+
try:
|
1066
|
+
operation = ctx.find_operation_by_label(event.label)
|
1067
|
+
except RefResolutionError:
|
1068
|
+
# This error will be reported elsewhere anyway
|
1069
|
+
return None
|
1064
1070
|
|
1065
1071
|
warnings = self.config.warnings_for(operation=operation)
|
1066
1072
|
|
schemathesis/cli/ext/fs.py
CHANGED
@@ -12,5 +12,5 @@ def open_file(file: Path) -> None:
|
|
12
12
|
raise click.BadParameter(f"'{file.name}': {exc.strerror}") from exc
|
13
13
|
try:
|
14
14
|
file.open("w", encoding="utf-8")
|
15
|
-
except OSError as exc:
|
15
|
+
except (OSError, ValueError) as exc:
|
16
16
|
raise click.BadParameter(f"Could not open file {file.name}: {exc}") from exc
|
@@ -69,12 +69,14 @@ class DiffBase:
|
|
69
69
|
@classmethod
|
70
70
|
def from_hierarchy(cls, configs: list[T]) -> T:
|
71
71
|
# This config will accumulate "merged" config options
|
72
|
+
if len(configs) == 1:
|
73
|
+
return configs[0]
|
72
74
|
output = cls()
|
73
75
|
for option in cls.__slots__: # type: ignore
|
74
76
|
if option.startswith("_"):
|
75
77
|
continue
|
76
78
|
default = getattr(output, option)
|
77
|
-
if
|
79
|
+
if hasattr(default, "__dataclass_fields__"):
|
78
80
|
# Sub-configs require merging of nested config options
|
79
81
|
sub_configs = [getattr(config, option) for config in configs]
|
80
82
|
merged = type(default).from_hierarchy(sub_configs) # type: ignore[union-attr]
|
@@ -68,6 +68,8 @@ class OperationsConfig(DiffBase):
|
|
68
68
|
|
69
69
|
def get_for_operation(self, operation: APIOperation) -> OperationConfig:
|
70
70
|
configs = [config for config in self.operations if config._filter_set.applies_to(operation)]
|
71
|
+
if not configs:
|
72
|
+
return OperationConfig()
|
71
73
|
return OperationConfig.from_hierarchy(configs)
|
72
74
|
|
73
75
|
def create_filter_set(
|
schemathesis/config/_phases.py
CHANGED
@@ -17,7 +17,7 @@ class PhaseConfig(DiffBase):
|
|
17
17
|
generation: GenerationConfig
|
18
18
|
checks: ChecksConfig
|
19
19
|
|
20
|
-
__slots__ = ("enabled", "generation", "checks")
|
20
|
+
__slots__ = ("enabled", "generation", "checks", "_is_default")
|
21
21
|
|
22
22
|
def __init__(
|
23
23
|
self,
|
@@ -29,6 +29,7 @@ class PhaseConfig(DiffBase):
|
|
29
29
|
self.enabled = enabled
|
30
30
|
self.generation = generation or GenerationConfig()
|
31
31
|
self.checks = checks or ChecksConfig()
|
32
|
+
self._is_default = enabled and generation is None and checks is None
|
32
33
|
|
33
34
|
@classmethod
|
34
35
|
def from_dict(cls, data: dict[str, Any]) -> PhaseConfig:
|
@@ -46,7 +47,7 @@ class ExamplesPhaseConfig(DiffBase):
|
|
46
47
|
generation: GenerationConfig
|
47
48
|
checks: ChecksConfig
|
48
49
|
|
49
|
-
__slots__ = ("enabled", "fill_missing", "generation", "checks")
|
50
|
+
__slots__ = ("enabled", "fill_missing", "generation", "checks", "_is_default")
|
50
51
|
|
51
52
|
def __init__(
|
52
53
|
self,
|
@@ -60,6 +61,7 @@ class ExamplesPhaseConfig(DiffBase):
|
|
60
61
|
self.fill_missing = fill_missing
|
61
62
|
self.generation = generation or GenerationConfig()
|
62
63
|
self.checks = checks or ChecksConfig()
|
64
|
+
self._is_default = enabled and not fill_missing and generation is None and checks is None
|
63
65
|
|
64
66
|
@classmethod
|
65
67
|
def from_dict(cls, data: dict[str, Any]) -> ExamplesPhaseConfig:
|
@@ -79,7 +81,14 @@ class CoveragePhaseConfig(DiffBase):
|
|
79
81
|
checks: ChecksConfig
|
80
82
|
unexpected_methods: set[str]
|
81
83
|
|
82
|
-
__slots__ = (
|
84
|
+
__slots__ = (
|
85
|
+
"enabled",
|
86
|
+
"generate_duplicate_query_parameters",
|
87
|
+
"generation",
|
88
|
+
"checks",
|
89
|
+
"unexpected_methods",
|
90
|
+
"_is_default",
|
91
|
+
)
|
83
92
|
|
84
93
|
def __init__(
|
85
94
|
self,
|
@@ -95,6 +104,13 @@ class CoveragePhaseConfig(DiffBase):
|
|
95
104
|
self.unexpected_methods = unexpected_methods or DEFAULT_UNEXPECTED_METHODS
|
96
105
|
self.generation = generation or GenerationConfig()
|
97
106
|
self.checks = checks or ChecksConfig()
|
107
|
+
self._is_default = (
|
108
|
+
enabled
|
109
|
+
and not generate_duplicate_query_parameters
|
110
|
+
and generation is None
|
111
|
+
and checks is None
|
112
|
+
and unexpected_methods is None
|
113
|
+
)
|
98
114
|
|
99
115
|
@classmethod
|
100
116
|
def from_dict(cls, data: dict[str, Any]) -> CoveragePhaseConfig:
|
@@ -142,7 +158,7 @@ class StatefulPhaseConfig(DiffBase):
|
|
142
158
|
max_steps: int
|
143
159
|
inference: InferenceConfig
|
144
160
|
|
145
|
-
__slots__ = ("enabled", "generation", "checks", "max_steps", "inference")
|
161
|
+
__slots__ = ("enabled", "generation", "checks", "max_steps", "inference", "_is_default")
|
146
162
|
|
147
163
|
def __init__(
|
148
164
|
self,
|
@@ -158,6 +174,7 @@ class StatefulPhaseConfig(DiffBase):
|
|
158
174
|
self.generation = generation or GenerationConfig()
|
159
175
|
self.checks = checks or ChecksConfig()
|
160
176
|
self.inference = inference or InferenceConfig()
|
177
|
+
self._is_default = enabled and generation is None and checks is None and max_steps is None and inference is None
|
161
178
|
|
162
179
|
@classmethod
|
163
180
|
def from_dict(cls, data: dict[str, Any]) -> StatefulPhaseConfig:
|
schemathesis/config/_projects.py
CHANGED
@@ -347,6 +347,8 @@ class ProjectConfig(DiffBase):
|
|
347
347
|
for op in self.operations.operations:
|
348
348
|
if op._filter_set.applies_to(operation=operation):
|
349
349
|
configs.append(op.phases)
|
350
|
+
if not configs:
|
351
|
+
return self.phases
|
350
352
|
configs.append(self.phases)
|
351
353
|
return PhasesConfig.from_hierarchy(configs)
|
352
354
|
|
@@ -367,7 +369,10 @@ class ProjectConfig(DiffBase):
|
|
367
369
|
if phase is not None:
|
368
370
|
phases = self.phases_for(operation=operation)
|
369
371
|
phase_config = phases.get_by_name(name=phase)
|
370
|
-
|
372
|
+
if not phase_config._is_default:
|
373
|
+
configs.append(phase_config.generation)
|
374
|
+
if not configs:
|
375
|
+
return self.generation
|
371
376
|
configs.append(self.generation)
|
372
377
|
return GenerationConfig.from_hierarchy(configs)
|
373
378
|
|
@@ -388,7 +393,10 @@ class ProjectConfig(DiffBase):
|
|
388
393
|
if phase is not None:
|
389
394
|
phases = self.phases_for(operation=operation)
|
390
395
|
phase_config = phases.get_by_name(name=phase)
|
391
|
-
|
396
|
+
if not phase_config._is_default:
|
397
|
+
configs.append(phase_config.checks)
|
398
|
+
if not configs:
|
399
|
+
return self.checks
|
392
400
|
configs.append(self.checks)
|
393
401
|
return ChecksConfig.from_hierarchy(configs)
|
394
402
|
|
@@ -0,0 +1,34 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import Any, Protocol, TypeVar
|
4
|
+
|
5
|
+
from schemathesis.core.parameters import ParameterLocation
|
6
|
+
|
7
|
+
T = TypeVar("T", covariant=True)
|
8
|
+
|
9
|
+
|
10
|
+
class ResponsesContainer(Protocol[T]):
|
11
|
+
def find_by_status_code(self, status_code: int) -> T | None: ... # pragma: no cover
|
12
|
+
def add(self, status_code: str, definition: dict[str, Any]) -> T: ... # pragma: no cover
|
13
|
+
|
14
|
+
|
15
|
+
class OperationParameter(Protocol):
|
16
|
+
"""API parameter at a specific location (query, header, body, etc.)."""
|
17
|
+
|
18
|
+
definition: Any
|
19
|
+
"""Raw parameter definition from the API spec."""
|
20
|
+
|
21
|
+
@property
|
22
|
+
def location(self) -> ParameterLocation:
|
23
|
+
"""Location: "query", "header", "body", etc."""
|
24
|
+
... # pragma: no cover
|
25
|
+
|
26
|
+
@property
|
27
|
+
def name(self) -> str:
|
28
|
+
"""Parameter name."""
|
29
|
+
... # pragma: no cover
|
30
|
+
|
31
|
+
@property
|
32
|
+
def is_required(self) -> bool:
|
33
|
+
"""True if required."""
|
34
|
+
... # pragma: no cover
|
schemathesis/core/errors.py
CHANGED
@@ -17,6 +17,7 @@ if TYPE_CHECKING:
|
|
17
17
|
|
18
18
|
from schemathesis.config import OutputConfig
|
19
19
|
from schemathesis.core.compat import RefResolutionError
|
20
|
+
from schemathesis.core.jsonschema import BundleError
|
20
21
|
|
21
22
|
|
22
23
|
SCHEMA_ERROR_SUGGESTION = "Ensure that the definition complies with the OpenAPI specification"
|
@@ -26,11 +27,6 @@ SERIALIZATION_NOT_POSSIBLE_MESSAGE = f"No supported serializers for media types:
|
|
26
27
|
SERIALIZATION_FOR_TYPE_IS_NOT_POSSIBLE_MESSAGE = (
|
27
28
|
f"Cannot serialize to '{{}}' (unsupported media type)\n{SERIALIZERS_SUGGESTION_MESSAGE}"
|
28
29
|
)
|
29
|
-
RECURSIVE_REFERENCE_ERROR_MESSAGE = (
|
30
|
-
"Currently, Schemathesis can't generate data for this operation due to "
|
31
|
-
"recursive references in the operation definition. See more information in "
|
32
|
-
"this issue - https://github.com/schemathesis/schemathesis/issues/947"
|
33
|
-
)
|
34
30
|
|
35
31
|
|
36
32
|
class SchemathesisError(Exception):
|
@@ -50,6 +46,14 @@ class InvalidSchema(SchemathesisError):
|
|
50
46
|
self.path = path
|
51
47
|
self.method = method
|
52
48
|
|
49
|
+
@classmethod
|
50
|
+
def from_bundle_error(cls, error: BundleError, location: str, name: str | None = None) -> InvalidSchema:
|
51
|
+
if location == "body":
|
52
|
+
message = f"Can not generate data for {location}! {error}"
|
53
|
+
else:
|
54
|
+
message = f"Can not generate data for {location} parameter `{name}`! {error}"
|
55
|
+
return InvalidSchema(message)
|
56
|
+
|
53
57
|
@classmethod
|
54
58
|
def from_jsonschema_error(
|
55
59
|
cls, error: ValidationError | JsonSchemaError, path: str | None, method: str | None, config: OutputConfig
|
@@ -283,6 +287,26 @@ class UnboundPrefix(SerializationError):
|
|
283
287
|
super().__init__(UNBOUND_PREFIX_MESSAGE_TEMPLATE.format(prefix=prefix))
|
284
288
|
|
285
289
|
|
290
|
+
class UnresolvableReference(SchemathesisError):
|
291
|
+
"""A reference cannot be resolved."""
|
292
|
+
|
293
|
+
def __init__(self, reference: str) -> None:
|
294
|
+
self.reference = reference
|
295
|
+
|
296
|
+
def __str__(self) -> str:
|
297
|
+
return f"Reference `{self.reference}` cannot be resolved"
|
298
|
+
|
299
|
+
|
300
|
+
class InfiniteRecursiveReference(SchemathesisError):
|
301
|
+
"""Required recursive reference creates a cycle."""
|
302
|
+
|
303
|
+
def __init__(self, reference: str) -> None:
|
304
|
+
self.reference = reference
|
305
|
+
|
306
|
+
def __str__(self) -> str:
|
307
|
+
return f"Required reference `{self.reference}` creates a cycle"
|
308
|
+
|
309
|
+
|
286
310
|
class SerializationNotPossible(SerializationError):
|
287
311
|
"""Not possible to serialize data to specified media type(s).
|
288
312
|
|
@@ -0,0 +1,13 @@
|
|
1
|
+
from .bundler import BUNDLE_STORAGE_KEY, REFERENCE_TO_BUNDLE_PREFIX, BundleError, Bundler, bundle
|
2
|
+
from .keywords import ALL_KEYWORDS
|
3
|
+
from .types import get_type
|
4
|
+
|
5
|
+
__all__ = [
|
6
|
+
"ALL_KEYWORDS",
|
7
|
+
"bundle",
|
8
|
+
"Bundler",
|
9
|
+
"BundleError",
|
10
|
+
"REFERENCE_TO_BUNDLE_PREFIX",
|
11
|
+
"BUNDLE_STORAGE_KEY",
|
12
|
+
"get_type",
|
13
|
+
]
|
@@ -0,0 +1,163 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import TYPE_CHECKING, Any
|
4
|
+
|
5
|
+
from schemathesis.core.errors import InfiniteRecursiveReference
|
6
|
+
from schemathesis.core.jsonschema.references import sanitize
|
7
|
+
from schemathesis.core.jsonschema.types import JsonSchema, to_json_type_name
|
8
|
+
from schemathesis.core.transforms import deepclone
|
9
|
+
|
10
|
+
if TYPE_CHECKING:
|
11
|
+
from schemathesis.core.compat import RefResolver
|
12
|
+
|
13
|
+
|
14
|
+
BUNDLE_STORAGE_KEY = "x-bundled"
|
15
|
+
REFERENCE_TO_BUNDLE_PREFIX = f"#/{BUNDLE_STORAGE_KEY}"
|
16
|
+
|
17
|
+
|
18
|
+
class BundleError(Exception):
|
19
|
+
def __init__(self, reference: str, value: Any) -> None:
|
20
|
+
self.reference = reference
|
21
|
+
self.value = value
|
22
|
+
|
23
|
+
def __str__(self) -> str:
|
24
|
+
return f"Cannot bundle `{self.reference}`: expected JSON Schema (object or boolean), got {to_json_type_name(self.value)}"
|
25
|
+
|
26
|
+
|
27
|
+
class Bundler:
|
28
|
+
"""Bundler tracks schema ids stored in a bundle."""
|
29
|
+
|
30
|
+
counter: int
|
31
|
+
|
32
|
+
__slots__ = ("counter",)
|
33
|
+
|
34
|
+
def __init__(self) -> None:
|
35
|
+
self.counter = 0
|
36
|
+
|
37
|
+
def bundle(self, schema: JsonSchema, resolver: RefResolver, *, inline_recursive: bool) -> JsonSchema:
|
38
|
+
"""Bundle a JSON Schema by embedding all references."""
|
39
|
+
# Inlining recursive reference is required (for now) for data generation, but is unsound for data validation
|
40
|
+
if not isinstance(schema, dict):
|
41
|
+
return schema
|
42
|
+
|
43
|
+
# Track visited URIs and their local definition names
|
44
|
+
visited: set[str] = set()
|
45
|
+
uri_to_def_name: dict[str, str] = {}
|
46
|
+
defs = {}
|
47
|
+
|
48
|
+
has_recursive_references = False
|
49
|
+
resolve = resolver.resolve
|
50
|
+
visit = visited.add
|
51
|
+
|
52
|
+
def get_def_name(uri: str) -> str:
|
53
|
+
"""Generate or retrieve the local definition name for a URI."""
|
54
|
+
name = uri_to_def_name.get(uri)
|
55
|
+
if name is None:
|
56
|
+
self.counter += 1
|
57
|
+
name = f"schema{self.counter}"
|
58
|
+
uri_to_def_name[uri] = name
|
59
|
+
return name
|
60
|
+
|
61
|
+
def bundle_recursive(current: JsonSchema | list[JsonSchema]) -> JsonSchema | list[JsonSchema]:
|
62
|
+
"""Recursively process and bundle references in the current schema."""
|
63
|
+
# Local lookup is cheaper and it matters for large schemas.
|
64
|
+
# It works because this recursive call goes to every nested value
|
65
|
+
nonlocal has_recursive_references
|
66
|
+
_bundle_recursive = bundle_recursive
|
67
|
+
if isinstance(current, dict):
|
68
|
+
reference = current.get("$ref")
|
69
|
+
if isinstance(reference, str) and not reference.startswith(REFERENCE_TO_BUNDLE_PREFIX):
|
70
|
+
resolved_uri, resolved_schema = resolve(reference)
|
71
|
+
|
72
|
+
if not isinstance(resolved_schema, (dict, bool)):
|
73
|
+
raise BundleError(reference, resolved_schema)
|
74
|
+
def_name = get_def_name(resolved_uri)
|
75
|
+
|
76
|
+
is_recursive_reference = resolved_uri in resolver._scopes_stack
|
77
|
+
has_recursive_references |= is_recursive_reference
|
78
|
+
if inline_recursive and is_recursive_reference:
|
79
|
+
# This is a recursive reference! As of Sep 2025, `hypothesis-jsonschema` does not support
|
80
|
+
# recursive references and Schemathesis has to remove them if possible.
|
81
|
+
#
|
82
|
+
# Cutting them of immediately would limit the quality of generated data, since it would have
|
83
|
+
# just a single level of recursion. Currently, the only way to generate recursive data is to
|
84
|
+
# inline definitions directly, which can lead to schema size explosion.
|
85
|
+
#
|
86
|
+
# To balance it, Schemathesis inlines one level, that avoids exponential blowup of O(B ^ L)
|
87
|
+
# in worst case, where B is branching factor (number of recursive references per schema), and
|
88
|
+
# L is the number of levels. Even quadratic growth can be unacceptable for large schemas.
|
89
|
+
#
|
90
|
+
# In the future, it **should** be handled by `hypothesis-jsonschema` instead.
|
91
|
+
cloned = deepclone(resolved_schema)
|
92
|
+
remaining_references = sanitize(cloned)
|
93
|
+
if remaining_references:
|
94
|
+
# This schema is either infinitely recursive or the sanitization logic misses it, in any
|
95
|
+
# event, we git up here
|
96
|
+
raise InfiniteRecursiveReference(reference)
|
97
|
+
|
98
|
+
result = {key: _bundle_recursive(value) for key, value in current.items() if key != "$ref"}
|
99
|
+
# Recursive references need `$ref` to be in them, which is only possible with `dict`
|
100
|
+
assert isinstance(cloned, dict)
|
101
|
+
result.update(cloned)
|
102
|
+
return result
|
103
|
+
elif resolved_uri not in visited:
|
104
|
+
# Bundle only new schemas
|
105
|
+
visit(resolved_uri)
|
106
|
+
|
107
|
+
# Recursively bundle the embedded schema too!
|
108
|
+
resolver.push_scope(resolved_uri)
|
109
|
+
try:
|
110
|
+
bundled_resolved = _bundle_recursive(resolved_schema)
|
111
|
+
finally:
|
112
|
+
resolver.pop_scope()
|
113
|
+
|
114
|
+
defs[def_name] = bundled_resolved
|
115
|
+
|
116
|
+
return {
|
117
|
+
key: f"{REFERENCE_TO_BUNDLE_PREFIX}/{def_name}"
|
118
|
+
if key == "$ref"
|
119
|
+
else _bundle_recursive(value)
|
120
|
+
if isinstance(value, (dict, list))
|
121
|
+
else value
|
122
|
+
for key, value in current.items()
|
123
|
+
}
|
124
|
+
else:
|
125
|
+
# Already visited - just update $ref
|
126
|
+
return {
|
127
|
+
key: f"{REFERENCE_TO_BUNDLE_PREFIX}/{def_name}"
|
128
|
+
if key == "$ref"
|
129
|
+
else _bundle_recursive(value)
|
130
|
+
if isinstance(value, (dict, list))
|
131
|
+
else value
|
132
|
+
for key, value in current.items()
|
133
|
+
}
|
134
|
+
return {
|
135
|
+
key: _bundle_recursive(value) if isinstance(value, (dict, list)) else value
|
136
|
+
for key, value in current.items()
|
137
|
+
}
|
138
|
+
elif isinstance(current, list):
|
139
|
+
return [_bundle_recursive(item) if isinstance(item, (dict, list)) else item for item in current] # type: ignore[misc]
|
140
|
+
# `isinstance` guards won't let it happen
|
141
|
+
# Otherwise is present to make type checker happy
|
142
|
+
return current # pragma: no cover
|
143
|
+
|
144
|
+
bundled = bundle_recursive(schema)
|
145
|
+
|
146
|
+
assert isinstance(bundled, dict)
|
147
|
+
|
148
|
+
# Inlining such a schema is only possible if recursive references were inlined
|
149
|
+
if (inline_recursive or not has_recursive_references) and "$ref" in bundled and len(defs) == 1:
|
150
|
+
result = {key: value for key, value in bundled.items() if key != "$ref"}
|
151
|
+
for value in defs.values():
|
152
|
+
if isinstance(value, dict):
|
153
|
+
result.update(value)
|
154
|
+
return result
|
155
|
+
|
156
|
+
if defs:
|
157
|
+
bundled[BUNDLE_STORAGE_KEY] = defs
|
158
|
+
return bundled
|
159
|
+
|
160
|
+
|
161
|
+
def bundle(schema: JsonSchema, resolver: RefResolver, *, inline_recursive: bool) -> JsonSchema:
|
162
|
+
"""Bundle a JSON Schema by embedding all references."""
|
163
|
+
return Bundler().bundle(schema, resolver, inline_recursive=inline_recursive)
|