schemathesis 3.13.0__py3-none-any.whl → 4.4.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/__init__.py +53 -25
- schemathesis/auths.py +507 -0
- schemathesis/checks.py +190 -25
- schemathesis/cli/__init__.py +27 -1016
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/commands/__init__.py +133 -0
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +602 -0
- schemathesis/cli/commands/run/context.py +228 -0
- schemathesis/cli/commands/run/events.py +60 -0
- schemathesis/cli/commands/run/executor.py +157 -0
- schemathesis/cli/commands/run/filters.py +53 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +45 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +464 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +60 -0
- schemathesis/cli/commands/run/handlers/output.py +1750 -0
- schemathesis/cli/commands/run/loaders.py +118 -0
- schemathesis/cli/commands/run/validation.py +256 -0
- schemathesis/cli/constants.py +5 -0
- schemathesis/cli/core.py +19 -0
- schemathesis/cli/ext/fs.py +16 -0
- schemathesis/cli/ext/groups.py +203 -0
- schemathesis/cli/ext/options.py +81 -0
- schemathesis/config/__init__.py +202 -0
- schemathesis/config/_auth.py +51 -0
- schemathesis/config/_checks.py +268 -0
- schemathesis/config/_diff_base.py +101 -0
- schemathesis/config/_env.py +21 -0
- schemathesis/config/_error.py +163 -0
- schemathesis/config/_generation.py +157 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +335 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +253 -0
- schemathesis/config/_projects.py +543 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +120 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/_warnings.py +89 -0
- schemathesis/config/schema.json +975 -0
- schemathesis/core/__init__.py +72 -0
- schemathesis/core/adapter.py +34 -0
- schemathesis/core/compat.py +32 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +100 -0
- schemathesis/core/deserialization.py +210 -0
- schemathesis/core/errors.py +588 -0
- schemathesis/core/failures.py +316 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/hooks.py +20 -0
- schemathesis/core/jsonschema/__init__.py +13 -0
- schemathesis/core/jsonschema/bundler.py +183 -0
- schemathesis/core/jsonschema/keywords.py +40 -0
- schemathesis/core/jsonschema/references.py +222 -0
- schemathesis/core/jsonschema/types.py +41 -0
- schemathesis/core/lazy_import.py +15 -0
- schemathesis/core/loaders.py +107 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/core/media_types.py +79 -0
- schemathesis/core/output/__init__.py +46 -0
- schemathesis/core/output/sanitization.py +54 -0
- schemathesis/core/parameters.py +45 -0
- schemathesis/core/rate_limit.py +60 -0
- schemathesis/core/registries.py +34 -0
- schemathesis/core/result.py +27 -0
- schemathesis/core/schema_analysis.py +17 -0
- schemathesis/core/shell.py +203 -0
- schemathesis/core/transforms.py +144 -0
- schemathesis/core/transport.py +223 -0
- schemathesis/core/validation.py +73 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +28 -0
- schemathesis/engine/context.py +152 -0
- schemathesis/engine/control.py +44 -0
- schemathesis/engine/core.py +201 -0
- schemathesis/engine/errors.py +446 -0
- schemathesis/engine/events.py +284 -0
- schemathesis/engine/observations.py +42 -0
- schemathesis/engine/phases/__init__.py +108 -0
- schemathesis/engine/phases/analysis.py +28 -0
- schemathesis/engine/phases/probes.py +172 -0
- schemathesis/engine/phases/stateful/__init__.py +68 -0
- schemathesis/engine/phases/stateful/_executor.py +364 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +220 -0
- schemathesis/engine/phases/unit/_executor.py +459 -0
- schemathesis/engine/phases/unit/_pool.py +82 -0
- schemathesis/engine/recorder.py +254 -0
- schemathesis/errors.py +47 -0
- schemathesis/filters.py +395 -0
- schemathesis/generation/__init__.py +25 -0
- schemathesis/generation/case.py +478 -0
- schemathesis/generation/coverage.py +1528 -0
- schemathesis/generation/hypothesis/__init__.py +121 -0
- schemathesis/generation/hypothesis/builder.py +992 -0
- schemathesis/generation/hypothesis/examples.py +56 -0
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +285 -0
- schemathesis/generation/meta.py +227 -0
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +20 -0
- schemathesis/generation/overrides.py +127 -0
- schemathesis/generation/stateful/__init__.py +37 -0
- schemathesis/generation/stateful/state_machine.py +294 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +285 -0
- schemathesis/hooks.py +270 -91
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +467 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +72 -0
- schemathesis/openapi/loaders.py +315 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +341 -0
- schemathesis/pytest/loaders.py +36 -0
- schemathesis/pytest/plugin.py +357 -0
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +683 -247
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/nodes.py +27 -0
- schemathesis/specs/graphql/scalars.py +86 -0
- schemathesis/specs/graphql/schemas.py +395 -123
- schemathesis/specs/graphql/validation.py +33 -0
- schemathesis/specs/openapi/__init__.py +9 -1
- schemathesis/specs/openapi/_hypothesis.py +578 -317
- schemathesis/specs/openapi/adapter/__init__.py +10 -0
- schemathesis/specs/openapi/adapter/parameters.py +729 -0
- schemathesis/specs/openapi/adapter/protocol.py +59 -0
- schemathesis/specs/openapi/adapter/references.py +19 -0
- schemathesis/specs/openapi/adapter/responses.py +368 -0
- schemathesis/specs/openapi/adapter/security.py +144 -0
- schemathesis/specs/openapi/adapter/v2.py +30 -0
- schemathesis/specs/openapi/adapter/v3_0.py +30 -0
- schemathesis/specs/openapi/adapter/v3_1.py +30 -0
- schemathesis/specs/openapi/analysis.py +96 -0
- schemathesis/specs/openapi/checks.py +753 -74
- schemathesis/specs/openapi/converter.py +176 -37
- schemathesis/specs/openapi/definitions.py +599 -4
- schemathesis/specs/openapi/examples.py +581 -165
- schemathesis/specs/openapi/expressions/__init__.py +52 -5
- schemathesis/specs/openapi/expressions/extractors.py +25 -0
- schemathesis/specs/openapi/expressions/lexer.py +34 -31
- schemathesis/specs/openapi/expressions/nodes.py +97 -46
- schemathesis/specs/openapi/expressions/parser.py +35 -13
- schemathesis/specs/openapi/formats.py +122 -0
- schemathesis/specs/openapi/media_types.py +75 -0
- schemathesis/specs/openapi/negative/__init__.py +117 -68
- schemathesis/specs/openapi/negative/mutations.py +294 -104
- schemathesis/specs/openapi/negative/utils.py +3 -6
- schemathesis/specs/openapi/patterns.py +458 -0
- schemathesis/specs/openapi/references.py +60 -81
- schemathesis/specs/openapi/schemas.py +648 -650
- schemathesis/specs/openapi/serialization.py +53 -30
- schemathesis/specs/openapi/stateful/__init__.py +404 -69
- schemathesis/specs/openapi/stateful/control.py +87 -0
- schemathesis/specs/openapi/stateful/dependencies/__init__.py +232 -0
- schemathesis/specs/openapi/stateful/dependencies/inputs.py +428 -0
- schemathesis/specs/openapi/stateful/dependencies/models.py +341 -0
- schemathesis/specs/openapi/stateful/dependencies/naming.py +491 -0
- schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
- schemathesis/specs/openapi/stateful/dependencies/resources.py +339 -0
- schemathesis/specs/openapi/stateful/dependencies/schemas.py +447 -0
- schemathesis/specs/openapi/stateful/inference.py +254 -0
- schemathesis/specs/openapi/stateful/links.py +219 -78
- schemathesis/specs/openapi/types/__init__.py +3 -0
- schemathesis/specs/openapi/types/common.py +23 -0
- schemathesis/specs/openapi/types/v2.py +129 -0
- schemathesis/specs/openapi/types/v3.py +134 -0
- schemathesis/specs/openapi/utils.py +7 -6
- schemathesis/specs/openapi/warnings.py +75 -0
- schemathesis/transport/__init__.py +224 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +126 -0
- schemathesis/transport/requests.py +278 -0
- schemathesis/transport/serialization.py +329 -0
- schemathesis/transport/wsgi.py +175 -0
- schemathesis-4.4.2.dist-info/METADATA +213 -0
- schemathesis-4.4.2.dist-info/RECORD +192 -0
- {schemathesis-3.13.0.dist-info → schemathesis-4.4.2.dist-info}/WHEEL +1 -1
- schemathesis-4.4.2.dist-info/entry_points.txt +6 -0
- {schemathesis-3.13.0.dist-info → schemathesis-4.4.2.dist-info/licenses}/LICENSE +1 -1
- schemathesis/_compat.py +0 -41
- schemathesis/_hypothesis.py +0 -115
- schemathesis/cli/callbacks.py +0 -188
- schemathesis/cli/cassettes.py +0 -253
- schemathesis/cli/context.py +0 -36
- schemathesis/cli/debug.py +0 -21
- schemathesis/cli/handlers.py +0 -11
- schemathesis/cli/junitxml.py +0 -41
- schemathesis/cli/options.py +0 -51
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -508
- schemathesis/cli/output/short.py +0 -40
- schemathesis/constants.py +0 -79
- schemathesis/exceptions.py +0 -207
- schemathesis/extra/_aiohttp.py +0 -27
- schemathesis/extra/_flask.py +0 -10
- schemathesis/extra/_server.py +0 -16
- schemathesis/extra/pytest_plugin.py +0 -216
- schemathesis/failures.py +0 -131
- schemathesis/fixups/__init__.py +0 -29
- schemathesis/fixups/fast_api.py +0 -30
- schemathesis/lazy.py +0 -227
- schemathesis/models.py +0 -1041
- schemathesis/parameters.py +0 -88
- schemathesis/runner/__init__.py +0 -460
- schemathesis/runner/events.py +0 -240
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/core.py +0 -755
- schemathesis/runner/impl/solo.py +0 -85
- schemathesis/runner/impl/threadpool.py +0 -367
- schemathesis/runner/serialization.py +0 -189
- schemathesis/serializers.py +0 -233
- schemathesis/service/__init__.py +0 -3
- schemathesis/service/client.py +0 -46
- schemathesis/service/constants.py +0 -12
- schemathesis/service/events.py +0 -39
- schemathesis/service/handler.py +0 -39
- schemathesis/service/models.py +0 -7
- schemathesis/service/serialization.py +0 -153
- schemathesis/service/worker.py +0 -40
- schemathesis/specs/graphql/loaders.py +0 -215
- schemathesis/specs/openapi/constants.py +0 -7
- schemathesis/specs/openapi/expressions/context.py +0 -12
- schemathesis/specs/openapi/expressions/pointers.py +0 -29
- schemathesis/specs/openapi/filters.py +0 -44
- schemathesis/specs/openapi/links.py +0 -302
- schemathesis/specs/openapi/loaders.py +0 -453
- schemathesis/specs/openapi/parameters.py +0 -413
- schemathesis/specs/openapi/security.py +0 -129
- schemathesis/specs/openapi/validation.py +0 -24
- schemathesis/stateful.py +0 -349
- schemathesis/targets.py +0 -32
- schemathesis/types.py +0 -38
- schemathesis/utils.py +0 -436
- schemathesis-3.13.0.dist-info/METADATA +0 -202
- schemathesis-3.13.0.dist-info/RECORD +0 -91
- schemathesis-3.13.0.dist-info/entry_points.txt +0 -6
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import http.client
|
|
4
|
+
import textwrap
|
|
5
|
+
import traceback
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum, auto
|
|
9
|
+
from json import JSONDecodeError
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
from schemathesis.config import OutputConfig
|
|
13
|
+
from schemathesis.core.compat import BaseExceptionGroup
|
|
14
|
+
from schemathesis.core.output import prepare_response_payload
|
|
15
|
+
from schemathesis.core.transport import Response
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Severity(Enum):
|
|
19
|
+
# For server errors, security issues like ignored auth
|
|
20
|
+
CRITICAL = auto()
|
|
21
|
+
# For schema violations
|
|
22
|
+
HIGH = auto()
|
|
23
|
+
# For content type issues, header problems
|
|
24
|
+
MEDIUM = auto()
|
|
25
|
+
# For performance issues, minor inconsistencies
|
|
26
|
+
LOW = auto()
|
|
27
|
+
|
|
28
|
+
def __lt__(self, other: Severity) -> bool:
|
|
29
|
+
# Lower values are more severe
|
|
30
|
+
return self.value < other.value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Failure(AssertionError):
|
|
35
|
+
"""API check failure."""
|
|
36
|
+
|
|
37
|
+
__slots__ = ("operation", "title", "message", "case_id", "severity")
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
operation: str,
|
|
43
|
+
title: str,
|
|
44
|
+
message: str,
|
|
45
|
+
case_id: str | None = None,
|
|
46
|
+
severity: Severity = Severity.MEDIUM,
|
|
47
|
+
) -> None:
|
|
48
|
+
self.operation = operation
|
|
49
|
+
self.title = title
|
|
50
|
+
self.message = message
|
|
51
|
+
self.case_id = case_id
|
|
52
|
+
self.severity = severity
|
|
53
|
+
|
|
54
|
+
def __str__(self) -> str:
|
|
55
|
+
if not self.message:
|
|
56
|
+
return self.title
|
|
57
|
+
return f"{self.title}\n\n{self.message}"
|
|
58
|
+
|
|
59
|
+
def __lt__(self, other: Failure) -> bool:
|
|
60
|
+
return (
|
|
61
|
+
self.severity,
|
|
62
|
+
self.__class__.__name__,
|
|
63
|
+
self.message,
|
|
64
|
+
) < (other.severity, other.__class__.__name__, other.message)
|
|
65
|
+
|
|
66
|
+
# Comparison & hashing is done purely on classes to simplify keeping the minimized failure during shrinking
|
|
67
|
+
def __hash__(self) -> int:
|
|
68
|
+
return hash(self.__class__)
|
|
69
|
+
|
|
70
|
+
def __eq__(self, other: object, /) -> bool:
|
|
71
|
+
if not isinstance(other, Failure):
|
|
72
|
+
return NotImplemented
|
|
73
|
+
return type(self) is type(other) and self.operation == other.operation and self._unique_key == other._unique_key
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def _unique_key(self) -> Any:
|
|
77
|
+
return self.message
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_origin(exception: BaseException, seen: tuple[BaseException, ...] = ()) -> tuple:
|
|
81
|
+
filename, lineno = None, None
|
|
82
|
+
if tb := exception.__traceback__:
|
|
83
|
+
filename, lineno, *_ = traceback.extract_tb(tb)[-1]
|
|
84
|
+
seen = (*seen, exception)
|
|
85
|
+
context = ()
|
|
86
|
+
if exception.__context__ is not None and exception.__context__ not in seen:
|
|
87
|
+
context = get_origin(exception.__context__, seen=seen)
|
|
88
|
+
return (
|
|
89
|
+
type(exception),
|
|
90
|
+
filename,
|
|
91
|
+
lineno,
|
|
92
|
+
context,
|
|
93
|
+
(
|
|
94
|
+
tuple(get_origin(exc, seen=seen) for exc in exception.exceptions if exc not in seen)
|
|
95
|
+
if isinstance(exception, BaseExceptionGroup)
|
|
96
|
+
else ()
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class CustomFailure(Failure):
|
|
102
|
+
__slots__ = ("operation", "title", "message", "exception", "case_id", "severity", "origin")
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
*,
|
|
107
|
+
operation: str,
|
|
108
|
+
title: str,
|
|
109
|
+
message: str,
|
|
110
|
+
exception: AssertionError,
|
|
111
|
+
case_id: str | None = None,
|
|
112
|
+
severity: Severity = Severity.MEDIUM,
|
|
113
|
+
) -> None:
|
|
114
|
+
self.operation = operation
|
|
115
|
+
self.title = title
|
|
116
|
+
self.message = message
|
|
117
|
+
self.exception = exception
|
|
118
|
+
self.case_id = case_id
|
|
119
|
+
self.severity = severity
|
|
120
|
+
self.origin = get_origin(exception)
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def _unique_key(self) -> Any:
|
|
124
|
+
return self.origin
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ResponseTimeExceeded(Failure):
|
|
128
|
+
"""Response took longer than expected."""
|
|
129
|
+
|
|
130
|
+
__slots__ = ("operation", "elapsed", "deadline", "title", "message", "case_id", "severity")
|
|
131
|
+
|
|
132
|
+
def __init__(
|
|
133
|
+
self,
|
|
134
|
+
*,
|
|
135
|
+
operation: str,
|
|
136
|
+
elapsed: float,
|
|
137
|
+
deadline: float,
|
|
138
|
+
message: str,
|
|
139
|
+
title: str = "Response time limit exceeded",
|
|
140
|
+
case_id: str | None = None,
|
|
141
|
+
) -> None:
|
|
142
|
+
self.operation = operation
|
|
143
|
+
self.elapsed = elapsed
|
|
144
|
+
self.deadline = deadline
|
|
145
|
+
self.title = title
|
|
146
|
+
self.message = message
|
|
147
|
+
self.case_id = case_id
|
|
148
|
+
self.severity = Severity.LOW
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def _unique_key(self) -> str:
|
|
152
|
+
return self.title
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class ServerError(Failure):
|
|
156
|
+
"""Server responded with an error."""
|
|
157
|
+
|
|
158
|
+
__slots__ = ("operation", "status_code", "title", "message", "case_id", "severity")
|
|
159
|
+
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
*,
|
|
163
|
+
operation: str,
|
|
164
|
+
status_code: int,
|
|
165
|
+
title: str = "Server error",
|
|
166
|
+
message: str = "",
|
|
167
|
+
case_id: str | None = None,
|
|
168
|
+
) -> None:
|
|
169
|
+
self.operation = operation
|
|
170
|
+
self.status_code = status_code
|
|
171
|
+
self.title = title
|
|
172
|
+
self.message = message
|
|
173
|
+
self.case_id = case_id
|
|
174
|
+
self.severity = Severity.CRITICAL
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def _unique_key(self) -> str:
|
|
178
|
+
return str(self.status_code)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class MalformedJson(Failure):
|
|
182
|
+
"""Failed to deserialize JSON."""
|
|
183
|
+
|
|
184
|
+
__slots__ = (
|
|
185
|
+
"operation",
|
|
186
|
+
"validation_message",
|
|
187
|
+
"document",
|
|
188
|
+
"position",
|
|
189
|
+
"lineno",
|
|
190
|
+
"colno",
|
|
191
|
+
"message",
|
|
192
|
+
"title",
|
|
193
|
+
"case_id",
|
|
194
|
+
"severity",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def __init__(
|
|
198
|
+
self,
|
|
199
|
+
*,
|
|
200
|
+
operation: str,
|
|
201
|
+
validation_message: str,
|
|
202
|
+
document: str,
|
|
203
|
+
position: int,
|
|
204
|
+
lineno: int,
|
|
205
|
+
colno: int,
|
|
206
|
+
message: str,
|
|
207
|
+
title: str = "JSON deserialization error",
|
|
208
|
+
case_id: str | None = None,
|
|
209
|
+
) -> None:
|
|
210
|
+
self.operation = operation
|
|
211
|
+
self.validation_message = validation_message
|
|
212
|
+
self.document = document
|
|
213
|
+
self.position = position
|
|
214
|
+
self.lineno = lineno
|
|
215
|
+
self.colno = colno
|
|
216
|
+
self.message = message
|
|
217
|
+
self.title = title
|
|
218
|
+
self.case_id = case_id
|
|
219
|
+
self.severity = Severity.MEDIUM
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def _unique_key(self) -> Any:
|
|
223
|
+
return self.title
|
|
224
|
+
|
|
225
|
+
@classmethod
|
|
226
|
+
def from_exception(cls, *, operation: str, exc: JSONDecodeError) -> MalformedJson:
|
|
227
|
+
message = f"Response must be valid JSON with 'Content-Type: application/json' header:\n\n {exc}"
|
|
228
|
+
return cls(
|
|
229
|
+
operation=operation,
|
|
230
|
+
message=message,
|
|
231
|
+
validation_message=exc.msg,
|
|
232
|
+
document=exc.doc,
|
|
233
|
+
position=exc.pos,
|
|
234
|
+
lineno=exc.lineno,
|
|
235
|
+
colno=exc.colno,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class FailureGroup(BaseExceptionGroup):
|
|
240
|
+
"""Multiple distinct check failures."""
|
|
241
|
+
|
|
242
|
+
exceptions: Sequence[Failure]
|
|
243
|
+
|
|
244
|
+
def __init__(self, exceptions: Sequence[Failure], message: str = "", /) -> None:
|
|
245
|
+
super().__init__(message, exceptions)
|
|
246
|
+
|
|
247
|
+
def __new__(cls, failures: Sequence[Failure], message: str | None = None) -> FailureGroup:
|
|
248
|
+
if message is None:
|
|
249
|
+
message = failure_report_title(failures)
|
|
250
|
+
return super().__new__(cls, message, list(failures))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class MessageBlock(str, Enum):
|
|
254
|
+
CASE_ID = "case_id"
|
|
255
|
+
FAILURE = "failure"
|
|
256
|
+
STATUS = "status"
|
|
257
|
+
CURL = "curl"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
BlockFormatter = Callable[[MessageBlock, str], str]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def failure_report_title(failures: Sequence[Failure]) -> str:
|
|
264
|
+
message = f"Schemathesis found {len(failures)} distinct failure"
|
|
265
|
+
if len(failures) > 1:
|
|
266
|
+
message += "s"
|
|
267
|
+
return message
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def format_failures(
|
|
271
|
+
*,
|
|
272
|
+
case_id: str | None,
|
|
273
|
+
response: Response | None,
|
|
274
|
+
failures: Sequence[Failure],
|
|
275
|
+
curl: str,
|
|
276
|
+
formatter: BlockFormatter | None = None,
|
|
277
|
+
config: OutputConfig,
|
|
278
|
+
) -> str:
|
|
279
|
+
"""Format failure information with custom styling."""
|
|
280
|
+
formatter = formatter or (lambda _, x: x)
|
|
281
|
+
|
|
282
|
+
if case_id is not None:
|
|
283
|
+
output = formatter(MessageBlock.CASE_ID, f"{case_id}\n")
|
|
284
|
+
else:
|
|
285
|
+
output = ""
|
|
286
|
+
|
|
287
|
+
# Failures
|
|
288
|
+
for idx, failure in enumerate(failures):
|
|
289
|
+
output += formatter(MessageBlock.FAILURE, f"\n- {failure.title}")
|
|
290
|
+
if failure.message:
|
|
291
|
+
output += "\n\n"
|
|
292
|
+
output += textwrap.indent(failure.message, " ")
|
|
293
|
+
if idx != len(failures):
|
|
294
|
+
output += "\n"
|
|
295
|
+
|
|
296
|
+
# Response status
|
|
297
|
+
if isinstance(response, Response):
|
|
298
|
+
reason = http.client.responses.get(response.status_code, "Unknown")
|
|
299
|
+
output += formatter(MessageBlock.STATUS, f"\n[{response.status_code}] {reason}:\n")
|
|
300
|
+
# Response payload
|
|
301
|
+
if response.content is None or not response.content:
|
|
302
|
+
output += "\n <EMPTY>"
|
|
303
|
+
else:
|
|
304
|
+
try:
|
|
305
|
+
payload = prepare_response_payload(response.text, config=config)
|
|
306
|
+
output += textwrap.indent(f"\n`{payload}`", prefix=" ")
|
|
307
|
+
except UnicodeDecodeError:
|
|
308
|
+
output += "\n <BINARY>"
|
|
309
|
+
else:
|
|
310
|
+
output += "\n <NO RESPONSE>"
|
|
311
|
+
|
|
312
|
+
# cURL
|
|
313
|
+
_curl = "\n".join(f" {line}" for line in curl.splitlines())
|
|
314
|
+
output += "\n" + formatter(MessageBlock.CURL, f"\nReproduce with: \n\n{_curl}")
|
|
315
|
+
|
|
316
|
+
return output
|
schemathesis/core/fs.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pathlib
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def ensure_parent(path: os.PathLike, fail_silently: bool = True) -> None:
|
|
6
|
+
# Try to create the parent dir
|
|
7
|
+
try:
|
|
8
|
+
pathlib.Path(path).parent.mkdir(mode=0o755, parents=True, exist_ok=True)
|
|
9
|
+
except OSError:
|
|
10
|
+
if not fail_silently:
|
|
11
|
+
raise
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def file_exists(path: str) -> bool:
|
|
15
|
+
try:
|
|
16
|
+
return pathlib.Path(path).is_file()
|
|
17
|
+
except OSError:
|
|
18
|
+
# For example, path could be too long
|
|
19
|
+
return False
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from schemathesis.core.errors import HookError
|
|
5
|
+
|
|
6
|
+
HOOKS_MODULE_ENV_VAR = "SCHEMATHESIS_HOOKS"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_from_env() -> None:
|
|
10
|
+
hooks = os.getenv(HOOKS_MODULE_ENV_VAR)
|
|
11
|
+
if hooks:
|
|
12
|
+
load_from_path(hooks)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_from_path(module_path: str) -> None:
|
|
16
|
+
try:
|
|
17
|
+
sys.path.append(os.getcwd()) # fix ModuleNotFoundError module in cwd
|
|
18
|
+
__import__(module_path)
|
|
19
|
+
except Exception as exc:
|
|
20
|
+
raise HookError(module_path) from exc
|
|
@@ -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,183 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
from schemathesis.core.errors import InfiniteRecursiveReference
|
|
7
|
+
from schemathesis.core.jsonschema.references import sanitize
|
|
8
|
+
from schemathesis.core.jsonschema.types import JsonSchema, to_json_type_name
|
|
9
|
+
from schemathesis.core.transforms import deepclone
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from schemathesis.core.compat import RefResolver
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
BUNDLE_STORAGE_KEY = "x-bundled"
|
|
16
|
+
REFERENCE_TO_BUNDLE_PREFIX = f"#/{BUNDLE_STORAGE_KEY}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BundleError(Exception):
|
|
20
|
+
def __init__(self, reference: str, value: Any) -> None:
|
|
21
|
+
self.reference = reference
|
|
22
|
+
self.value = value
|
|
23
|
+
|
|
24
|
+
def __str__(self) -> str:
|
|
25
|
+
return f"Cannot bundle `{self.reference}`: expected JSON Schema (object or boolean), got {to_json_type_name(self.value)}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Bundle:
|
|
30
|
+
schema: JsonSchema
|
|
31
|
+
name_to_uri: dict[str, str]
|
|
32
|
+
|
|
33
|
+
__slots__ = ("schema", "name_to_uri")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Bundler:
|
|
37
|
+
"""Bundler tracks schema ids stored in a bundle."""
|
|
38
|
+
|
|
39
|
+
counter: int
|
|
40
|
+
|
|
41
|
+
__slots__ = ("counter",)
|
|
42
|
+
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
self.counter = 0
|
|
45
|
+
|
|
46
|
+
def bundle(self, schema: JsonSchema, resolver: RefResolver, *, inline_recursive: bool) -> Bundle:
|
|
47
|
+
"""Bundle a JSON Schema by embedding all references."""
|
|
48
|
+
# Inlining recursive reference is required (for now) for data generation, but is unsound for data validation
|
|
49
|
+
if not isinstance(schema, dict):
|
|
50
|
+
return Bundle(schema=schema, name_to_uri={})
|
|
51
|
+
|
|
52
|
+
# Track visited URIs and their local definition names
|
|
53
|
+
inlining_for_recursion: set[str] = set()
|
|
54
|
+
visited: set[str] = set()
|
|
55
|
+
uri_to_name: dict[str, str] = {}
|
|
56
|
+
defs = {}
|
|
57
|
+
|
|
58
|
+
has_recursive_references = False
|
|
59
|
+
resolve = resolver.resolve
|
|
60
|
+
visit = visited.add
|
|
61
|
+
|
|
62
|
+
def get_def_name(uri: str) -> str:
|
|
63
|
+
"""Generate or retrieve the local definition name for a URI."""
|
|
64
|
+
name = uri_to_name.get(uri)
|
|
65
|
+
if name is None:
|
|
66
|
+
self.counter += 1
|
|
67
|
+
name = f"schema{self.counter}"
|
|
68
|
+
uri_to_name[uri] = name
|
|
69
|
+
return name
|
|
70
|
+
|
|
71
|
+
def bundle_recursive(current: JsonSchema | list[JsonSchema]) -> JsonSchema | list[JsonSchema]:
|
|
72
|
+
"""Recursively process and bundle references in the current schema."""
|
|
73
|
+
# Local lookup is cheaper and it matters for large schemas.
|
|
74
|
+
# It works because this recursive call goes to every nested value
|
|
75
|
+
nonlocal has_recursive_references
|
|
76
|
+
_bundle_recursive = bundle_recursive
|
|
77
|
+
if isinstance(current, dict):
|
|
78
|
+
reference = current.get("$ref")
|
|
79
|
+
if isinstance(reference, str) and not reference.startswith(REFERENCE_TO_BUNDLE_PREFIX):
|
|
80
|
+
resolved_uri, resolved_schema = resolve(reference)
|
|
81
|
+
|
|
82
|
+
if not isinstance(resolved_schema, (dict, bool)):
|
|
83
|
+
raise BundleError(reference, resolved_schema)
|
|
84
|
+
def_name = get_def_name(resolved_uri)
|
|
85
|
+
|
|
86
|
+
scopes = resolver._scopes_stack
|
|
87
|
+
|
|
88
|
+
is_recursive_reference = resolved_uri in scopes
|
|
89
|
+
has_recursive_references |= is_recursive_reference
|
|
90
|
+
if inline_recursive and is_recursive_reference:
|
|
91
|
+
# This is a recursive reference! As of Sep 2025, `hypothesis-jsonschema` does not support
|
|
92
|
+
# recursive references and Schemathesis has to remove them if possible.
|
|
93
|
+
#
|
|
94
|
+
# Cutting them of immediately would limit the quality of generated data, since it would have
|
|
95
|
+
# just a single level of recursion. Currently, the only way to generate recursive data is to
|
|
96
|
+
# inline definitions directly, which can lead to schema size explosion.
|
|
97
|
+
#
|
|
98
|
+
# To balance it, Schemathesis inlines one level, that avoids exponential blowup of O(B ^ L)
|
|
99
|
+
# in worst case, where B is branching factor (number of recursive references per schema), and
|
|
100
|
+
# L is the number of levels. Even quadratic growth can be unacceptable for large schemas.
|
|
101
|
+
#
|
|
102
|
+
# In the future, it **should** be handled by `hypothesis-jsonschema` instead.
|
|
103
|
+
if resolved_uri in inlining_for_recursion:
|
|
104
|
+
# Check if we're already trying to inline this schema
|
|
105
|
+
# If yes, it means we have an unbreakable cycle
|
|
106
|
+
cycle = scopes[scopes.index(resolved_uri) :]
|
|
107
|
+
raise InfiniteRecursiveReference(reference, cycle)
|
|
108
|
+
|
|
109
|
+
# Track that we're inlining this schema
|
|
110
|
+
inlining_for_recursion.add(resolved_uri)
|
|
111
|
+
try:
|
|
112
|
+
cloned = deepclone(resolved_schema)
|
|
113
|
+
# Sanitize to remove optional recursive references
|
|
114
|
+
sanitize(cloned)
|
|
115
|
+
|
|
116
|
+
result = {key: _bundle_recursive(value) for key, value in current.items() if key != "$ref"}
|
|
117
|
+
bundled_clone = _bundle_recursive(cloned)
|
|
118
|
+
assert isinstance(bundled_clone, dict)
|
|
119
|
+
result.update(bundled_clone)
|
|
120
|
+
return result
|
|
121
|
+
finally:
|
|
122
|
+
inlining_for_recursion.discard(resolved_uri)
|
|
123
|
+
elif resolved_uri not in visited:
|
|
124
|
+
# Bundle only new schemas
|
|
125
|
+
visit(resolved_uri)
|
|
126
|
+
|
|
127
|
+
# Recursively bundle the embedded schema too!
|
|
128
|
+
resolver.push_scope(resolved_uri)
|
|
129
|
+
try:
|
|
130
|
+
bundled_resolved = _bundle_recursive(resolved_schema)
|
|
131
|
+
finally:
|
|
132
|
+
resolver.pop_scope()
|
|
133
|
+
|
|
134
|
+
defs[def_name] = bundled_resolved
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
key: f"{REFERENCE_TO_BUNDLE_PREFIX}/{def_name}"
|
|
138
|
+
if key == "$ref"
|
|
139
|
+
else _bundle_recursive(value)
|
|
140
|
+
if isinstance(value, (dict, list))
|
|
141
|
+
else value
|
|
142
|
+
for key, value in current.items()
|
|
143
|
+
}
|
|
144
|
+
else:
|
|
145
|
+
# Already visited - just update $ref
|
|
146
|
+
return {
|
|
147
|
+
key: f"{REFERENCE_TO_BUNDLE_PREFIX}/{def_name}"
|
|
148
|
+
if key == "$ref"
|
|
149
|
+
else _bundle_recursive(value)
|
|
150
|
+
if isinstance(value, (dict, list))
|
|
151
|
+
else value
|
|
152
|
+
for key, value in current.items()
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
key: _bundle_recursive(value) if isinstance(value, (dict, list)) else value
|
|
156
|
+
for key, value in current.items()
|
|
157
|
+
}
|
|
158
|
+
elif isinstance(current, list):
|
|
159
|
+
return [_bundle_recursive(item) if isinstance(item, (dict, list)) else item for item in current] # type: ignore[misc]
|
|
160
|
+
# `isinstance` guards won't let it happen
|
|
161
|
+
# Otherwise is present to make type checker happy
|
|
162
|
+
return current # pragma: no cover
|
|
163
|
+
|
|
164
|
+
bundled = bundle_recursive(schema)
|
|
165
|
+
|
|
166
|
+
assert isinstance(bundled, dict)
|
|
167
|
+
|
|
168
|
+
# Inlining such a schema is only possible if recursive references were inlined
|
|
169
|
+
if (inline_recursive or not has_recursive_references) and "$ref" in bundled and len(defs) == 1:
|
|
170
|
+
result = {key: value for key, value in bundled.items() if key != "$ref"}
|
|
171
|
+
for value in defs.values():
|
|
172
|
+
if isinstance(value, dict):
|
|
173
|
+
result.update(value)
|
|
174
|
+
return Bundle(schema=result, name_to_uri={})
|
|
175
|
+
|
|
176
|
+
if defs:
|
|
177
|
+
bundled[BUNDLE_STORAGE_KEY] = defs
|
|
178
|
+
return Bundle(schema=bundled, name_to_uri={v: k for k, v in uri_to_name.items()})
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def bundle(schema: JsonSchema, resolver: RefResolver, *, inline_recursive: bool) -> Bundle:
|
|
182
|
+
"""Bundle a JSON Schema by embedding all references."""
|
|
183
|
+
return Bundler().bundle(schema, resolver, inline_recursive=inline_recursive)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
ALL_KEYWORDS = frozenset(
|
|
2
|
+
{
|
|
3
|
+
"additionalItems",
|
|
4
|
+
"additionalProperties",
|
|
5
|
+
"allOf",
|
|
6
|
+
"anyOf",
|
|
7
|
+
"const",
|
|
8
|
+
"contains",
|
|
9
|
+
"contentEncoding",
|
|
10
|
+
"contentMediaType",
|
|
11
|
+
"dependencies",
|
|
12
|
+
"enum",
|
|
13
|
+
"else",
|
|
14
|
+
"exclusiveMaximum",
|
|
15
|
+
"exclusiveMinimum",
|
|
16
|
+
"format",
|
|
17
|
+
"if",
|
|
18
|
+
"items",
|
|
19
|
+
"maxItems",
|
|
20
|
+
"maxLength",
|
|
21
|
+
"maxProperties",
|
|
22
|
+
"maximum",
|
|
23
|
+
"minItems",
|
|
24
|
+
"minLength",
|
|
25
|
+
"minProperties",
|
|
26
|
+
"minimum",
|
|
27
|
+
"multipleOf",
|
|
28
|
+
"not",
|
|
29
|
+
"oneOf",
|
|
30
|
+
"pattern",
|
|
31
|
+
"patternProperties",
|
|
32
|
+
"properties",
|
|
33
|
+
"propertyNames",
|
|
34
|
+
"$ref",
|
|
35
|
+
"required",
|
|
36
|
+
"then",
|
|
37
|
+
"type",
|
|
38
|
+
"uniqueItems",
|
|
39
|
+
}
|
|
40
|
+
)
|