schemathesis 3.39.16__py3-none-any.whl → 4.0.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/__init__.py +41 -79
- schemathesis/auths.py +111 -122
- schemathesis/checks.py +169 -60
- schemathesis/cli/__init__.py +15 -2117
- schemathesis/cli/commands/__init__.py +85 -0
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +590 -0
- schemathesis/cli/commands/run/context.py +204 -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 +18 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +474 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +55 -0
- schemathesis/cli/commands/run/handlers/output.py +1628 -0
- schemathesis/cli/commands/run/loaders.py +114 -0
- schemathesis/cli/commands/run/validation.py +246 -0
- schemathesis/cli/constants.py +5 -58
- schemathesis/cli/core.py +19 -0
- schemathesis/cli/ext/fs.py +16 -0
- schemathesis/cli/ext/groups.py +84 -0
- schemathesis/cli/{options.py → ext/options.py} +36 -34
- schemathesis/config/__init__.py +189 -0
- schemathesis/config/_auth.py +51 -0
- schemathesis/config/_checks.py +268 -0
- schemathesis/config/_diff_base.py +99 -0
- schemathesis/config/_env.py +21 -0
- schemathesis/config/_error.py +156 -0
- schemathesis/config/_generation.py +149 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +327 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +187 -0
- schemathesis/config/_projects.py +527 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +120 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/_warnings.py +25 -0
- schemathesis/config/schema.json +885 -0
- schemathesis/core/__init__.py +67 -0
- schemathesis/core/compat.py +32 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +58 -0
- schemathesis/core/deserialization.py +65 -0
- schemathesis/core/errors.py +459 -0
- schemathesis/core/failures.py +315 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/hooks.py +20 -0
- schemathesis/core/loaders.py +104 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/{transports/content_types.py → core/media_types.py} +14 -12
- schemathesis/core/output/__init__.py +46 -0
- schemathesis/core/output/sanitization.py +54 -0
- schemathesis/{throttling.py → core/rate_limit.py} +16 -17
- schemathesis/core/registries.py +31 -0
- schemathesis/core/transforms.py +113 -0
- schemathesis/core/transport.py +223 -0
- schemathesis/core/validation.py +54 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +28 -0
- schemathesis/engine/context.py +118 -0
- schemathesis/engine/control.py +36 -0
- schemathesis/engine/core.py +169 -0
- schemathesis/engine/errors.py +464 -0
- schemathesis/engine/events.py +258 -0
- schemathesis/engine/phases/__init__.py +88 -0
- schemathesis/{runner → engine/phases}/probes.py +52 -68
- schemathesis/engine/phases/stateful/__init__.py +68 -0
- schemathesis/engine/phases/stateful/_executor.py +356 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +212 -0
- schemathesis/engine/phases/unit/_executor.py +416 -0
- schemathesis/engine/phases/unit/_pool.py +82 -0
- schemathesis/engine/recorder.py +247 -0
- schemathesis/errors.py +43 -0
- schemathesis/filters.py +17 -98
- schemathesis/generation/__init__.py +5 -33
- schemathesis/generation/case.py +317 -0
- schemathesis/generation/coverage.py +282 -175
- schemathesis/generation/hypothesis/__init__.py +36 -0
- schemathesis/generation/hypothesis/builder.py +800 -0
- schemathesis/generation/{_hypothesis.py → hypothesis/examples.py} +2 -11
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +14 -0
- schemathesis/generation/hypothesis/strategies.py +16 -0
- schemathesis/generation/meta.py +115 -0
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +20 -0
- schemathesis/generation/overrides.py +116 -0
- schemathesis/generation/stateful/__init__.py +37 -0
- schemathesis/generation/stateful/state_machine.py +278 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +284 -0
- schemathesis/hooks.py +80 -101
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +455 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +72 -0
- schemathesis/openapi/loaders.py +313 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +281 -0
- schemathesis/pytest/loaders.py +36 -0
- schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +128 -108
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +537 -273
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/_cache.py +1 -2
- schemathesis/specs/graphql/scalars.py +42 -6
- schemathesis/specs/graphql/schemas.py +141 -137
- schemathesis/specs/graphql/validation.py +11 -17
- schemathesis/specs/openapi/__init__.py +6 -1
- schemathesis/specs/openapi/_cache.py +1 -2
- schemathesis/specs/openapi/_hypothesis.py +142 -156
- schemathesis/specs/openapi/checks.py +368 -257
- schemathesis/specs/openapi/converter.py +4 -4
- schemathesis/specs/openapi/definitions.py +1 -1
- schemathesis/specs/openapi/examples.py +23 -21
- schemathesis/specs/openapi/expressions/__init__.py +31 -19
- schemathesis/specs/openapi/expressions/extractors.py +1 -4
- schemathesis/specs/openapi/expressions/lexer.py +1 -1
- schemathesis/specs/openapi/expressions/nodes.py +36 -41
- schemathesis/specs/openapi/expressions/parser.py +1 -1
- schemathesis/specs/openapi/formats.py +35 -7
- schemathesis/specs/openapi/media_types.py +53 -12
- schemathesis/specs/openapi/negative/__init__.py +7 -4
- schemathesis/specs/openapi/negative/mutations.py +6 -5
- schemathesis/specs/openapi/parameters.py +7 -10
- schemathesis/specs/openapi/patterns.py +94 -31
- schemathesis/specs/openapi/references.py +12 -53
- schemathesis/specs/openapi/schemas.py +233 -307
- schemathesis/specs/openapi/security.py +1 -1
- schemathesis/specs/openapi/serialization.py +12 -6
- schemathesis/specs/openapi/stateful/__init__.py +268 -133
- schemathesis/specs/openapi/stateful/control.py +87 -0
- schemathesis/specs/openapi/stateful/links.py +209 -0
- schemathesis/transport/__init__.py +142 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +124 -0
- schemathesis/transport/requests.py +244 -0
- schemathesis/{_xml.py → transport/serialization.py} +69 -11
- schemathesis/transport/wsgi.py +171 -0
- schemathesis-4.0.0.dist-info/METADATA +204 -0
- schemathesis-4.0.0.dist-info/RECORD +164 -0
- {schemathesis-3.39.16.dist-info → schemathesis-4.0.0.dist-info}/entry_points.txt +1 -1
- {schemathesis-3.39.16.dist-info → schemathesis-4.0.0.dist-info}/licenses/LICENSE +1 -1
- schemathesis/_compat.py +0 -74
- schemathesis/_dependency_versions.py +0 -19
- schemathesis/_hypothesis.py +0 -717
- schemathesis/_override.py +0 -50
- schemathesis/_patches.py +0 -21
- schemathesis/_rate_limiter.py +0 -7
- schemathesis/cli/callbacks.py +0 -466
- schemathesis/cli/cassettes.py +0 -561
- schemathesis/cli/context.py +0 -75
- schemathesis/cli/debug.py +0 -27
- schemathesis/cli/handlers.py +0 -19
- schemathesis/cli/junitxml.py +0 -124
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -920
- schemathesis/cli/output/short.py +0 -59
- schemathesis/cli/reporting.py +0 -79
- schemathesis/cli/sanitization.py +0 -26
- schemathesis/code_samples.py +0 -151
- schemathesis/constants.py +0 -54
- schemathesis/contrib/__init__.py +0 -11
- schemathesis/contrib/openapi/__init__.py +0 -11
- schemathesis/contrib/openapi/fill_missing_examples.py +0 -24
- schemathesis/contrib/openapi/formats/__init__.py +0 -9
- schemathesis/contrib/openapi/formats/uuid.py +0 -16
- schemathesis/contrib/unique_data.py +0 -41
- schemathesis/exceptions.py +0 -571
- schemathesis/experimental/__init__.py +0 -109
- schemathesis/extra/_aiohttp.py +0 -28
- schemathesis/extra/_flask.py +0 -13
- schemathesis/extra/_server.py +0 -18
- schemathesis/failures.py +0 -284
- schemathesis/fixups/__init__.py +0 -37
- schemathesis/fixups/fast_api.py +0 -41
- schemathesis/fixups/utf8_bom.py +0 -28
- schemathesis/generation/_methods.py +0 -44
- schemathesis/graphql.py +0 -3
- schemathesis/internal/__init__.py +0 -7
- schemathesis/internal/checks.py +0 -86
- schemathesis/internal/copy.py +0 -32
- schemathesis/internal/datetime.py +0 -5
- schemathesis/internal/deprecation.py +0 -37
- schemathesis/internal/diff.py +0 -15
- schemathesis/internal/extensions.py +0 -27
- schemathesis/internal/jsonschema.py +0 -36
- schemathesis/internal/output.py +0 -68
- schemathesis/internal/transformation.py +0 -26
- schemathesis/internal/validation.py +0 -34
- schemathesis/lazy.py +0 -474
- schemathesis/loaders.py +0 -122
- schemathesis/models.py +0 -1341
- schemathesis/parameters.py +0 -90
- schemathesis/runner/__init__.py +0 -605
- schemathesis/runner/events.py +0 -389
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/context.py +0 -88
- schemathesis/runner/impl/core.py +0 -1280
- schemathesis/runner/impl/solo.py +0 -80
- schemathesis/runner/impl/threadpool.py +0 -391
- schemathesis/runner/serialization.py +0 -544
- schemathesis/sanitization.py +0 -252
- schemathesis/serializers.py +0 -328
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -11
- schemathesis/service/ci.py +0 -202
- schemathesis/service/client.py +0 -133
- schemathesis/service/constants.py +0 -38
- schemathesis/service/events.py +0 -61
- schemathesis/service/extensions.py +0 -224
- schemathesis/service/hosts.py +0 -111
- schemathesis/service/metadata.py +0 -71
- schemathesis/service/models.py +0 -258
- schemathesis/service/report.py +0 -255
- schemathesis/service/serialization.py +0 -173
- schemathesis/service/usage.py +0 -66
- schemathesis/specs/graphql/loaders.py +0 -364
- schemathesis/specs/openapi/expressions/context.py +0 -16
- schemathesis/specs/openapi/links.py +0 -389
- schemathesis/specs/openapi/loaders.py +0 -707
- schemathesis/specs/openapi/stateful/statistic.py +0 -198
- schemathesis/specs/openapi/stateful/types.py +0 -14
- schemathesis/specs/openapi/validation.py +0 -26
- schemathesis/stateful/__init__.py +0 -147
- schemathesis/stateful/config.py +0 -97
- schemathesis/stateful/context.py +0 -135
- schemathesis/stateful/events.py +0 -274
- schemathesis/stateful/runner.py +0 -309
- schemathesis/stateful/sink.py +0 -68
- schemathesis/stateful/state_machine.py +0 -328
- schemathesis/stateful/statistic.py +0 -22
- schemathesis/stateful/validation.py +0 -100
- schemathesis/targets.py +0 -77
- schemathesis/transports/__init__.py +0 -369
- schemathesis/transports/asgi.py +0 -7
- schemathesis/transports/auth.py +0 -38
- schemathesis/transports/headers.py +0 -36
- schemathesis/transports/responses.py +0 -57
- schemathesis/types.py +0 -44
- schemathesis/utils.py +0 -164
- schemathesis-3.39.16.dist-info/METADATA +0 -293
- schemathesis-3.39.16.dist-info/RECORD +0 -160
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
- /schemathesis/{_lazy_import.py → core/lazy_import.py} +0 -0
- /schemathesis/{internal → core}/result.py +0 -0
- {schemathesis-3.39.16.dist-info → schemathesis-4.0.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,315 @@
|
|
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
|
+
output += "\n" + formatter(MessageBlock.CURL, f"\nReproduce with: \n\n {curl}")
|
314
|
+
|
315
|
+
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,104 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import http.client
|
4
|
+
from typing import TYPE_CHECKING, Any, Callable, NoReturn
|
5
|
+
|
6
|
+
from schemathesis.core.errors import LoaderError, LoaderErrorKind, get_request_error_extras, get_request_error_message
|
7
|
+
from schemathesis.core.transport import DEFAULT_RESPONSE_TIMEOUT, USER_AGENT
|
8
|
+
|
9
|
+
if TYPE_CHECKING:
|
10
|
+
import requests
|
11
|
+
|
12
|
+
|
13
|
+
def prepare_request_kwargs(kwargs: dict[str, Any]) -> None:
|
14
|
+
"""Prepare common request kwargs."""
|
15
|
+
headers = kwargs.setdefault("headers", {})
|
16
|
+
if "user-agent" not in {header.lower() for header in headers}:
|
17
|
+
kwargs["headers"]["User-Agent"] = USER_AGENT
|
18
|
+
|
19
|
+
|
20
|
+
def handle_request_error(exc: requests.RequestException) -> NoReturn:
|
21
|
+
"""Handle request-level errors."""
|
22
|
+
import requests
|
23
|
+
|
24
|
+
url = exc.request.url if exc.request is not None else None
|
25
|
+
if isinstance(exc, requests.exceptions.SSLError):
|
26
|
+
kind = LoaderErrorKind.CONNECTION_SSL
|
27
|
+
elif isinstance(exc, requests.exceptions.ConnectionError):
|
28
|
+
kind = LoaderErrorKind.CONNECTION_OTHER
|
29
|
+
else:
|
30
|
+
kind = LoaderErrorKind.NETWORK_OTHER
|
31
|
+
raise LoaderError(
|
32
|
+
message=get_request_error_message(exc),
|
33
|
+
kind=kind,
|
34
|
+
url=url,
|
35
|
+
extras=get_request_error_extras(exc),
|
36
|
+
) from exc
|
37
|
+
|
38
|
+
|
39
|
+
def raise_for_status(response: requests.Response) -> requests.Response:
|
40
|
+
"""Handle response status codes."""
|
41
|
+
status_code = response.status_code
|
42
|
+
if status_code < 400:
|
43
|
+
return response
|
44
|
+
|
45
|
+
reason = http.client.responses.get(status_code, "Unknown")
|
46
|
+
if status_code >= 500:
|
47
|
+
message = f"Failed to load schema due to server error (HTTP {status_code} {reason})"
|
48
|
+
kind = LoaderErrorKind.HTTP_SERVER_ERROR
|
49
|
+
else:
|
50
|
+
message = f"Failed to load schema due to client error (HTTP {status_code} {reason})"
|
51
|
+
kind = (
|
52
|
+
LoaderErrorKind.HTTP_FORBIDDEN
|
53
|
+
if status_code == 403
|
54
|
+
else LoaderErrorKind.HTTP_NOT_FOUND
|
55
|
+
if status_code == 404
|
56
|
+
else LoaderErrorKind.HTTP_CLIENT_ERROR
|
57
|
+
)
|
58
|
+
raise LoaderError(message=message, kind=kind, url=response.request.url, extras=[])
|
59
|
+
|
60
|
+
|
61
|
+
def make_request(func: Callable[..., requests.Response], url: str, **kwargs: Any) -> requests.Response:
|
62
|
+
"""Make HTTP request with error handling."""
|
63
|
+
import requests
|
64
|
+
|
65
|
+
try:
|
66
|
+
response = func(url, **kwargs)
|
67
|
+
return raise_for_status(response)
|
68
|
+
except requests.RequestException as exc:
|
69
|
+
handle_request_error(exc)
|
70
|
+
|
71
|
+
|
72
|
+
WAIT_FOR_SCHEMA_INTERVAL = 0.05
|
73
|
+
|
74
|
+
|
75
|
+
def load_from_url(
|
76
|
+
func: Callable[..., requests.Response],
|
77
|
+
*,
|
78
|
+
url: str,
|
79
|
+
wait_for_schema: float | None = None,
|
80
|
+
**kwargs: Any,
|
81
|
+
) -> requests.Response:
|
82
|
+
"""Load schema from URL with retries."""
|
83
|
+
import backoff
|
84
|
+
import requests
|
85
|
+
|
86
|
+
kwargs.setdefault("timeout", DEFAULT_RESPONSE_TIMEOUT)
|
87
|
+
prepare_request_kwargs(kwargs)
|
88
|
+
|
89
|
+
if wait_for_schema is not None:
|
90
|
+
func = backoff.on_exception(
|
91
|
+
backoff.constant,
|
92
|
+
requests.exceptions.ConnectionError,
|
93
|
+
max_time=wait_for_schema,
|
94
|
+
interval=WAIT_FOR_SCHEMA_INTERVAL,
|
95
|
+
)(func)
|
96
|
+
|
97
|
+
return make_request(func, url, **kwargs)
|
98
|
+
|
99
|
+
|
100
|
+
def require_relative_url(url: str) -> None:
|
101
|
+
"""Raise an error if the URL is not relative."""
|
102
|
+
# Deliberately simplistic approach
|
103
|
+
if "://" in url or url.startswith("//"):
|
104
|
+
raise ValueError("Schema path should be relative for WSGI/ASGI loaders")
|
@@ -0,0 +1,66 @@
|
|
1
|
+
"""A lightweight mechanism to attach Schemathesis-specific metadata to test functions."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
from dataclasses import dataclass
|
6
|
+
from typing import Callable, Generic, TypeVar
|
7
|
+
|
8
|
+
from schemathesis.core import NOT_SET, NotSet
|
9
|
+
|
10
|
+
METADATA_ATTR = "_schemathesis_metadata"
|
11
|
+
|
12
|
+
|
13
|
+
@dataclass
|
14
|
+
class SchemathesisMetadata:
|
15
|
+
"""Container for all Schemathesis-specific data attached to test functions."""
|
16
|
+
|
17
|
+
|
18
|
+
T = TypeVar("T")
|
19
|
+
|
20
|
+
|
21
|
+
class Mark(Generic[T]):
|
22
|
+
"""Access to specific attributes in SchemathesisMetadata."""
|
23
|
+
|
24
|
+
def __init__(
|
25
|
+
self, *, attr_name: str, default: T | Callable[[], T] | None = None, check: Callable[[T], bool] | None = None
|
26
|
+
) -> None:
|
27
|
+
self.attr_name = attr_name
|
28
|
+
self._default = default
|
29
|
+
self._check = check
|
30
|
+
|
31
|
+
def _get_default(self) -> T | None:
|
32
|
+
if callable(self._default):
|
33
|
+
return self._default()
|
34
|
+
return self._default
|
35
|
+
|
36
|
+
def _check_value(self, value: T) -> bool:
|
37
|
+
if self._check is not None:
|
38
|
+
return self._check(value)
|
39
|
+
return True
|
40
|
+
|
41
|
+
def get(self, func: Callable) -> T | None:
|
42
|
+
"""Get marker value if it's set."""
|
43
|
+
metadata = getattr(func, METADATA_ATTR, None)
|
44
|
+
if metadata is None:
|
45
|
+
return self._get_default()
|
46
|
+
value = getattr(metadata, self.attr_name, NOT_SET)
|
47
|
+
if value is NOT_SET:
|
48
|
+
return self._get_default()
|
49
|
+
assert not isinstance(value, NotSet)
|
50
|
+
if self._check_value(value):
|
51
|
+
return value
|
52
|
+
return self._get_default()
|
53
|
+
|
54
|
+
def set(self, func: Callable, value: T) -> None:
|
55
|
+
"""Set marker value, creating metadata if needed."""
|
56
|
+
if not hasattr(func, METADATA_ATTR):
|
57
|
+
setattr(func, METADATA_ATTR, SchemathesisMetadata())
|
58
|
+
metadata = getattr(func, METADATA_ATTR)
|
59
|
+
setattr(metadata, self.attr_name, value)
|
60
|
+
|
61
|
+
def is_set(self, func: Callable) -> bool:
|
62
|
+
"""Check if function has metadata with this marker set."""
|
63
|
+
metadata = getattr(func, METADATA_ATTR, None)
|
64
|
+
if metadata is None:
|
65
|
+
return False
|
66
|
+
return hasattr(metadata, self.attr_name)
|
@@ -1,6 +1,8 @@
|
|
1
1
|
from functools import lru_cache
|
2
2
|
from typing import Generator, Tuple
|
3
3
|
|
4
|
+
from schemathesis.core.errors import MalformedMediaType
|
5
|
+
|
4
6
|
|
5
7
|
def _parseparam(s: str) -> Generator[str, None, None]:
|
6
8
|
while s[:1] == ";":
|
@@ -15,7 +17,7 @@ def _parseparam(s: str) -> Generator[str, None, None]:
|
|
15
17
|
s = s[end:]
|
16
18
|
|
17
19
|
|
18
|
-
def
|
20
|
+
def _parse_header(line: str) -> Tuple[str, dict]:
|
19
21
|
parts = _parseparam(";" + line)
|
20
22
|
key = parts.__next__()
|
21
23
|
pdict = {}
|
@@ -32,36 +34,36 @@ def parse_header(line: str) -> Tuple[str, dict]:
|
|
32
34
|
|
33
35
|
|
34
36
|
@lru_cache
|
35
|
-
def
|
37
|
+
def parse(media_type: str) -> Tuple[str, str]:
|
36
38
|
"""Parse Content Type and return main type and subtype."""
|
37
39
|
try:
|
38
|
-
|
39
|
-
main_type, sub_type =
|
40
|
+
media_type, _ = _parse_header(media_type)
|
41
|
+
main_type, sub_type = media_type.split("/", 1)
|
40
42
|
except ValueError as exc:
|
41
|
-
raise
|
43
|
+
raise MalformedMediaType(f"Malformed media type: `{media_type}`") from exc
|
42
44
|
return main_type.lower(), sub_type.lower()
|
43
45
|
|
44
46
|
|
45
|
-
def
|
47
|
+
def is_json(value: str) -> bool:
|
46
48
|
"""Detect whether the content type is JSON-compatible.
|
47
49
|
|
48
50
|
For example - ``application/problem+json`` matches.
|
49
51
|
"""
|
50
|
-
main, sub =
|
52
|
+
main, sub = parse(value)
|
51
53
|
return main == "application" and (sub == "json" or sub.endswith("+json"))
|
52
54
|
|
53
55
|
|
54
|
-
def
|
56
|
+
def is_yaml(value: str) -> bool:
|
55
57
|
"""Detect whether the content type is YAML-compatible."""
|
56
58
|
return value in ("text/yaml", "text/x-yaml", "application/x-yaml", "text/vnd.yaml")
|
57
59
|
|
58
60
|
|
59
|
-
def
|
61
|
+
def is_plain_text(value: str) -> bool:
|
60
62
|
"""Detect variations of the ``text/plain`` media type."""
|
61
|
-
return
|
63
|
+
return parse(value) == ("text", "plain")
|
62
64
|
|
63
65
|
|
64
|
-
def
|
66
|
+
def is_xml(value: str) -> bool:
|
65
67
|
"""Detect variations of the ``application/xml`` media type."""
|
66
|
-
_, sub =
|
68
|
+
_, sub = parse(value)
|
67
69
|
return sub == "xml" or sub.endswith("+xml")
|
@@ -0,0 +1,46 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import json
|
4
|
+
from typing import TYPE_CHECKING, Any
|
5
|
+
|
6
|
+
if TYPE_CHECKING:
|
7
|
+
from schemathesis.config import OutputConfig
|
8
|
+
|
9
|
+
TRUNCATED = "// Output truncated..."
|
10
|
+
|
11
|
+
|
12
|
+
def truncate_json(data: Any, *, config: OutputConfig, max_lines: int | None = None) -> str:
|
13
|
+
# Convert JSON to string with indentation
|
14
|
+
indent = 4
|
15
|
+
serialized = json.dumps(data, indent=indent)
|
16
|
+
if not config.truncation.enabled:
|
17
|
+
return serialized
|
18
|
+
|
19
|
+
max_lines = max_lines if max_lines is not None else config.truncation.max_lines
|
20
|
+
# Split string by lines
|
21
|
+
lines = [
|
22
|
+
line[: config.truncation.max_width - 3] + "..." if len(line) > config.truncation.max_width else line
|
23
|
+
for line in serialized.split("\n")
|
24
|
+
]
|
25
|
+
|
26
|
+
if len(lines) <= max_lines:
|
27
|
+
return "\n".join(lines)
|
28
|
+
|
29
|
+
truncated_lines = lines[: max_lines - 1]
|
30
|
+
indentation = " " * indent
|
31
|
+
truncated_lines.append(f"{indentation}{TRUNCATED}")
|
32
|
+
truncated_lines.append(lines[-1])
|
33
|
+
|
34
|
+
return "\n".join(truncated_lines)
|
35
|
+
|
36
|
+
|
37
|
+
def prepare_response_payload(payload: str, *, config: OutputConfig) -> str:
|
38
|
+
if payload.endswith("\r\n"):
|
39
|
+
payload = payload[:-2]
|
40
|
+
elif payload.endswith("\n"):
|
41
|
+
payload = payload[:-1]
|
42
|
+
if not config.truncation.enabled:
|
43
|
+
return payload
|
44
|
+
if len(payload) > config.truncation.max_payload_size:
|
45
|
+
payload = payload[: config.truncation.max_payload_size] + f" {TRUNCATED}"
|
46
|
+
return payload
|
@@ -0,0 +1,54 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from collections.abc import MutableMapping, MutableSequence
|
4
|
+
from typing import Any
|
5
|
+
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
|
6
|
+
|
7
|
+
from schemathesis.config import SanitizationConfig
|
8
|
+
|
9
|
+
|
10
|
+
def sanitize_value(item: Any, *, config: SanitizationConfig) -> None:
|
11
|
+
"""Sanitize sensitive values within a given item.
|
12
|
+
|
13
|
+
This function is recursive and will sanitize sensitive data within nested
|
14
|
+
dictionaries and lists as well.
|
15
|
+
"""
|
16
|
+
if isinstance(item, MutableMapping):
|
17
|
+
for key in list(item.keys()):
|
18
|
+
lower_key = key.lower()
|
19
|
+
if lower_key in config.keys_to_sanitize or any(marker in lower_key for marker in config.sensitive_markers):
|
20
|
+
if isinstance(item[key], list):
|
21
|
+
item[key] = [config.replacement]
|
22
|
+
else:
|
23
|
+
item[key] = config.replacement
|
24
|
+
for value in item.values():
|
25
|
+
if isinstance(value, (MutableMapping, MutableSequence)):
|
26
|
+
sanitize_value(value, config=config)
|
27
|
+
elif isinstance(item, MutableSequence):
|
28
|
+
for value in item:
|
29
|
+
if isinstance(value, (MutableMapping, MutableSequence)):
|
30
|
+
sanitize_value(value, config=config)
|
31
|
+
|
32
|
+
|
33
|
+
def sanitize_url(url: str, *, config: SanitizationConfig) -> str:
|
34
|
+
"""Sanitize sensitive parts of a given URL.
|
35
|
+
|
36
|
+
This function will sanitize the authority and query parameters in the URL.
|
37
|
+
"""
|
38
|
+
parsed = urlsplit(url)
|
39
|
+
|
40
|
+
# Sanitize authority
|
41
|
+
netloc_parts = parsed.netloc.split("@")
|
42
|
+
if len(netloc_parts) > 1:
|
43
|
+
netloc = f"{config.replacement}@{netloc_parts[-1]}"
|
44
|
+
else:
|
45
|
+
netloc = parsed.netloc
|
46
|
+
|
47
|
+
# Sanitize query parameters
|
48
|
+
query = parse_qs(parsed.query, keep_blank_values=True)
|
49
|
+
sanitize_value(query, config=config)
|
50
|
+
sanitized_query = urlencode(query, doseq=True)
|
51
|
+
|
52
|
+
# Reconstruct the URL
|
53
|
+
sanitized_url_parts = parsed._replace(netloc=netloc, query=sanitized_query)
|
54
|
+
return urlunsplit(sanitized_url_parts)
|