schemathesis 3.19.7__py3-none-any.whl → 3.20.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- schemathesis/_compat.py +3 -2
- schemathesis/_hypothesis.py +21 -6
- schemathesis/_xml.py +177 -0
- schemathesis/auths.py +48 -10
- schemathesis/cli/__init__.py +77 -19
- schemathesis/cli/callbacks.py +42 -18
- schemathesis/cli/context.py +2 -1
- schemathesis/cli/output/default.py +102 -34
- schemathesis/cli/sanitization.py +15 -0
- schemathesis/code_samples.py +141 -0
- schemathesis/constants.py +1 -24
- schemathesis/exceptions.py +127 -26
- schemathesis/experimental/__init__.py +85 -0
- schemathesis/extra/pytest_plugin.py +10 -4
- schemathesis/fixups/__init__.py +8 -2
- schemathesis/fixups/fast_api.py +11 -1
- schemathesis/fixups/utf8_bom.py +7 -1
- schemathesis/hooks.py +63 -0
- schemathesis/lazy.py +10 -4
- schemathesis/loaders.py +57 -0
- schemathesis/models.py +120 -96
- schemathesis/parameters.py +3 -0
- schemathesis/runner/__init__.py +3 -0
- schemathesis/runner/events.py +55 -20
- schemathesis/runner/impl/core.py +54 -54
- schemathesis/runner/serialization.py +75 -34
- schemathesis/sanitization.py +248 -0
- schemathesis/schemas.py +21 -6
- schemathesis/serializers.py +32 -3
- schemathesis/service/serialization.py +5 -1
- schemathesis/specs/graphql/loaders.py +44 -13
- schemathesis/specs/graphql/schemas.py +56 -25
- schemathesis/specs/openapi/_hypothesis.py +11 -23
- schemathesis/specs/openapi/definitions.py +572 -0
- schemathesis/specs/openapi/loaders.py +100 -49
- schemathesis/specs/openapi/parameters.py +2 -2
- schemathesis/specs/openapi/schemas.py +87 -13
- schemathesis/specs/openapi/security.py +1 -0
- schemathesis/stateful.py +2 -2
- schemathesis/utils.py +30 -9
- schemathesis-3.20.1.dist-info/METADATA +342 -0
- {schemathesis-3.19.7.dist-info → schemathesis-3.20.1.dist-info}/RECORD +45 -39
- schemathesis-3.19.7.dist-info/METADATA +0 -291
- {schemathesis-3.19.7.dist-info → schemathesis-3.20.1.dist-info}/WHEEL +0 -0
- {schemathesis-3.19.7.dist-info → schemathesis-3.20.1.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.19.7.dist-info → schemathesis-3.20.1.dist-info}/licenses/LICENSE +0 -0
schemathesis/exceptions.py
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
import enum
|
|
2
|
+
import json
|
|
3
|
+
from dataclasses import dataclass, field
|
|
2
4
|
from hashlib import sha1
|
|
3
5
|
from json import JSONDecodeError
|
|
4
6
|
from typing import TYPE_CHECKING, Any, Callable, Dict, Generator, List, NoReturn, Optional, Tuple, Type, Union
|
|
5
7
|
|
|
6
8
|
import hypothesis.errors
|
|
7
|
-
import
|
|
8
|
-
from jsonschema import ValidationError
|
|
9
|
+
from jsonschema import RefResolutionError, ValidationError
|
|
9
10
|
|
|
10
11
|
from .constants import SERIALIZERS_SUGGESTION_MESSAGE
|
|
11
12
|
from .failures import FailureContext
|
|
@@ -139,8 +140,11 @@ def get_timeout_error(deadline: Union[float, int]) -> Type[CheckFailed]:
|
|
|
139
140
|
return _get_hashed_exception("TimeoutError", str(deadline))
|
|
140
141
|
|
|
141
142
|
|
|
143
|
+
SCHEMA_ERROR_SUGGESTION = "Ensure that the definition complies with the OpenAPI specification"
|
|
144
|
+
|
|
145
|
+
|
|
142
146
|
@dataclass
|
|
143
|
-
class
|
|
147
|
+
class OperationSchemaError(Exception):
|
|
144
148
|
"""Schema associated with an API operation contains an error."""
|
|
145
149
|
|
|
146
150
|
__module__ = "builtins"
|
|
@@ -149,6 +153,44 @@ class InvalidSchema(Exception):
|
|
|
149
153
|
method: Optional[str] = None
|
|
150
154
|
full_path: Optional[str] = None
|
|
151
155
|
|
|
156
|
+
@classmethod
|
|
157
|
+
def from_jsonschema_error(
|
|
158
|
+
cls, error: ValidationError, path: Optional[str], method: Optional[str], full_path: Optional[str]
|
|
159
|
+
) -> "OperationSchemaError":
|
|
160
|
+
if error.absolute_path:
|
|
161
|
+
part = error.absolute_path[-1]
|
|
162
|
+
if isinstance(part, int) and len(error.absolute_path) > 1:
|
|
163
|
+
parent = error.absolute_path[-2]
|
|
164
|
+
message = f"Invalid definition for element at index {part} in `{parent}`"
|
|
165
|
+
else:
|
|
166
|
+
message = f"Invalid `{part}` definition"
|
|
167
|
+
else:
|
|
168
|
+
message = "Invalid schema definition"
|
|
169
|
+
error_path = " -> ".join((str(entry) for entry in error.path)) or "[root]"
|
|
170
|
+
message += f"\n\nLocation:\n {error_path}"
|
|
171
|
+
instance = truncated_json(error.instance)
|
|
172
|
+
message += f"\n\nProblematic definition:\n{instance}"
|
|
173
|
+
message += "\n\nError details:\n "
|
|
174
|
+
# This default message contains the instance which we already printed
|
|
175
|
+
if "is not valid under any of the given schemas" in error.message:
|
|
176
|
+
message += "The provided definition doesn't match any of the expected formats or types."
|
|
177
|
+
else:
|
|
178
|
+
message += error.message
|
|
179
|
+
message += f"\n\n{SCHEMA_ERROR_SUGGESTION}"
|
|
180
|
+
return cls(message, path=path, method=method, full_path=full_path)
|
|
181
|
+
|
|
182
|
+
@classmethod
|
|
183
|
+
def from_reference_resolution_error(
|
|
184
|
+
cls, error: RefResolutionError, path: Optional[str], method: Optional[str], full_path: Optional[str]
|
|
185
|
+
) -> "OperationSchemaError":
|
|
186
|
+
message = "Unresolvable JSON pointer in the schema"
|
|
187
|
+
# Get the pointer value from "Unresolvable JSON pointer: 'components/UnknownParameter'"
|
|
188
|
+
pointer = str(error).split(": ", 1)[-1]
|
|
189
|
+
message += f"\n\nError details:\n JSON pointer: {pointer}"
|
|
190
|
+
message += "\n This typically means that the schema is referencing a component that doesn't exist."
|
|
191
|
+
message += f"\n\n{SCHEMA_ERROR_SUGGESTION}"
|
|
192
|
+
return cls(message, path=path, method=method, full_path=full_path)
|
|
193
|
+
|
|
152
194
|
def as_failing_test_function(self) -> Callable:
|
|
153
195
|
"""Create a test function that will fail.
|
|
154
196
|
|
|
@@ -162,6 +204,26 @@ class InvalidSchema(Exception):
|
|
|
162
204
|
return actual_test
|
|
163
205
|
|
|
164
206
|
|
|
207
|
+
def truncated_json(data: Any, max_lines: int = 10, max_width: int = 80) -> str:
|
|
208
|
+
# Convert JSON to string with indentation
|
|
209
|
+
indent = 4
|
|
210
|
+
serialized = json.dumps(data, indent=indent)
|
|
211
|
+
|
|
212
|
+
# Split string by lines
|
|
213
|
+
|
|
214
|
+
lines = [line[: max_width - 3] + "..." if len(line) > max_width else line for line in serialized.split("\n")]
|
|
215
|
+
|
|
216
|
+
if len(lines) <= max_lines:
|
|
217
|
+
return "\n".join(lines)
|
|
218
|
+
|
|
219
|
+
truncated_lines = lines[: max_lines - 1]
|
|
220
|
+
indentation = " " * indent
|
|
221
|
+
truncated_lines.append(f"{indentation}// Output truncated...")
|
|
222
|
+
truncated_lines.append(lines[-1])
|
|
223
|
+
|
|
224
|
+
return "\n".join(truncated_lines)
|
|
225
|
+
|
|
226
|
+
|
|
165
227
|
class DeadlineExceeded(Exception):
|
|
166
228
|
"""Test took too long to run."""
|
|
167
229
|
|
|
@@ -176,9 +238,46 @@ class DeadlineExceeded(Exception):
|
|
|
176
238
|
)
|
|
177
239
|
|
|
178
240
|
|
|
179
|
-
|
|
241
|
+
@enum.unique
|
|
242
|
+
class SchemaErrorType(enum.Enum):
|
|
243
|
+
# Connection related issues
|
|
244
|
+
CONNECTION_SSL = "connection_ssl"
|
|
245
|
+
CONNECTION_OTHER = "connection_other"
|
|
246
|
+
NETWORK_OTHER = "network_other"
|
|
247
|
+
|
|
248
|
+
# HTTP error codes
|
|
249
|
+
HTTP_SERVER_ERROR = "http_server_error"
|
|
250
|
+
HTTP_CLIENT_ERROR = "http_client_error"
|
|
251
|
+
HTTP_NOT_FOUND = "http_not_found"
|
|
252
|
+
HTTP_FORBIDDEN = "http_forbidden"
|
|
253
|
+
|
|
254
|
+
# Content decoding issues
|
|
255
|
+
UNEXPECTED_CONTENT_TYPE = "unexpected_content_type"
|
|
256
|
+
YAML_NUMERIC_STATUS_CODES = "yaml_numeric_status_codes"
|
|
257
|
+
YAML_NON_STRING_KEYS = "yaml_non_string_keys"
|
|
258
|
+
|
|
259
|
+
# Open API validation
|
|
260
|
+
OPEN_API_INVALID_SCHEMA = "open_api_invalid_schema"
|
|
261
|
+
OPEN_API_UNSPECIFIED_VERSION = "open_api_unspecified_version"
|
|
262
|
+
OPEN_API_UNSUPPORTED_VERSION = "open_api_unsupported_version"
|
|
263
|
+
|
|
264
|
+
# Unclassified
|
|
265
|
+
UNCLASSIFIED = "unclassified"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@dataclass
|
|
269
|
+
class SchemaError(RuntimeError):
|
|
180
270
|
"""Failed to load an API schema."""
|
|
181
271
|
|
|
272
|
+
type: SchemaErrorType
|
|
273
|
+
message: str
|
|
274
|
+
url: Optional[str] = None
|
|
275
|
+
response: Optional["GenericResponse"] = None
|
|
276
|
+
extras: List[str] = field(default_factory=list)
|
|
277
|
+
|
|
278
|
+
def __str__(self) -> str:
|
|
279
|
+
return self.message
|
|
280
|
+
|
|
182
281
|
|
|
183
282
|
class NonCheckError(Exception):
|
|
184
283
|
"""An error happened in side the runner, but is not related to failed checks.
|
|
@@ -205,13 +304,35 @@ class SkipTest(BaseException):
|
|
|
205
304
|
SERIALIZATION_NOT_POSSIBLE_MESSAGE = (
|
|
206
305
|
f"Schemathesis can't serialize data to any of the defined media types: {{}} \n{SERIALIZERS_SUGGESTION_MESSAGE}"
|
|
207
306
|
)
|
|
307
|
+
NAMESPACE_DEFINITION_URL = "https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#xmlNamespace"
|
|
308
|
+
UNBOUND_PREFIX_MESSAGE_TEMPLATE = (
|
|
309
|
+
"Unbound prefix: `{prefix}`. "
|
|
310
|
+
"You need to define this namespace in your API schema via the `xml.namespace` keyword. "
|
|
311
|
+
f"See more at {NAMESPACE_DEFINITION_URL}"
|
|
312
|
+
)
|
|
208
313
|
|
|
209
314
|
SERIALIZATION_FOR_TYPE_IS_NOT_POSSIBLE_MESSAGE = (
|
|
210
315
|
f"Schemathesis can't serialize data to {{}} \n{SERIALIZERS_SUGGESTION_MESSAGE}"
|
|
211
316
|
)
|
|
212
317
|
|
|
213
318
|
|
|
214
|
-
class
|
|
319
|
+
class SerializationError(Exception):
|
|
320
|
+
"""Serialization can not be done."""
|
|
321
|
+
|
|
322
|
+
__module__ = "builtins"
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class UnboundPrefixError(SerializationError):
|
|
326
|
+
"""XML serialization error.
|
|
327
|
+
|
|
328
|
+
It happens when the schema does not define a namespace that is used by some of its parts.
|
|
329
|
+
"""
|
|
330
|
+
|
|
331
|
+
def __init__(self, prefix: str):
|
|
332
|
+
super().__init__(UNBOUND_PREFIX_MESSAGE_TEMPLATE.format(prefix=prefix))
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class SerializationNotPossible(SerializationError):
|
|
215
336
|
"""Not possible to serialize to any of the media types defined for some API operation.
|
|
216
337
|
|
|
217
338
|
Usually, there is still `application/json` along with less common ones, but this error happens when there is no
|
|
@@ -233,25 +354,5 @@ class InvalidRegularExpression(Exception):
|
|
|
233
354
|
__module__ = "builtins"
|
|
234
355
|
|
|
235
356
|
|
|
236
|
-
@dataclass
|
|
237
|
-
class HTTPError(Exception):
|
|
238
|
-
response: "GenericResponse"
|
|
239
|
-
url: str
|
|
240
|
-
|
|
241
|
-
@classmethod
|
|
242
|
-
def raise_for_status(cls, response: requests.Response) -> None:
|
|
243
|
-
try:
|
|
244
|
-
response.raise_for_status()
|
|
245
|
-
except requests.HTTPError as exc:
|
|
246
|
-
raise cls(response=response, url=response.url) from exc
|
|
247
|
-
|
|
248
|
-
@classmethod
|
|
249
|
-
def check_response(cls, response: requests.Response, schema_path: str) -> None:
|
|
250
|
-
# Raising exception to provide unified behavior
|
|
251
|
-
# E.g. it will be handled in CLI - a proper error message will be shown
|
|
252
|
-
if 400 <= response.status_code < 600:
|
|
253
|
-
raise cls(response=response, url=schema_path)
|
|
254
|
-
|
|
255
|
-
|
|
256
357
|
class UsageError(Exception):
|
|
257
358
|
"""Incorrect usage of Schemathesis functions."""
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(eq=False)
|
|
6
|
+
class Experiment:
|
|
7
|
+
name: str
|
|
8
|
+
verbose_name: str
|
|
9
|
+
env_var: str
|
|
10
|
+
description: str
|
|
11
|
+
discussion_url: str
|
|
12
|
+
_storage: "ExperimentSet" = field(repr=False)
|
|
13
|
+
|
|
14
|
+
def enable(self) -> None:
|
|
15
|
+
self._storage.enable(self)
|
|
16
|
+
|
|
17
|
+
def disable(self) -> None:
|
|
18
|
+
self._storage.disable(self)
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def is_enabled(self) -> bool:
|
|
22
|
+
return self._storage.is_enabled(self)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ExperimentSet:
|
|
27
|
+
_local_data: threading.local = field(default_factory=threading.local, repr=False)
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
self.available = set()
|
|
31
|
+
self.enabled = set()
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def available(self) -> set:
|
|
35
|
+
return self._local_data.available
|
|
36
|
+
|
|
37
|
+
@available.setter
|
|
38
|
+
def available(self, value: set) -> None:
|
|
39
|
+
self._local_data.available = value
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def enabled(self) -> set:
|
|
43
|
+
return self._local_data.enabled
|
|
44
|
+
|
|
45
|
+
@enabled.setter
|
|
46
|
+
def enabled(self, value: set) -> None:
|
|
47
|
+
self._local_data.enabled = value
|
|
48
|
+
|
|
49
|
+
def create_experiment(
|
|
50
|
+
self, name: str, verbose_name: str, env_var: str, description: str, discussion_url: str
|
|
51
|
+
) -> Experiment:
|
|
52
|
+
instance = Experiment(
|
|
53
|
+
name=name,
|
|
54
|
+
verbose_name=verbose_name,
|
|
55
|
+
env_var=f"{ENV_PREFIX}_{env_var}",
|
|
56
|
+
description=description,
|
|
57
|
+
discussion_url=discussion_url,
|
|
58
|
+
_storage=self,
|
|
59
|
+
)
|
|
60
|
+
self.available.add(instance)
|
|
61
|
+
return instance
|
|
62
|
+
|
|
63
|
+
def enable(self, feature: Experiment) -> None:
|
|
64
|
+
self.enabled.add(feature)
|
|
65
|
+
|
|
66
|
+
def disable(self, feature: Experiment) -> None:
|
|
67
|
+
self.enabled.discard(feature)
|
|
68
|
+
|
|
69
|
+
def disable_all(self) -> None:
|
|
70
|
+
self.enabled.clear()
|
|
71
|
+
|
|
72
|
+
def is_enabled(self, feature: Experiment) -> bool:
|
|
73
|
+
return feature in self.enabled
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
ENV_PREFIX = "SCHEMATHESIS_EXPERIMENTAL"
|
|
77
|
+
GLOBAL_EXPERIMENTS = ExperimentSet()
|
|
78
|
+
|
|
79
|
+
OPEN_API_3_1 = GLOBAL_EXPERIMENTS.create_experiment(
|
|
80
|
+
name="openapi-3.1",
|
|
81
|
+
verbose_name="OpenAPI 3.1",
|
|
82
|
+
env_var="OPENAPI_3_1",
|
|
83
|
+
description="Support for response validation",
|
|
84
|
+
discussion_url="https://github.com/schemathesis/schemathesis/discussions/1822",
|
|
85
|
+
)
|
|
@@ -14,7 +14,7 @@ from hypothesis_jsonschema._canonicalise import HypothesisRefResolutionError
|
|
|
14
14
|
|
|
15
15
|
from .._hypothesis import create_test
|
|
16
16
|
from ..constants import IS_PYTEST_ABOVE_7, IS_PYTEST_ABOVE_54, RECURSIVE_REFERENCE_ERROR_MESSAGE
|
|
17
|
-
from ..exceptions import
|
|
17
|
+
from ..exceptions import OperationSchemaError, SkipTest
|
|
18
18
|
from ..models import APIOperation
|
|
19
19
|
from ..utils import (
|
|
20
20
|
PARAMETRIZE_MARKER,
|
|
@@ -87,7 +87,9 @@ class SchemathesisCase(PyCollector):
|
|
|
87
87
|
def _get_test_name(self, operation: APIOperation) -> str:
|
|
88
88
|
return f"{self.name}[{operation.verbose_name}]"
|
|
89
89
|
|
|
90
|
-
def _gen_items(
|
|
90
|
+
def _gen_items(
|
|
91
|
+
self, result: Result[APIOperation, OperationSchemaError]
|
|
92
|
+
) -> Generator[SchemathesisFunction, None, None]:
|
|
91
93
|
"""Generate all tests for the given API operation.
|
|
92
94
|
|
|
93
95
|
Could produce more than one test item if
|
|
@@ -183,7 +185,11 @@ class SchemathesisCase(PyCollector):
|
|
|
183
185
|
"""Generate different test items for all API operations available in the given schema."""
|
|
184
186
|
try:
|
|
185
187
|
items = [
|
|
186
|
-
item
|
|
188
|
+
item
|
|
189
|
+
for operation in self.schemathesis_case.get_all_operations(
|
|
190
|
+
hooks=getattr(self.test_function, "_schemathesis_hooks", None)
|
|
191
|
+
)
|
|
192
|
+
for item in self._gen_items(operation)
|
|
187
193
|
]
|
|
188
194
|
if not items:
|
|
189
195
|
fail_on_no_matches(self.nodeid)
|
|
@@ -234,7 +240,7 @@ def pytest_pyfunc_call(pyfuncitem): # type:ignore
|
|
|
234
240
|
try:
|
|
235
241
|
outcome.get_result()
|
|
236
242
|
except InvalidArgument as exc:
|
|
237
|
-
raise
|
|
243
|
+
raise OperationSchemaError(exc.args[0]) from None
|
|
238
244
|
except HypothesisRefResolutionError:
|
|
239
245
|
pytest.skip(RECURSIVE_REFERENCE_ERROR_MESSAGE)
|
|
240
246
|
except SkipTest as exc:
|
schemathesis/fixups/__init__.py
CHANGED
|
@@ -3,6 +3,7 @@ from typing import Iterable, Optional
|
|
|
3
3
|
from . import fast_api, utf8_bom
|
|
4
4
|
|
|
5
5
|
ALL_FIXUPS = {"fast_api": fast_api, "utf8_bom": utf8_bom}
|
|
6
|
+
ALL_FIXUP_NAMES = list(ALL_FIXUPS.keys())
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
def install(fixups: Optional[Iterable[str]] = None) -> None:
|
|
@@ -12,7 +13,7 @@ def install(fixups: Optional[Iterable[str]] = None) -> None:
|
|
|
12
13
|
|
|
13
14
|
:param fixups: Names of fixups to install.
|
|
14
15
|
"""
|
|
15
|
-
fixups = fixups or
|
|
16
|
+
fixups = fixups or ALL_FIXUP_NAMES
|
|
16
17
|
for name in fixups:
|
|
17
18
|
ALL_FIXUPS[name].install() # type: ignore
|
|
18
19
|
|
|
@@ -24,6 +25,11 @@ def uninstall(fixups: Optional[Iterable[str]] = None) -> None:
|
|
|
24
25
|
|
|
25
26
|
:param fixups: Names of fixups to uninstall.
|
|
26
27
|
"""
|
|
27
|
-
fixups = fixups or
|
|
28
|
+
fixups = fixups or ALL_FIXUP_NAMES
|
|
28
29
|
for name in fixups:
|
|
29
30
|
ALL_FIXUPS[name].uninstall() # type: ignore
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_installed(name: str) -> bool:
|
|
34
|
+
"""Check whether fixup is installed."""
|
|
35
|
+
return ALL_FIXUPS[name].is_installed()
|
schemathesis/fixups/fast_api.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from typing import Any, Dict
|
|
2
2
|
|
|
3
|
-
from ..hooks import HookContext
|
|
3
|
+
from ..hooks import HookContext
|
|
4
|
+
from ..hooks import is_installed as global_is_installed
|
|
5
|
+
from ..hooks import register, unregister
|
|
4
6
|
from ..utils import traverse_schema
|
|
5
7
|
|
|
6
8
|
|
|
@@ -12,7 +14,15 @@ def uninstall() -> None:
|
|
|
12
14
|
unregister(before_load_schema)
|
|
13
15
|
|
|
14
16
|
|
|
17
|
+
def is_installed() -> bool:
|
|
18
|
+
return global_is_installed("before_load_schema", before_load_schema)
|
|
19
|
+
|
|
20
|
+
|
|
15
21
|
def before_load_schema(context: HookContext, schema: Dict[str, Any]) -> None:
|
|
22
|
+
adjust_schema(schema)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def adjust_schema(schema: Dict[str, Any]) -> None:
|
|
16
26
|
traverse_schema(schema, _handle_boundaries)
|
|
17
27
|
|
|
18
28
|
|
schemathesis/fixups/utf8_bom.py
CHANGED
|
@@ -3,7 +3,9 @@ from typing import TYPE_CHECKING
|
|
|
3
3
|
import requests
|
|
4
4
|
|
|
5
5
|
from ..constants import BOM_MARK
|
|
6
|
-
from ..hooks import HookContext
|
|
6
|
+
from ..hooks import HookContext
|
|
7
|
+
from ..hooks import is_installed as global_is_installed
|
|
8
|
+
from ..hooks import register, unregister
|
|
7
9
|
|
|
8
10
|
if TYPE_CHECKING:
|
|
9
11
|
from .. import Case, GenericResponse
|
|
@@ -17,6 +19,10 @@ def uninstall() -> None:
|
|
|
17
19
|
unregister(after_call)
|
|
18
20
|
|
|
19
21
|
|
|
22
|
+
def is_installed() -> bool:
|
|
23
|
+
return global_is_installed("after_call", after_call)
|
|
24
|
+
|
|
25
|
+
|
|
20
26
|
def after_call(context: HookContext, case: "Case", response: "GenericResponse") -> None:
|
|
21
27
|
if isinstance(response, requests.Response) and response.encoding == "utf-8" and response.text[0:1] == BOM_MARK:
|
|
22
28
|
response.encoding = "utf-8-sig"
|
schemathesis/hooks.py
CHANGED
|
@@ -3,6 +3,7 @@ from collections import defaultdict
|
|
|
3
3
|
from copy import deepcopy
|
|
4
4
|
from dataclasses import dataclass, field
|
|
5
5
|
from enum import Enum, unique
|
|
6
|
+
from functools import partial
|
|
6
7
|
from typing import TYPE_CHECKING, Any, Callable, ClassVar, DefaultDict, Dict, List, Optional, Union, cast
|
|
7
8
|
|
|
8
9
|
from hypothesis import strategies as st
|
|
@@ -178,6 +179,28 @@ class HookDispatcher:
|
|
|
178
179
|
"""Get a list of hooks registered for a name."""
|
|
179
180
|
return self._hooks.get(name, [])
|
|
180
181
|
|
|
182
|
+
def is_installed(self, name: str, needle: Callable) -> bool:
|
|
183
|
+
for hook in self.get_all_by_name(name):
|
|
184
|
+
if hook is needle:
|
|
185
|
+
return True
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
def apply_to_container(
|
|
189
|
+
self, strategy: st.SearchStrategy, container: str, context: HookContext
|
|
190
|
+
) -> st.SearchStrategy:
|
|
191
|
+
for hook in self.get_all_by_name(f"before_generate_{container}"):
|
|
192
|
+
strategy = hook(context, strategy)
|
|
193
|
+
for hook in self.get_all_by_name(f"filter_{container}"):
|
|
194
|
+
hook = partial(hook, context)
|
|
195
|
+
strategy = strategy.filter(hook)
|
|
196
|
+
for hook in self.get_all_by_name(f"map_{container}"):
|
|
197
|
+
hook = partial(hook, context)
|
|
198
|
+
strategy = strategy.map(hook)
|
|
199
|
+
for hook in self.get_all_by_name(f"flatmap_{container}"):
|
|
200
|
+
hook = partial(hook, context)
|
|
201
|
+
strategy = strategy.flatmap(hook)
|
|
202
|
+
return strategy
|
|
203
|
+
|
|
181
204
|
def dispatch(self, name: str, context: HookContext, *args: Any, **kwargs: Any) -> None:
|
|
182
205
|
"""Run all hooks for the given name."""
|
|
183
206
|
for hook in self.get_all_by_name(name):
|
|
@@ -200,9 +223,43 @@ class HookDispatcher:
|
|
|
200
223
|
self._hooks = defaultdict(list)
|
|
201
224
|
|
|
202
225
|
|
|
226
|
+
def apply_to_all_dispatchers(
|
|
227
|
+
operation: "APIOperation",
|
|
228
|
+
context: HookContext,
|
|
229
|
+
hooks: Optional[HookDispatcher],
|
|
230
|
+
strategy: st.SearchStrategy,
|
|
231
|
+
container: str,
|
|
232
|
+
) -> st.SearchStrategy:
|
|
233
|
+
"""Apply all hooks related to the given location."""
|
|
234
|
+
strategy = GLOBAL_HOOK_DISPATCHER.apply_to_container(strategy, container, context)
|
|
235
|
+
strategy = operation.schema.hooks.apply_to_container(strategy, container, context)
|
|
236
|
+
if hooks is not None:
|
|
237
|
+
strategy = hooks.apply_to_container(strategy, container, context)
|
|
238
|
+
return strategy
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def should_skip_operation(dispatcher: HookDispatcher, context: HookContext) -> bool:
|
|
242
|
+
for hook in dispatcher.get_all_by_name("filter_operations"):
|
|
243
|
+
if not hook(context):
|
|
244
|
+
return True
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
|
|
203
248
|
all_scopes = HookDispatcher.register_spec(list(HookScope))
|
|
204
249
|
|
|
205
250
|
|
|
251
|
+
for action in ("filter", "map", "flatmap"):
|
|
252
|
+
for target in ("path_parameters", "query", "headers", "cookies", "body", "case"):
|
|
253
|
+
exec(
|
|
254
|
+
f"""
|
|
255
|
+
@all_scopes
|
|
256
|
+
def {action}_{target}(context: HookContext, {target}: Any) -> Any:
|
|
257
|
+
pass
|
|
258
|
+
""",
|
|
259
|
+
globals(),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
206
263
|
@all_scopes
|
|
207
264
|
def before_generate_path_parameters(context: HookContext, strategy: st.SearchStrategy) -> st.SearchStrategy:
|
|
208
265
|
"""Called on a strategy that generates values for ``path_parameters``."""
|
|
@@ -238,6 +295,11 @@ def before_process_path(context: HookContext, path: str, methods: Dict[str, Any]
|
|
|
238
295
|
"""Called before API path is processed."""
|
|
239
296
|
|
|
240
297
|
|
|
298
|
+
@all_scopes
|
|
299
|
+
def filter_operations(context: HookContext) -> Optional[bool]:
|
|
300
|
+
"""Decide whether testing of this particular API operation should be skipped or not."""
|
|
301
|
+
|
|
302
|
+
|
|
241
303
|
@HookDispatcher.register_spec([HookScope.GLOBAL])
|
|
242
304
|
def before_load_schema(context: HookContext, raw_schema: Dict[str, Any]) -> None:
|
|
243
305
|
"""Called before schema instance is created."""
|
|
@@ -294,6 +356,7 @@ def after_call(context: HookContext, case: "Case", response: GenericResponse) ->
|
|
|
294
356
|
GLOBAL_HOOK_DISPATCHER = HookDispatcher(scope=HookScope.GLOBAL)
|
|
295
357
|
dispatch = GLOBAL_HOOK_DISPATCHER.dispatch
|
|
296
358
|
get_all_by_name = GLOBAL_HOOK_DISPATCHER.get_all_by_name
|
|
359
|
+
is_installed = GLOBAL_HOOK_DISPATCHER.is_installed
|
|
297
360
|
collect_statistic = GLOBAL_HOOK_DISPATCHER.collect_statistic
|
|
298
361
|
register = GLOBAL_HOOK_DISPATCHER.register
|
|
299
362
|
unregister = GLOBAL_HOOK_DISPATCHER.unregister
|
schemathesis/lazy.py
CHANGED
|
@@ -13,8 +13,9 @@ from pytest_subtests import SubTests, nullcontext
|
|
|
13
13
|
|
|
14
14
|
from ._compat import MultipleFailures
|
|
15
15
|
from .auths import AuthStorage
|
|
16
|
-
from .
|
|
17
|
-
from .
|
|
16
|
+
from .code_samples import CodeSampleStyle
|
|
17
|
+
from .constants import FLAKY_FAILURE_MESSAGE
|
|
18
|
+
from .exceptions import CheckFailed, OperationSchemaError, SkipTest, get_grouped_exception
|
|
18
19
|
from .hooks import HookDispatcher, HookScope
|
|
19
20
|
from .models import APIOperation
|
|
20
21
|
from .schemas import BaseSchema
|
|
@@ -49,6 +50,7 @@ class LazySchema:
|
|
|
49
50
|
data_generation_methods: Union[DataGenerationMethodInput, NotSet] = NOT_SET
|
|
50
51
|
code_sample_style: CodeSampleStyle = CodeSampleStyle.default()
|
|
51
52
|
rate_limiter: Optional[Limiter] = None
|
|
53
|
+
sanitize_output: bool = True
|
|
52
54
|
|
|
53
55
|
def hook(self, hook: Union[str, Callable]) -> Callable:
|
|
54
56
|
return self.hooks.register(hook)
|
|
@@ -115,12 +117,13 @@ class LazySchema:
|
|
|
115
117
|
code_sample_style=_code_sample_style,
|
|
116
118
|
app=self.app,
|
|
117
119
|
rate_limiter=self.rate_limiter,
|
|
120
|
+
sanitize_output=self.sanitize_output,
|
|
118
121
|
)
|
|
119
122
|
fixtures = get_fixtures(test, request, given_kwargs)
|
|
120
123
|
# Changing the node id is required for better reporting - the method and path will appear there
|
|
121
124
|
node_id = request.node._nodeid
|
|
122
125
|
settings = getattr(wrapped_test, "_hypothesis_internal_use_settings", None)
|
|
123
|
-
tests = list(schema.get_all_tests(test, settings, _given_kwargs=given_kwargs))
|
|
126
|
+
tests = list(schema.get_all_tests(test, settings, hooks=self.hooks, _given_kwargs=given_kwargs))
|
|
124
127
|
if not tests:
|
|
125
128
|
fail_on_no_matches(node_id)
|
|
126
129
|
request.session.testscollected += len(tests)
|
|
@@ -244,7 +247,7 @@ def run_subtest(
|
|
|
244
247
|
SEPARATOR = "\n===================="
|
|
245
248
|
|
|
246
249
|
|
|
247
|
-
def _schema_error(subtests: SubTests, error:
|
|
250
|
+
def _schema_error(subtests: SubTests, error: OperationSchemaError, node_id: str) -> None:
|
|
248
251
|
"""Run a failing test, that will show the underlying problem."""
|
|
249
252
|
sub_test = error.as_failing_test_function()
|
|
250
253
|
# `full_path` is always available in this case
|
|
@@ -252,6 +255,7 @@ def _schema_error(subtests: SubTests, error: InvalidSchema, node_id: str) -> Non
|
|
|
252
255
|
if error.method:
|
|
253
256
|
kwargs["method"] = error.method.upper()
|
|
254
257
|
subtests.item._nodeid = _get_partial_node_name(node_id, **kwargs)
|
|
258
|
+
__tracebackhide__ = True
|
|
255
259
|
with subtests.test(**kwargs):
|
|
256
260
|
sub_test()
|
|
257
261
|
|
|
@@ -274,6 +278,7 @@ def get_schema(
|
|
|
274
278
|
data_generation_methods: Union[DataGenerationMethodInput, NotSet] = NOT_SET,
|
|
275
279
|
code_sample_style: CodeSampleStyle,
|
|
276
280
|
rate_limiter: Optional[Limiter],
|
|
281
|
+
sanitize_output: bool,
|
|
277
282
|
) -> BaseSchema:
|
|
278
283
|
"""Loads a schema from the fixture."""
|
|
279
284
|
schema = request.getfixturevalue(name)
|
|
@@ -294,6 +299,7 @@ def get_schema(
|
|
|
294
299
|
data_generation_methods=data_generation_methods,
|
|
295
300
|
code_sample_style=code_sample_style,
|
|
296
301
|
rate_limiter=rate_limiter,
|
|
302
|
+
sanitize_output=sanitize_output,
|
|
297
303
|
)
|
|
298
304
|
|
|
299
305
|
|
schemathesis/loaders.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import http.client
|
|
2
|
+
import re
|
|
3
|
+
from typing import Callable, TypeVar, cast
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from .exceptions import SchemaError, SchemaErrorType
|
|
8
|
+
from .utils import GenericResponse
|
|
9
|
+
|
|
10
|
+
R = TypeVar("R", bound=GenericResponse)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def remove_ssl_line_number(text: str) -> str:
|
|
14
|
+
return re.sub(r"\(_ssl\.c:\d+\)", "", text)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_schema_from_url(loader: Callable[[], R]) -> R:
|
|
18
|
+
try:
|
|
19
|
+
response = loader()
|
|
20
|
+
except requests.RequestException as exc:
|
|
21
|
+
request = cast(requests.PreparedRequest, exc.request)
|
|
22
|
+
if isinstance(exc, requests.exceptions.SSLError):
|
|
23
|
+
message = "SSL verification problem"
|
|
24
|
+
type_ = SchemaErrorType.CONNECTION_SSL
|
|
25
|
+
reason = str(exc.args[0].reason)
|
|
26
|
+
extra = [remove_ssl_line_number(reason)]
|
|
27
|
+
elif isinstance(exc, requests.exceptions.ConnectionError):
|
|
28
|
+
message = "Connection failed"
|
|
29
|
+
type_ = SchemaErrorType.CONNECTION_OTHER
|
|
30
|
+
_, reason = exc.args[0].reason.args[0].split(":", maxsplit=1)
|
|
31
|
+
extra = [reason.strip()]
|
|
32
|
+
else:
|
|
33
|
+
message = "Network problem"
|
|
34
|
+
type_ = SchemaErrorType.NETWORK_OTHER
|
|
35
|
+
extra = []
|
|
36
|
+
raise SchemaError(message=message, type=type_, url=request.url, response=exc.response, extras=extra) from exc
|
|
37
|
+
_raise_for_status(response)
|
|
38
|
+
return response
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _raise_for_status(response: GenericResponse) -> None:
|
|
42
|
+
status_code = response.status_code
|
|
43
|
+
reason = http.client.responses.get(status_code, "Unknown")
|
|
44
|
+
if status_code >= 500:
|
|
45
|
+
message = f"Failed to load schema due to server error (HTTP {status_code} {reason})"
|
|
46
|
+
type_ = SchemaErrorType.HTTP_SERVER_ERROR
|
|
47
|
+
elif status_code >= 400:
|
|
48
|
+
message = f"Failed to load schema due to client error (HTTP {status_code} {reason})"
|
|
49
|
+
if status_code == 403:
|
|
50
|
+
type_ = SchemaErrorType.HTTP_FORBIDDEN
|
|
51
|
+
elif status_code == 404:
|
|
52
|
+
type_ = SchemaErrorType.HTTP_NOT_FOUND
|
|
53
|
+
else:
|
|
54
|
+
type_ = SchemaErrorType.HTTP_CLIENT_ERROR
|
|
55
|
+
else:
|
|
56
|
+
return None
|
|
57
|
+
raise SchemaError(message=message, type=type_, url=response.request.url, response=response, extras=[])
|