schemathesis 3.21.2__py3-none-any.whl → 3.22.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/__init__.py +1 -1
- schemathesis/_compat.py +2 -18
- schemathesis/_dependency_versions.py +1 -6
- schemathesis/_hypothesis.py +15 -12
- schemathesis/_lazy_import.py +3 -2
- schemathesis/_xml.py +12 -11
- schemathesis/auths.py +88 -81
- schemathesis/checks.py +4 -4
- schemathesis/cli/__init__.py +202 -171
- schemathesis/cli/callbacks.py +29 -32
- schemathesis/cli/cassettes.py +25 -25
- schemathesis/cli/context.py +18 -12
- schemathesis/cli/junitxml.py +2 -2
- schemathesis/cli/options.py +10 -11
- schemathesis/cli/output/default.py +64 -34
- schemathesis/code_samples.py +10 -10
- schemathesis/constants.py +1 -1
- schemathesis/contrib/unique_data.py +2 -2
- schemathesis/exceptions.py +55 -42
- schemathesis/extra/_aiohttp.py +2 -2
- schemathesis/extra/_flask.py +2 -2
- schemathesis/extra/_server.py +3 -2
- schemathesis/extra/pytest_plugin.py +10 -10
- schemathesis/failures.py +16 -16
- schemathesis/filters.py +40 -41
- schemathesis/fixups/__init__.py +4 -3
- schemathesis/fixups/fast_api.py +5 -4
- schemathesis/generation/__init__.py +16 -4
- schemathesis/hooks.py +25 -25
- schemathesis/internal/jsonschema.py +4 -3
- schemathesis/internal/transformation.py +3 -2
- schemathesis/lazy.py +39 -31
- schemathesis/loaders.py +8 -8
- schemathesis/models.py +128 -126
- schemathesis/parameters.py +6 -5
- schemathesis/runner/__init__.py +107 -81
- schemathesis/runner/events.py +37 -26
- schemathesis/runner/impl/core.py +86 -81
- schemathesis/runner/impl/solo.py +19 -15
- schemathesis/runner/impl/threadpool.py +40 -22
- schemathesis/runner/serialization.py +67 -40
- schemathesis/sanitization.py +18 -20
- schemathesis/schemas.py +83 -72
- schemathesis/serializers.py +39 -30
- schemathesis/service/ci.py +20 -21
- schemathesis/service/client.py +29 -9
- schemathesis/service/constants.py +1 -0
- schemathesis/service/events.py +2 -2
- schemathesis/service/hosts.py +8 -7
- schemathesis/service/metadata.py +5 -0
- schemathesis/service/models.py +22 -4
- schemathesis/service/report.py +15 -15
- schemathesis/service/serialization.py +23 -27
- schemathesis/service/usage.py +8 -7
- schemathesis/specs/graphql/loaders.py +31 -24
- schemathesis/specs/graphql/nodes.py +3 -2
- schemathesis/specs/graphql/scalars.py +26 -2
- schemathesis/specs/graphql/schemas.py +38 -34
- schemathesis/specs/openapi/_hypothesis.py +62 -44
- schemathesis/specs/openapi/checks.py +10 -10
- schemathesis/specs/openapi/converter.py +10 -9
- schemathesis/specs/openapi/definitions.py +2 -2
- schemathesis/specs/openapi/examples.py +22 -21
- schemathesis/specs/openapi/expressions/nodes.py +5 -4
- schemathesis/specs/openapi/expressions/parser.py +7 -6
- schemathesis/specs/openapi/filters.py +6 -6
- schemathesis/specs/openapi/formats.py +2 -2
- schemathesis/specs/openapi/links.py +19 -21
- schemathesis/specs/openapi/loaders.py +133 -78
- schemathesis/specs/openapi/negative/__init__.py +16 -11
- schemathesis/specs/openapi/negative/mutations.py +11 -10
- schemathesis/specs/openapi/parameters.py +20 -19
- schemathesis/specs/openapi/references.py +21 -20
- schemathesis/specs/openapi/schemas.py +97 -84
- schemathesis/specs/openapi/security.py +25 -24
- schemathesis/specs/openapi/serialization.py +20 -23
- schemathesis/specs/openapi/stateful/__init__.py +12 -11
- schemathesis/specs/openapi/stateful/links.py +7 -7
- schemathesis/specs/openapi/utils.py +4 -3
- schemathesis/specs/openapi/validation.py +3 -2
- schemathesis/stateful/__init__.py +15 -16
- schemathesis/stateful/state_machine.py +9 -9
- schemathesis/targets.py +3 -3
- schemathesis/throttling.py +2 -2
- schemathesis/transports/auth.py +2 -2
- schemathesis/transports/content_types.py +5 -0
- schemathesis/transports/headers.py +3 -2
- schemathesis/transports/responses.py +1 -1
- schemathesis/utils.py +7 -10
- {schemathesis-3.21.2.dist-info → schemathesis-3.22.1.dist-info}/METADATA +12 -13
- schemathesis-3.22.1.dist-info/RECORD +130 -0
- schemathesis-3.21.2.dist-info/RECORD +0 -130
- {schemathesis-3.21.2.dist-info → schemathesis-3.22.1.dist-info}/WHEEL +0 -0
- {schemathesis-3.21.2.dist-info → schemathesis-3.22.1.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.21.2.dist-info → schemathesis-3.22.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,15 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
1
2
|
import ctypes
|
|
2
3
|
import queue
|
|
3
4
|
import threading
|
|
4
5
|
import time
|
|
5
6
|
from dataclasses import dataclass
|
|
6
7
|
from queue import Queue
|
|
7
|
-
from typing import Any, Callable,
|
|
8
|
+
from typing import Any, Callable, Generator, Iterable, cast
|
|
8
9
|
|
|
9
10
|
import hypothesis
|
|
10
11
|
|
|
11
12
|
from ..._hypothesis import create_test
|
|
12
|
-
from ...generation import DataGenerationMethod
|
|
13
|
+
from ...generation import DataGenerationMethod, GenerationConfig
|
|
13
14
|
from ...internal.result import Ok
|
|
14
15
|
from ...models import CheckFunction, TestResultSet
|
|
15
16
|
from ...stateful import Feedback, Stateful
|
|
@@ -30,11 +31,12 @@ def _run_task(
|
|
|
30
31
|
targets: Iterable[Target],
|
|
31
32
|
data_generation_methods: Iterable[DataGenerationMethod],
|
|
32
33
|
settings: hypothesis.settings,
|
|
33
|
-
|
|
34
|
+
generation_config: GenerationConfig,
|
|
35
|
+
seed: int | None,
|
|
34
36
|
results: TestResultSet,
|
|
35
|
-
stateful:
|
|
37
|
+
stateful: Stateful | None,
|
|
36
38
|
stateful_recursion_limit: int,
|
|
37
|
-
headers:
|
|
39
|
+
headers: dict[str, Any] | None = None,
|
|
38
40
|
**kwargs: Any,
|
|
39
41
|
) -> None:
|
|
40
42
|
as_strategy_kwargs = {}
|
|
@@ -44,7 +46,13 @@ def _run_task(
|
|
|
44
46
|
def _run_tests(maker: Callable, recursion_level: int = 0) -> None:
|
|
45
47
|
if recursion_level > stateful_recursion_limit:
|
|
46
48
|
return
|
|
47
|
-
for _result in maker(
|
|
49
|
+
for _result in maker(
|
|
50
|
+
test_template,
|
|
51
|
+
settings=settings,
|
|
52
|
+
generation_config=generation_config,
|
|
53
|
+
seed=seed,
|
|
54
|
+
as_strategy_kwargs=as_strategy_kwargs,
|
|
55
|
+
):
|
|
48
56
|
# `result` is always `Ok` here
|
|
49
57
|
_operation, test = _result.ok()
|
|
50
58
|
feedback = Feedback(stateful, _operation)
|
|
@@ -81,6 +89,7 @@ def _run_task(
|
|
|
81
89
|
settings=settings,
|
|
82
90
|
seed=seed,
|
|
83
91
|
data_generation_methods=list(data_generation_methods),
|
|
92
|
+
generation_config=generation_config,
|
|
84
93
|
as_strategy_kwargs=as_strategy_kwargs,
|
|
85
94
|
)
|
|
86
95
|
items = Ok((operation, test_function))
|
|
@@ -100,12 +109,13 @@ def thread_task(
|
|
|
100
109
|
targets: Iterable[Target],
|
|
101
110
|
data_generation_methods: Iterable[DataGenerationMethod],
|
|
102
111
|
settings: hypothesis.settings,
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
112
|
+
generation_config: GenerationConfig,
|
|
113
|
+
auth: RawAuth | None,
|
|
114
|
+
auth_type: str | None,
|
|
115
|
+
headers: dict[str, Any] | None,
|
|
116
|
+
seed: int | None,
|
|
107
117
|
results: TestResultSet,
|
|
108
|
-
stateful:
|
|
118
|
+
stateful: Stateful | None,
|
|
109
119
|
stateful_recursion_limit: int,
|
|
110
120
|
kwargs: Any,
|
|
111
121
|
) -> None:
|
|
@@ -124,6 +134,7 @@ def thread_task(
|
|
|
124
134
|
targets,
|
|
125
135
|
data_generation_methods,
|
|
126
136
|
settings,
|
|
137
|
+
generation_config,
|
|
127
138
|
seed,
|
|
128
139
|
results,
|
|
129
140
|
stateful=stateful,
|
|
@@ -142,9 +153,10 @@ def wsgi_thread_task(
|
|
|
142
153
|
targets: Iterable[Target],
|
|
143
154
|
data_generation_methods: Iterable[DataGenerationMethod],
|
|
144
155
|
settings: hypothesis.settings,
|
|
145
|
-
|
|
156
|
+
generation_config: GenerationConfig,
|
|
157
|
+
seed: int | None,
|
|
146
158
|
results: TestResultSet,
|
|
147
|
-
stateful:
|
|
159
|
+
stateful: Stateful | None,
|
|
148
160
|
stateful_recursion_limit: int,
|
|
149
161
|
kwargs: Any,
|
|
150
162
|
) -> None:
|
|
@@ -157,6 +169,7 @@ def wsgi_thread_task(
|
|
|
157
169
|
targets,
|
|
158
170
|
data_generation_methods,
|
|
159
171
|
settings,
|
|
172
|
+
generation_config,
|
|
160
173
|
seed,
|
|
161
174
|
results,
|
|
162
175
|
stateful=stateful,
|
|
@@ -173,10 +186,11 @@ def asgi_thread_task(
|
|
|
173
186
|
targets: Iterable[Target],
|
|
174
187
|
data_generation_methods: Iterable[DataGenerationMethod],
|
|
175
188
|
settings: hypothesis.settings,
|
|
176
|
-
|
|
177
|
-
|
|
189
|
+
generation_config: GenerationConfig,
|
|
190
|
+
headers: dict[str, Any] | None,
|
|
191
|
+
seed: int | None,
|
|
178
192
|
results: TestResultSet,
|
|
179
|
-
stateful:
|
|
193
|
+
stateful: Stateful | None,
|
|
180
194
|
stateful_recursion_limit: int,
|
|
181
195
|
kwargs: Any,
|
|
182
196
|
) -> None:
|
|
@@ -189,6 +203,7 @@ def asgi_thread_task(
|
|
|
189
203
|
targets,
|
|
190
204
|
data_generation_methods,
|
|
191
205
|
settings,
|
|
206
|
+
generation_config,
|
|
192
207
|
seed,
|
|
193
208
|
results,
|
|
194
209
|
stateful=stateful,
|
|
@@ -208,8 +223,8 @@ class ThreadPoolRunner(BaseRunner):
|
|
|
208
223
|
"""Spread different tests among multiple worker threads."""
|
|
209
224
|
|
|
210
225
|
workers_num: int = 2
|
|
211
|
-
request_tls_verify:
|
|
212
|
-
request_cert:
|
|
226
|
+
request_tls_verify: bool | str = True
|
|
227
|
+
request_cert: RequestCert | None = None
|
|
213
228
|
|
|
214
229
|
def _execute(
|
|
215
230
|
self, results: TestResultSet, stop_event: threading.Event
|
|
@@ -276,7 +291,7 @@ class ThreadPoolRunner(BaseRunner):
|
|
|
276
291
|
|
|
277
292
|
def _init_workers(
|
|
278
293
|
self, tasks_queue: Queue, events_queue: Queue, results: TestResultSet, generator_done: threading.Event
|
|
279
|
-
) ->
|
|
294
|
+
) -> list[threading.Thread]:
|
|
280
295
|
"""Initialize & start workers that will execute tests."""
|
|
281
296
|
workers = [
|
|
282
297
|
threading.Thread(
|
|
@@ -295,7 +310,7 @@ class ThreadPoolRunner(BaseRunner):
|
|
|
295
310
|
|
|
296
311
|
def _get_worker_kwargs(
|
|
297
312
|
self, tasks_queue: Queue, events_queue: Queue, results: TestResultSet, generator_done: threading.Event
|
|
298
|
-
) ->
|
|
313
|
+
) -> dict[str, Any]:
|
|
299
314
|
return {
|
|
300
315
|
"tasks_queue": tasks_queue,
|
|
301
316
|
"events_queue": events_queue,
|
|
@@ -303,6 +318,7 @@ class ThreadPoolRunner(BaseRunner):
|
|
|
303
318
|
"checks": self.checks,
|
|
304
319
|
"targets": self.targets,
|
|
305
320
|
"settings": self.hypothesis_settings,
|
|
321
|
+
"generation_config": self.generation_config,
|
|
306
322
|
"auth": self.auth,
|
|
307
323
|
"auth_type": self.auth_type,
|
|
308
324
|
"headers": self.headers,
|
|
@@ -328,7 +344,7 @@ class ThreadPoolWSGIRunner(ThreadPoolRunner):
|
|
|
328
344
|
|
|
329
345
|
def _get_worker_kwargs(
|
|
330
346
|
self, tasks_queue: Queue, events_queue: Queue, results: TestResultSet, generator_done: threading.Event
|
|
331
|
-
) ->
|
|
347
|
+
) -> dict[str, Any]:
|
|
332
348
|
return {
|
|
333
349
|
"tasks_queue": tasks_queue,
|
|
334
350
|
"events_queue": events_queue,
|
|
@@ -336,6 +352,7 @@ class ThreadPoolWSGIRunner(ThreadPoolRunner):
|
|
|
336
352
|
"checks": self.checks,
|
|
337
353
|
"targets": self.targets,
|
|
338
354
|
"settings": self.hypothesis_settings,
|
|
355
|
+
"generation_config": self.generation_config,
|
|
339
356
|
"seed": self.seed,
|
|
340
357
|
"results": results,
|
|
341
358
|
"stateful": self.stateful,
|
|
@@ -358,7 +375,7 @@ class ThreadPoolASGIRunner(ThreadPoolRunner):
|
|
|
358
375
|
|
|
359
376
|
def _get_worker_kwargs(
|
|
360
377
|
self, tasks_queue: Queue, events_queue: Queue, results: TestResultSet, generator_done: threading.Event
|
|
361
|
-
) ->
|
|
378
|
+
) -> dict[str, Any]:
|
|
362
379
|
return {
|
|
363
380
|
"tasks_queue": tasks_queue,
|
|
364
381
|
"events_queue": events_queue,
|
|
@@ -366,6 +383,7 @@ class ThreadPoolASGIRunner(ThreadPoolRunner):
|
|
|
366
383
|
"checks": self.checks,
|
|
367
384
|
"targets": self.targets,
|
|
368
385
|
"settings": self.hypothesis_settings,
|
|
386
|
+
"generation_config": self.generation_config,
|
|
369
387
|
"headers": self.headers,
|
|
370
388
|
"seed": self.seed,
|
|
371
389
|
"results": results,
|
|
@@ -6,7 +6,7 @@ from __future__ import annotations
|
|
|
6
6
|
import logging
|
|
7
7
|
import re
|
|
8
8
|
from dataclasses import dataclass, field
|
|
9
|
-
from typing import Any,
|
|
9
|
+
from typing import Any, TYPE_CHECKING, cast
|
|
10
10
|
|
|
11
11
|
from ..transports import serialize_payload
|
|
12
12
|
from ..code_samples import get_excluded_headers
|
|
@@ -21,6 +21,8 @@ from ..exceptions import (
|
|
|
21
21
|
OperationSchemaError,
|
|
22
22
|
BodyInGetRequestError,
|
|
23
23
|
InvalidRegularExpression,
|
|
24
|
+
SerializationError,
|
|
25
|
+
UnboundPrefixError,
|
|
24
26
|
)
|
|
25
27
|
from ..models import Case, Check, Interaction, Request, Response, Status, TestResult
|
|
26
28
|
|
|
@@ -33,13 +35,13 @@ if TYPE_CHECKING:
|
|
|
33
35
|
class SerializedCase:
|
|
34
36
|
# Case data
|
|
35
37
|
id: str
|
|
36
|
-
path_parameters:
|
|
37
|
-
headers:
|
|
38
|
-
cookies:
|
|
39
|
-
query:
|
|
40
|
-
body:
|
|
41
|
-
media_type:
|
|
42
|
-
data_generation_method:
|
|
38
|
+
path_parameters: dict[str, Any] | None
|
|
39
|
+
headers: dict[str, Any] | None
|
|
40
|
+
cookies: dict[str, Any] | None
|
|
41
|
+
query: dict[str, Any] | None
|
|
42
|
+
body: str | None
|
|
43
|
+
media_type: str | None
|
|
44
|
+
data_generation_method: str | None
|
|
43
45
|
# Operation data
|
|
44
46
|
method: str
|
|
45
47
|
url: str
|
|
@@ -48,10 +50,10 @@ class SerializedCase:
|
|
|
48
50
|
# Transport info
|
|
49
51
|
verify: bool
|
|
50
52
|
# Headers coming from sources outside data generation
|
|
51
|
-
extra_headers:
|
|
53
|
+
extra_headers: dict[str, Any]
|
|
52
54
|
|
|
53
55
|
@classmethod
|
|
54
|
-
def from_case(cls, case: Case, headers:
|
|
56
|
+
def from_case(cls, case: Case, headers: dict[str, Any] | None, verify: bool) -> SerializedCase:
|
|
55
57
|
# `headers` include not only explicitly provided headers but also ones added by hooks, custom auth, etc.
|
|
56
58
|
request_data = case.prepare_code_sample_data(headers)
|
|
57
59
|
serialized_body = _serialize_body(request_data.body)
|
|
@@ -75,7 +77,7 @@ class SerializedCase:
|
|
|
75
77
|
)
|
|
76
78
|
|
|
77
79
|
|
|
78
|
-
def _serialize_body(body:
|
|
80
|
+
def _serialize_body(body: str | bytes | None) -> str | None:
|
|
79
81
|
if body is None:
|
|
80
82
|
return None
|
|
81
83
|
if isinstance(body, str):
|
|
@@ -90,18 +92,18 @@ class SerializedCheck:
|
|
|
90
92
|
# Check result
|
|
91
93
|
value: Status
|
|
92
94
|
request: Request
|
|
93
|
-
response:
|
|
95
|
+
response: Response | None
|
|
94
96
|
# Generated example
|
|
95
97
|
example: SerializedCase
|
|
96
98
|
# Message could be absent for plain `assert` statements
|
|
97
|
-
message:
|
|
99
|
+
message: str | None = None
|
|
98
100
|
# Failure-specific context
|
|
99
|
-
context:
|
|
101
|
+
context: FailureContext | None = None
|
|
100
102
|
# Cases & responses that were made before this one
|
|
101
|
-
history:
|
|
103
|
+
history: list[SerializedHistoryEntry] = field(default_factory=list)
|
|
102
104
|
|
|
103
105
|
@classmethod
|
|
104
|
-
def from_check(cls, check: Check) ->
|
|
106
|
+
def from_check(cls, check: Check) -> SerializedCheck:
|
|
105
107
|
import requests
|
|
106
108
|
from ..transports.responses import WSGIResponse
|
|
107
109
|
|
|
@@ -113,7 +115,7 @@ class SerializedCheck:
|
|
|
113
115
|
else:
|
|
114
116
|
raise InternalError("Can not find request data")
|
|
115
117
|
|
|
116
|
-
response:
|
|
118
|
+
response: Response | None
|
|
117
119
|
if isinstance(check.response, requests.Response):
|
|
118
120
|
response = Response.from_requests(check.response)
|
|
119
121
|
elif isinstance(check.response, WSGIResponse):
|
|
@@ -136,7 +138,7 @@ class SerializedCheck:
|
|
|
136
138
|
)
|
|
137
139
|
|
|
138
140
|
|
|
139
|
-
def _get_headers(headers:
|
|
141
|
+
def _get_headers(headers: dict[str, Any] | CaseInsensitiveDict) -> dict[str, str]:
|
|
140
142
|
return {key: value[0] for key, value in headers.items() if key not in get_excluded_headers()}
|
|
141
143
|
|
|
142
144
|
|
|
@@ -146,7 +148,7 @@ class SerializedHistoryEntry:
|
|
|
146
148
|
response: Response
|
|
147
149
|
|
|
148
150
|
|
|
149
|
-
def get_serialized_history(case: Case) ->
|
|
151
|
+
def get_serialized_history(case: Case) -> list[SerializedHistoryEntry]:
|
|
150
152
|
import requests
|
|
151
153
|
|
|
152
154
|
history = []
|
|
@@ -170,9 +172,9 @@ def get_serialized_history(case: Case) -> List[SerializedHistoryEntry]:
|
|
|
170
172
|
@dataclass
|
|
171
173
|
class SerializedError:
|
|
172
174
|
type: RuntimeErrorType
|
|
173
|
-
title:
|
|
174
|
-
message:
|
|
175
|
-
extras:
|
|
175
|
+
title: str | None
|
|
176
|
+
message: str | None
|
|
177
|
+
extras: list[str]
|
|
176
178
|
|
|
177
179
|
# Exception info
|
|
178
180
|
exception: str
|
|
@@ -182,11 +184,11 @@ class SerializedError:
|
|
|
182
184
|
def with_exception(
|
|
183
185
|
cls,
|
|
184
186
|
type_: RuntimeErrorType,
|
|
185
|
-
title:
|
|
186
|
-
message:
|
|
187
|
-
extras:
|
|
187
|
+
title: str | None,
|
|
188
|
+
message: str | None,
|
|
189
|
+
extras: list[str],
|
|
188
190
|
exception: Exception,
|
|
189
|
-
) ->
|
|
191
|
+
) -> SerializedError:
|
|
190
192
|
return cls(
|
|
191
193
|
type=type_,
|
|
192
194
|
title=title,
|
|
@@ -197,13 +199,13 @@ class SerializedError:
|
|
|
197
199
|
)
|
|
198
200
|
|
|
199
201
|
@classmethod
|
|
200
|
-
def from_exception(cls, exception: Exception) ->
|
|
202
|
+
def from_exception(cls, exception: Exception) -> SerializedError:
|
|
201
203
|
import requests
|
|
202
204
|
import hypothesis.errors
|
|
203
205
|
from hypothesis import HealthCheck
|
|
204
206
|
|
|
205
207
|
title = "Runtime Error"
|
|
206
|
-
message:
|
|
208
|
+
message: str | None
|
|
207
209
|
if isinstance(exception, requests.RequestException):
|
|
208
210
|
if isinstance(exception, requests.exceptions.SSLError):
|
|
209
211
|
type_ = RuntimeErrorType.CONNECTION_SSL
|
|
@@ -217,6 +219,13 @@ class SerializedError:
|
|
|
217
219
|
type_ = RuntimeErrorType.HYPOTHESIS_DEADLINE_EXCEEDED
|
|
218
220
|
message = str(exception).strip()
|
|
219
221
|
extras = []
|
|
222
|
+
elif isinstance(exception, hypothesis.errors.InvalidArgument) and str(exception).startswith("Scalar "):
|
|
223
|
+
# Comes from `hypothesis-graphql`
|
|
224
|
+
scalar_name = _scalar_name_from_error(exception)
|
|
225
|
+
type_ = RuntimeErrorType.HYPOTHESIS_UNSUPPORTED_GRAPHQL_SCALAR
|
|
226
|
+
message = f"Scalar type '{scalar_name}' is not recognized"
|
|
227
|
+
extras = []
|
|
228
|
+
title = "Unknown GraphQL Scalar"
|
|
220
229
|
elif isinstance(exception, hypothesis.errors.Unsatisfiable):
|
|
221
230
|
type_ = RuntimeErrorType.HYPOTHESIS_UNSATISFIABLE
|
|
222
231
|
message = f"{exception}. Possible reasons:"
|
|
@@ -261,6 +270,15 @@ class SerializedError:
|
|
|
261
270
|
message = exception.message
|
|
262
271
|
extras = []
|
|
263
272
|
title = "Schema Error"
|
|
273
|
+
elif isinstance(exception, SerializationError):
|
|
274
|
+
if isinstance(exception, UnboundPrefixError):
|
|
275
|
+
type_ = RuntimeErrorType.SERIALIZATION_UNBOUNDED_PREFIX
|
|
276
|
+
title = "XML serialization error"
|
|
277
|
+
else:
|
|
278
|
+
title = "Serialization not possible"
|
|
279
|
+
type_ = RuntimeErrorType.SERIALIZATION_NOT_POSSIBLE
|
|
280
|
+
message = str(exception)
|
|
281
|
+
extras = []
|
|
264
282
|
else:
|
|
265
283
|
type_ = RuntimeErrorType.UNCLASSIFIED
|
|
266
284
|
message = str(exception)
|
|
@@ -290,7 +308,7 @@ Consider revising the schema to more accurately represent typical use cases
|
|
|
290
308
|
or applying constraints to reduce the data size."""
|
|
291
309
|
|
|
292
310
|
|
|
293
|
-
def _health_check_from_error(exception: hypothesis.errors.FailedHealthCheck) ->
|
|
311
|
+
def _health_check_from_error(exception: hypothesis.errors.FailedHealthCheck) -> hypothesis.HealthCheck | None:
|
|
294
312
|
from hypothesis import HealthCheck
|
|
295
313
|
|
|
296
314
|
match = re.search(r"add HealthCheck\.(\w+) to the suppress_health_check ", str(exception))
|
|
@@ -304,16 +322,23 @@ def _health_check_from_error(exception: hypothesis.errors.FailedHealthCheck) ->
|
|
|
304
322
|
return None
|
|
305
323
|
|
|
306
324
|
|
|
325
|
+
def _scalar_name_from_error(exception: hypothesis.errors.InvalidArgument) -> str:
|
|
326
|
+
# This one is always available as the format is checked upfront
|
|
327
|
+
match = re.search(r"Scalar '(\w+)' is not supported", str(exception))
|
|
328
|
+
match = cast(re.Match, match)
|
|
329
|
+
return match.group(1)
|
|
330
|
+
|
|
331
|
+
|
|
307
332
|
@dataclass
|
|
308
333
|
class SerializedInteraction:
|
|
309
334
|
request: Request
|
|
310
335
|
response: Response
|
|
311
|
-
checks:
|
|
336
|
+
checks: list[SerializedCheck]
|
|
312
337
|
status: Status
|
|
313
338
|
recorded_at: str
|
|
314
339
|
|
|
315
340
|
@classmethod
|
|
316
|
-
def from_interaction(cls, interaction: Interaction) ->
|
|
341
|
+
def from_interaction(cls, interaction: Interaction) -> SerializedInteraction:
|
|
317
342
|
return cls(
|
|
318
343
|
request=interaction.request,
|
|
319
344
|
response=interaction.response,
|
|
@@ -334,15 +359,16 @@ class SerializedTestResult:
|
|
|
334
359
|
is_errored: bool
|
|
335
360
|
is_flaky: bool
|
|
336
361
|
is_skipped: bool
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
362
|
+
skip_reason: str | None
|
|
363
|
+
seed: int | None
|
|
364
|
+
data_generation_method: list[str]
|
|
365
|
+
checks: list[SerializedCheck]
|
|
366
|
+
logs: list[str]
|
|
367
|
+
errors: list[SerializedError]
|
|
368
|
+
interactions: list[SerializedInteraction]
|
|
343
369
|
|
|
344
370
|
@classmethod
|
|
345
|
-
def from_test_result(cls, result: TestResult) ->
|
|
371
|
+
def from_test_result(cls, result: TestResult) -> SerializedTestResult:
|
|
346
372
|
formatter = logging.Formatter("[%(asctime)s] %(levelname)s in %(module)s: %(message)s")
|
|
347
373
|
return cls(
|
|
348
374
|
method=result.method,
|
|
@@ -354,6 +380,7 @@ class SerializedTestResult:
|
|
|
354
380
|
is_errored=result.is_errored,
|
|
355
381
|
is_flaky=result.is_flaky,
|
|
356
382
|
is_skipped=result.is_skipped,
|
|
383
|
+
skip_reason=result.skip_reason,
|
|
357
384
|
seed=result.seed,
|
|
358
385
|
data_generation_method=[m.as_short_name() for m in result.data_generation_method],
|
|
359
386
|
checks=[SerializedCheck.from_check(check) for check in result.checks],
|
|
@@ -363,9 +390,9 @@ class SerializedTestResult:
|
|
|
363
390
|
)
|
|
364
391
|
|
|
365
392
|
|
|
366
|
-
def deduplicate_failures(checks:
|
|
393
|
+
def deduplicate_failures(checks: list[SerializedCheck]) -> list[SerializedCheck]:
|
|
367
394
|
"""Return only unique checks that should be displayed in the output."""
|
|
368
|
-
seen:
|
|
395
|
+
seen: set[tuple[str | None, ...]] = set()
|
|
369
396
|
unique_checks = []
|
|
370
397
|
for check in reversed(checks):
|
|
371
398
|
# There are also could be checks that didn't fail
|
schemathesis/sanitization.py
CHANGED
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
import threading
|
|
3
3
|
from collections.abc import MutableMapping, MutableSequence
|
|
4
4
|
from dataclasses import dataclass, replace
|
|
5
|
-
from typing import TYPE_CHECKING, Any,
|
|
5
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
6
6
|
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
|
|
7
7
|
|
|
8
8
|
from .constants import NOT_SET
|
|
@@ -87,26 +87,26 @@ class Config:
|
|
|
87
87
|
:param str replacement: The replacement string for sanitized values.
|
|
88
88
|
"""
|
|
89
89
|
|
|
90
|
-
keys_to_sanitize:
|
|
91
|
-
sensitive_markers:
|
|
90
|
+
keys_to_sanitize: frozenset[str] = DEFAULT_KEYS_TO_SANITIZE
|
|
91
|
+
sensitive_markers: frozenset[str] = DEFAULT_SENSITIVE_MARKERS
|
|
92
92
|
replacement: str = DEFAULT_REPLACEMENT
|
|
93
93
|
|
|
94
|
-
def with_keys_to_sanitize(self, *keys: str) ->
|
|
94
|
+
def with_keys_to_sanitize(self, *keys: str) -> Config:
|
|
95
95
|
"""Create a new configuration with additional keys to sanitize."""
|
|
96
96
|
new_keys_to_sanitize = self.keys_to_sanitize.union([key.lower() for key in keys])
|
|
97
97
|
return replace(self, keys_to_sanitize=frozenset(new_keys_to_sanitize))
|
|
98
98
|
|
|
99
|
-
def without_keys_to_sanitize(self, *keys: str) ->
|
|
99
|
+
def without_keys_to_sanitize(self, *keys: str) -> Config:
|
|
100
100
|
"""Create a new configuration without certain keys to sanitize."""
|
|
101
101
|
new_keys_to_sanitize = self.keys_to_sanitize.difference([key.lower() for key in keys])
|
|
102
102
|
return replace(self, keys_to_sanitize=frozenset(new_keys_to_sanitize))
|
|
103
103
|
|
|
104
|
-
def with_sensitive_markers(self, *markers: str) ->
|
|
104
|
+
def with_sensitive_markers(self, *markers: str) -> Config:
|
|
105
105
|
"""Create a new configuration with additional sensitive markers."""
|
|
106
106
|
new_sensitive_markers = self.sensitive_markers.union([key.lower() for key in markers])
|
|
107
107
|
return replace(self, sensitive_markers=frozenset(new_sensitive_markers))
|
|
108
108
|
|
|
109
|
-
def without_sensitive_markers(self, *markers: str) ->
|
|
109
|
+
def without_sensitive_markers(self, *markers: str) -> Config:
|
|
110
110
|
"""Create a new configuration without certain sensitive markers."""
|
|
111
111
|
new_sensitive_markers = self.sensitive_markers.difference([key.lower() for key in markers])
|
|
112
112
|
return replace(self, sensitive_markers=frozenset(new_sensitive_markers))
|
|
@@ -126,7 +126,7 @@ def configure(config: Config) -> None:
|
|
|
126
126
|
_thread_local.default_sanitization_config = config
|
|
127
127
|
|
|
128
128
|
|
|
129
|
-
def sanitize_value(item: Any, *, config:
|
|
129
|
+
def sanitize_value(item: Any, *, config: Config | None = None) -> None:
|
|
130
130
|
"""Sanitize sensitive values within a given item.
|
|
131
131
|
|
|
132
132
|
This function is recursive and will sanitize sensitive data within nested
|
|
@@ -150,7 +150,7 @@ def sanitize_value(item: Any, *, config: Optional[Config] = None) -> None:
|
|
|
150
150
|
sanitize_value(value, config=config)
|
|
151
151
|
|
|
152
152
|
|
|
153
|
-
def sanitize_case(case:
|
|
153
|
+
def sanitize_case(case: Case, *, config: Config | None = None) -> None:
|
|
154
154
|
"""Sanitize sensitive values within a given case."""
|
|
155
155
|
if case.path_parameters is not None:
|
|
156
156
|
sanitize_value(case.path_parameters, config=config)
|
|
@@ -166,21 +166,21 @@ def sanitize_case(case: "Case", *, config: Optional[Config] = None) -> None:
|
|
|
166
166
|
sanitize_history(case.source, config=config)
|
|
167
167
|
|
|
168
168
|
|
|
169
|
-
def sanitize_history(source:
|
|
169
|
+
def sanitize_history(source: CaseSource, *, config: Config | None = None) -> None:
|
|
170
170
|
"""Recursively sanitize history of case/response pairs."""
|
|
171
|
-
current:
|
|
171
|
+
current: CaseSource | None = source
|
|
172
172
|
while current is not None:
|
|
173
173
|
sanitize_case(current.case, config=config)
|
|
174
174
|
sanitize_response(current.response, config=config)
|
|
175
175
|
current = current.case.source
|
|
176
176
|
|
|
177
177
|
|
|
178
|
-
def sanitize_response(response:
|
|
178
|
+
def sanitize_response(response: GenericResponse, *, config: Config | None = None) -> None:
|
|
179
179
|
# Sanitize headers
|
|
180
180
|
sanitize_value(response.headers, config=config)
|
|
181
181
|
|
|
182
182
|
|
|
183
|
-
def sanitize_request(request:
|
|
183
|
+
def sanitize_request(request: PreparedRequest | Request, *, config: Config | None = None) -> None:
|
|
184
184
|
from requests import PreparedRequest
|
|
185
185
|
|
|
186
186
|
if isinstance(request, PreparedRequest) and request.url:
|
|
@@ -192,16 +192,14 @@ def sanitize_request(request: Union[PreparedRequest, "Request"], *, config: Opti
|
|
|
192
192
|
sanitize_value(request.headers, config=config)
|
|
193
193
|
|
|
194
194
|
|
|
195
|
-
def sanitize_output(
|
|
196
|
-
case: "Case", response: Optional["GenericResponse"] = None, *, config: Optional[Config] = None
|
|
197
|
-
) -> None:
|
|
195
|
+
def sanitize_output(case: Case, response: GenericResponse | None = None, *, config: Config | None = None) -> None:
|
|
198
196
|
sanitize_case(case, config=config)
|
|
199
197
|
if response is not None:
|
|
200
198
|
sanitize_response(response, config=config)
|
|
201
199
|
sanitize_request(response.request, config=config)
|
|
202
200
|
|
|
203
201
|
|
|
204
|
-
def sanitize_url(url: str, *, config:
|
|
202
|
+
def sanitize_url(url: str, *, config: Config | None = None) -> str:
|
|
205
203
|
"""Sanitize sensitive parts of a given URL.
|
|
206
204
|
|
|
207
205
|
This function will sanitize the authority and query parameters in the URL.
|
|
@@ -226,7 +224,7 @@ def sanitize_url(url: str, *, config: Optional[Config] = None) -> str:
|
|
|
226
224
|
return urlunsplit(sanitized_url_parts)
|
|
227
225
|
|
|
228
226
|
|
|
229
|
-
def sanitize_serialized_check(check:
|
|
227
|
+
def sanitize_serialized_check(check: SerializedCheck, *, config: Config | None = None) -> None:
|
|
230
228
|
sanitize_request(check.request, config=config)
|
|
231
229
|
response = check.response
|
|
232
230
|
if response:
|
|
@@ -237,13 +235,13 @@ def sanitize_serialized_check(check: "SerializedCheck", *, config: Optional[Conf
|
|
|
237
235
|
sanitize_value(entry.response.headers, config=config)
|
|
238
236
|
|
|
239
237
|
|
|
240
|
-
def sanitize_serialized_case(case:
|
|
238
|
+
def sanitize_serialized_case(case: SerializedCase, *, config: Config | None = None) -> None:
|
|
241
239
|
for value in (case.path_parameters, case.headers, case.cookies, case.query, case.extra_headers):
|
|
242
240
|
if value is not None:
|
|
243
241
|
sanitize_value(value, config=config)
|
|
244
242
|
|
|
245
243
|
|
|
246
|
-
def sanitize_serialized_interaction(interaction:
|
|
244
|
+
def sanitize_serialized_interaction(interaction: SerializedInteraction, *, config: Config | None = None) -> None:
|
|
247
245
|
sanitize_request(interaction.request, config=config)
|
|
248
246
|
sanitize_value(interaction.response.headers, config=config)
|
|
249
247
|
for check in interaction.checks:
|