schemathesis 3.25.5__py3-none-any.whl → 3.39.7__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 +6 -6
- schemathesis/_compat.py +2 -2
- schemathesis/_dependency_versions.py +4 -2
- schemathesis/_hypothesis.py +369 -56
- schemathesis/_lazy_import.py +1 -0
- schemathesis/_override.py +5 -4
- schemathesis/_patches.py +21 -0
- schemathesis/_rate_limiter.py +7 -0
- schemathesis/_xml.py +75 -22
- schemathesis/auths.py +78 -16
- schemathesis/checks.py +21 -9
- schemathesis/cli/__init__.py +793 -448
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/callbacks.py +58 -13
- schemathesis/cli/cassettes.py +233 -47
- schemathesis/cli/constants.py +8 -2
- schemathesis/cli/context.py +24 -4
- schemathesis/cli/debug.py +2 -1
- schemathesis/cli/handlers.py +4 -1
- schemathesis/cli/junitxml.py +103 -22
- schemathesis/cli/options.py +15 -4
- schemathesis/cli/output/default.py +286 -115
- schemathesis/cli/output/short.py +25 -6
- schemathesis/cli/reporting.py +79 -0
- schemathesis/cli/sanitization.py +6 -0
- schemathesis/code_samples.py +5 -3
- schemathesis/constants.py +1 -0
- schemathesis/contrib/openapi/__init__.py +1 -1
- schemathesis/contrib/openapi/fill_missing_examples.py +3 -1
- schemathesis/contrib/openapi/formats/uuid.py +2 -1
- schemathesis/contrib/unique_data.py +3 -3
- schemathesis/exceptions.py +76 -65
- schemathesis/experimental/__init__.py +35 -0
- schemathesis/extra/_aiohttp.py +1 -0
- schemathesis/extra/_flask.py +4 -1
- schemathesis/extra/_server.py +1 -0
- schemathesis/extra/pytest_plugin.py +17 -25
- schemathesis/failures.py +77 -9
- schemathesis/filters.py +185 -8
- schemathesis/fixups/__init__.py +1 -0
- schemathesis/fixups/fast_api.py +2 -2
- schemathesis/fixups/utf8_bom.py +1 -2
- schemathesis/generation/__init__.py +20 -36
- schemathesis/generation/_hypothesis.py +59 -0
- schemathesis/generation/_methods.py +44 -0
- schemathesis/generation/coverage.py +931 -0
- schemathesis/graphql.py +0 -1
- schemathesis/hooks.py +89 -12
- schemathesis/internal/checks.py +84 -0
- schemathesis/internal/copy.py +22 -3
- schemathesis/internal/deprecation.py +6 -2
- schemathesis/internal/diff.py +15 -0
- schemathesis/internal/extensions.py +27 -0
- schemathesis/internal/jsonschema.py +2 -1
- schemathesis/internal/output.py +68 -0
- schemathesis/internal/result.py +1 -1
- schemathesis/internal/transformation.py +11 -0
- schemathesis/lazy.py +138 -25
- schemathesis/loaders.py +7 -5
- schemathesis/models.py +323 -213
- schemathesis/parameters.py +4 -0
- schemathesis/runner/__init__.py +72 -22
- schemathesis/runner/events.py +86 -6
- schemathesis/runner/impl/context.py +104 -0
- schemathesis/runner/impl/core.py +447 -187
- schemathesis/runner/impl/solo.py +19 -29
- schemathesis/runner/impl/threadpool.py +70 -79
- schemathesis/{cli → runner}/probes.py +37 -25
- schemathesis/runner/serialization.py +150 -17
- schemathesis/sanitization.py +5 -1
- schemathesis/schemas.py +170 -102
- schemathesis/serializers.py +17 -4
- schemathesis/service/ci.py +1 -0
- schemathesis/service/client.py +39 -6
- schemathesis/service/events.py +5 -1
- schemathesis/service/extensions.py +224 -0
- schemathesis/service/hosts.py +6 -2
- schemathesis/service/metadata.py +25 -0
- schemathesis/service/models.py +211 -2
- schemathesis/service/report.py +6 -6
- schemathesis/service/serialization.py +60 -71
- schemathesis/service/usage.py +1 -0
- schemathesis/specs/graphql/_cache.py +26 -0
- schemathesis/specs/graphql/loaders.py +25 -5
- schemathesis/specs/graphql/nodes.py +1 -0
- schemathesis/specs/graphql/scalars.py +2 -2
- schemathesis/specs/graphql/schemas.py +130 -100
- schemathesis/specs/graphql/validation.py +1 -2
- schemathesis/specs/openapi/__init__.py +1 -0
- schemathesis/specs/openapi/_cache.py +123 -0
- schemathesis/specs/openapi/_hypothesis.py +79 -61
- schemathesis/specs/openapi/checks.py +504 -25
- schemathesis/specs/openapi/converter.py +31 -4
- schemathesis/specs/openapi/definitions.py +10 -17
- schemathesis/specs/openapi/examples.py +143 -31
- schemathesis/specs/openapi/expressions/__init__.py +37 -2
- schemathesis/specs/openapi/expressions/context.py +1 -1
- schemathesis/specs/openapi/expressions/extractors.py +26 -0
- schemathesis/specs/openapi/expressions/lexer.py +20 -18
- schemathesis/specs/openapi/expressions/nodes.py +29 -6
- schemathesis/specs/openapi/expressions/parser.py +26 -5
- schemathesis/specs/openapi/formats.py +44 -0
- schemathesis/specs/openapi/links.py +125 -42
- schemathesis/specs/openapi/loaders.py +77 -36
- schemathesis/specs/openapi/media_types.py +34 -0
- schemathesis/specs/openapi/negative/__init__.py +6 -3
- schemathesis/specs/openapi/negative/mutations.py +21 -6
- schemathesis/specs/openapi/parameters.py +39 -25
- schemathesis/specs/openapi/patterns.py +137 -0
- schemathesis/specs/openapi/references.py +37 -7
- schemathesis/specs/openapi/schemas.py +368 -242
- schemathesis/specs/openapi/security.py +25 -7
- schemathesis/specs/openapi/serialization.py +1 -0
- schemathesis/specs/openapi/stateful/__init__.py +198 -70
- schemathesis/specs/openapi/stateful/statistic.py +198 -0
- schemathesis/specs/openapi/stateful/types.py +14 -0
- schemathesis/specs/openapi/utils.py +6 -1
- schemathesis/specs/openapi/validation.py +1 -0
- schemathesis/stateful/__init__.py +35 -21
- schemathesis/stateful/config.py +97 -0
- schemathesis/stateful/context.py +135 -0
- schemathesis/stateful/events.py +274 -0
- schemathesis/stateful/runner.py +309 -0
- schemathesis/stateful/sink.py +68 -0
- schemathesis/stateful/state_machine.py +67 -38
- schemathesis/stateful/statistic.py +22 -0
- schemathesis/stateful/validation.py +100 -0
- schemathesis/targets.py +33 -1
- schemathesis/throttling.py +25 -5
- schemathesis/transports/__init__.py +354 -0
- schemathesis/transports/asgi.py +7 -0
- schemathesis/transports/auth.py +25 -2
- schemathesis/transports/content_types.py +3 -1
- schemathesis/transports/headers.py +2 -1
- schemathesis/transports/responses.py +9 -4
- schemathesis/types.py +9 -0
- schemathesis/utils.py +11 -16
- schemathesis-3.39.7.dist-info/METADATA +293 -0
- schemathesis-3.39.7.dist-info/RECORD +160 -0
- {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/WHEEL +1 -1
- schemathesis/specs/openapi/filters.py +0 -49
- schemathesis/specs/openapi/stateful/links.py +0 -92
- schemathesis-3.25.5.dist-info/METADATA +0 -356
- schemathesis-3.25.5.dist-info/RECORD +0 -134
- {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/licenses/LICENSE +0 -0
schemathesis/models.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
import datetime
|
|
3
4
|
import inspect
|
|
4
5
|
import textwrap
|
|
@@ -6,9 +7,8 @@ from collections import Counter
|
|
|
6
7
|
from contextlib import contextmanager
|
|
7
8
|
from dataclasses import dataclass, field
|
|
8
9
|
from enum import Enum
|
|
9
|
-
from functools import
|
|
10
|
+
from functools import lru_cache, partial
|
|
10
11
|
from itertools import chain
|
|
11
|
-
from logging import LogRecord
|
|
12
12
|
from typing import (
|
|
13
13
|
TYPE_CHECKING,
|
|
14
14
|
Any,
|
|
@@ -16,60 +16,71 @@ from typing import (
|
|
|
16
16
|
Generator,
|
|
17
17
|
Generic,
|
|
18
18
|
Iterator,
|
|
19
|
+
Literal,
|
|
19
20
|
NoReturn,
|
|
20
|
-
Optional,
|
|
21
21
|
Sequence,
|
|
22
22
|
Type,
|
|
23
23
|
TypeVar,
|
|
24
24
|
cast,
|
|
25
25
|
)
|
|
26
|
-
from urllib.parse import quote, unquote, urljoin,
|
|
27
|
-
|
|
28
|
-
from urllib3.exceptions import ReadTimeoutError
|
|
26
|
+
from urllib.parse import quote, unquote, urljoin, urlsplit, urlunsplit
|
|
29
27
|
|
|
30
|
-
from . import
|
|
28
|
+
from . import serializers
|
|
31
29
|
from ._dependency_versions import IS_WERKZEUG_ABOVE_3
|
|
32
|
-
from .
|
|
30
|
+
from ._override import CaseOverride
|
|
33
31
|
from .code_samples import CodeSampleStyle
|
|
34
|
-
from .generation import DataGenerationMethod, GenerationConfig
|
|
35
32
|
from .constants import (
|
|
36
|
-
|
|
33
|
+
NOT_SET,
|
|
37
34
|
SCHEMATHESIS_TEST_CASE_HEADER,
|
|
38
35
|
SERIALIZERS_SUGGESTION_MESSAGE,
|
|
39
36
|
USER_AGENT,
|
|
40
|
-
NOT_SET,
|
|
41
37
|
)
|
|
42
38
|
from .exceptions import (
|
|
43
|
-
maybe_set_assertion_message,
|
|
44
39
|
CheckFailed,
|
|
45
|
-
FailureContext,
|
|
46
40
|
OperationSchemaError,
|
|
47
41
|
SerializationNotPossible,
|
|
42
|
+
SkipTest,
|
|
43
|
+
UsageError,
|
|
48
44
|
deduplicate_failed_checks,
|
|
49
45
|
get_grouped_exception,
|
|
50
|
-
|
|
51
|
-
prepare_response_payload,
|
|
52
|
-
SkipTest,
|
|
46
|
+
maybe_set_assertion_message,
|
|
53
47
|
)
|
|
54
|
-
from .
|
|
55
|
-
from .internal.copy import fast_deepcopy
|
|
48
|
+
from .generation import DataGenerationMethod, GenerationConfig, generate_random_case_id
|
|
56
49
|
from .hooks import GLOBAL_HOOK_DISPATCHER, HookContext, HookDispatcher, dispatch
|
|
50
|
+
from .internal.checks import CheckContext
|
|
51
|
+
from .internal.copy import fast_deepcopy
|
|
52
|
+
from .internal.deprecation import deprecated_function, deprecated_property
|
|
53
|
+
from .internal.diff import diff
|
|
54
|
+
from .internal.output import prepare_response_payload
|
|
57
55
|
from .parameters import Parameter, ParameterSet, PayloadAlternatives
|
|
58
56
|
from .sanitization import sanitize_request, sanitize_response
|
|
59
|
-
from .
|
|
60
|
-
from .transports import serialize_payload
|
|
57
|
+
from .transports import ASGITransport, RequestsTransport, WSGITransport, deserialize_payload, serialize_payload
|
|
61
58
|
from .types import Body, Cookies, FormData, Headers, NotSet, PathParameters, Query
|
|
62
|
-
from .generation import generate_random_case_id
|
|
63
59
|
|
|
64
60
|
if TYPE_CHECKING:
|
|
65
|
-
import werkzeug
|
|
66
61
|
import unittest
|
|
67
|
-
from
|
|
68
|
-
|
|
62
|
+
from logging import LogRecord
|
|
63
|
+
|
|
69
64
|
import requests.auth
|
|
70
|
-
|
|
65
|
+
import werkzeug
|
|
66
|
+
from hypothesis import strategies as st
|
|
67
|
+
from requests.structures import CaseInsensitiveDict
|
|
68
|
+
|
|
69
|
+
from .auths import AuthStorage
|
|
70
|
+
from .failures import FailureContext
|
|
71
|
+
from .internal.checks import CheckFunction
|
|
71
72
|
from .schemas import BaseSchema
|
|
73
|
+
from .serializers import Serializer
|
|
72
74
|
from .stateful import Stateful, StatefulTest
|
|
75
|
+
from .transports.responses import GenericResponse, WSGIResponse
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class TransitionId:
|
|
80
|
+
name: str
|
|
81
|
+
status_code: str
|
|
82
|
+
|
|
83
|
+
__slots__ = ("name", "status_code")
|
|
73
84
|
|
|
74
85
|
|
|
75
86
|
@dataclass
|
|
@@ -79,14 +90,22 @@ class CaseSource:
|
|
|
79
90
|
case: Case
|
|
80
91
|
response: GenericResponse
|
|
81
92
|
elapsed: float
|
|
93
|
+
overrides_all_parameters: bool
|
|
94
|
+
transition_id: TransitionId
|
|
82
95
|
|
|
83
96
|
def partial_deepcopy(self) -> CaseSource:
|
|
84
|
-
return self.__class__(
|
|
97
|
+
return self.__class__(
|
|
98
|
+
case=self.case.partial_deepcopy(),
|
|
99
|
+
response=self.response,
|
|
100
|
+
elapsed=self.elapsed,
|
|
101
|
+
overrides_all_parameters=self.overrides_all_parameters,
|
|
102
|
+
transition_id=self.transition_id,
|
|
103
|
+
)
|
|
85
104
|
|
|
86
105
|
|
|
87
106
|
def cant_serialize(media_type: str) -> NoReturn: # type: ignore
|
|
88
107
|
"""Reject the current example if we don't know how to send this data to the application."""
|
|
89
|
-
from hypothesis import
|
|
108
|
+
from hypothesis import event, note, reject
|
|
90
109
|
|
|
91
110
|
event_text = f"Can't serialize data to `{media_type}`."
|
|
92
111
|
note(f"{event_text} {SERIALIZERS_SUGGESTION_MESSAGE}")
|
|
@@ -120,11 +139,51 @@ def prepare_request_data(kwargs: dict[str, Any]) -> PreparedRequestData:
|
|
|
120
139
|
)
|
|
121
140
|
|
|
122
141
|
|
|
142
|
+
class TestPhase(str, Enum):
|
|
143
|
+
__test__ = False
|
|
144
|
+
|
|
145
|
+
EXPLICIT = "explicit"
|
|
146
|
+
COVERAGE = "coverage"
|
|
147
|
+
GENERATE = "generate"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class GenerationMetadata:
|
|
152
|
+
"""Stores various information about how data is generated."""
|
|
153
|
+
|
|
154
|
+
query: DataGenerationMethod | None
|
|
155
|
+
path_parameters: DataGenerationMethod | None
|
|
156
|
+
headers: DataGenerationMethod | None
|
|
157
|
+
cookies: DataGenerationMethod | None
|
|
158
|
+
body: DataGenerationMethod | None
|
|
159
|
+
phase: TestPhase
|
|
160
|
+
# Temporary attributes to carry info specific to the coverage phase
|
|
161
|
+
description: str | None
|
|
162
|
+
location: str | None
|
|
163
|
+
parameter: str | None
|
|
164
|
+
parameter_location: str | None
|
|
165
|
+
|
|
166
|
+
__slots__ = (
|
|
167
|
+
"query",
|
|
168
|
+
"path_parameters",
|
|
169
|
+
"headers",
|
|
170
|
+
"cookies",
|
|
171
|
+
"body",
|
|
172
|
+
"phase",
|
|
173
|
+
"description",
|
|
174
|
+
"location",
|
|
175
|
+
"parameter",
|
|
176
|
+
"parameter_location",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
123
180
|
@dataclass(repr=False)
|
|
124
181
|
class Case:
|
|
125
182
|
"""A single test case parameters."""
|
|
126
183
|
|
|
127
184
|
operation: APIOperation
|
|
185
|
+
# Time spent on generation of this test case
|
|
186
|
+
generation_time: float
|
|
128
187
|
# Unique test case identifier
|
|
129
188
|
id: str = field(default_factory=generate_random_case_id, compare=False)
|
|
130
189
|
path_parameters: PathParameters | None = None
|
|
@@ -138,9 +197,33 @@ class Case:
|
|
|
138
197
|
media_type: str | None = None
|
|
139
198
|
source: CaseSource | None = None
|
|
140
199
|
|
|
200
|
+
meta: GenerationMetadata | None = None
|
|
201
|
+
|
|
141
202
|
# The way the case was generated (None for manually crafted ones)
|
|
142
203
|
data_generation_method: DataGenerationMethod | None = None
|
|
143
204
|
_auth: requests.auth.AuthBase | None = None
|
|
205
|
+
_has_explicit_auth: bool = False
|
|
206
|
+
_explicit_method: str | None = None
|
|
207
|
+
|
|
208
|
+
def __post_init__(self) -> None:
|
|
209
|
+
self._original_path_parameters = self.path_parameters.copy() if self.path_parameters else None
|
|
210
|
+
self._original_headers = self.headers.copy() if self.headers else None
|
|
211
|
+
self._original_cookies = self.cookies.copy() if self.cookies else None
|
|
212
|
+
self._original_query = self.query.copy() if self.query else None
|
|
213
|
+
|
|
214
|
+
def _has_generated_component(self, name: str) -> bool:
|
|
215
|
+
assert name in ["path_parameters", "headers", "cookies", "query"]
|
|
216
|
+
if self.meta is None:
|
|
217
|
+
return False
|
|
218
|
+
return getattr(self.meta, name) is not None
|
|
219
|
+
|
|
220
|
+
def _get_diff(self, component: Literal["path_parameters", "headers", "query", "cookies"]) -> dict[str, Any]:
|
|
221
|
+
original = getattr(self, f"_original_{component}")
|
|
222
|
+
current = getattr(self, component)
|
|
223
|
+
if not (current and original):
|
|
224
|
+
return {}
|
|
225
|
+
original_value = original if self._has_generated_component(component) else {}
|
|
226
|
+
return diff(original_value, current)
|
|
144
227
|
|
|
145
228
|
def __repr__(self) -> str:
|
|
146
229
|
parts = [f"{self.__class__.__name__}("]
|
|
@@ -158,7 +241,18 @@ class Case:
|
|
|
158
241
|
def __hash__(self) -> int:
|
|
159
242
|
return hash(self.as_curl_command({SCHEMATHESIS_TEST_CASE_HEADER: "0"}))
|
|
160
243
|
|
|
161
|
-
@
|
|
244
|
+
@property
|
|
245
|
+
def _override(self) -> CaseOverride:
|
|
246
|
+
return CaseOverride(
|
|
247
|
+
path_parameters=self._get_diff("path_parameters"),
|
|
248
|
+
headers=self._get_diff("headers"),
|
|
249
|
+
query=self._get_diff("query"),
|
|
250
|
+
cookies=self._get_diff("cookies"),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def _repr_pretty_(self, *args: Any, **kwargs: Any) -> None: ...
|
|
254
|
+
|
|
255
|
+
@deprecated_property(removed_in="4.0", replacement="`operation`")
|
|
162
256
|
def endpoint(self) -> APIOperation:
|
|
163
257
|
return self.operation
|
|
164
258
|
|
|
@@ -172,7 +266,7 @@ class Case:
|
|
|
172
266
|
|
|
173
267
|
@property
|
|
174
268
|
def method(self) -> str:
|
|
175
|
-
return self.operation.method.upper()
|
|
269
|
+
return self._explicit_method.upper() if self._explicit_method else self.operation.method.upper()
|
|
176
270
|
|
|
177
271
|
@property
|
|
178
272
|
def base_url(self) -> str | None:
|
|
@@ -182,8 +276,21 @@ class Case:
|
|
|
182
276
|
def app(self) -> Any:
|
|
183
277
|
return self.operation.app
|
|
184
278
|
|
|
185
|
-
def set_source(
|
|
186
|
-
self
|
|
279
|
+
def set_source(
|
|
280
|
+
self,
|
|
281
|
+
response: GenericResponse,
|
|
282
|
+
case: Case,
|
|
283
|
+
elapsed: float,
|
|
284
|
+
overrides_all_parameters: bool,
|
|
285
|
+
transition_id: TransitionId,
|
|
286
|
+
) -> None:
|
|
287
|
+
self.source = CaseSource(
|
|
288
|
+
case=case,
|
|
289
|
+
response=response,
|
|
290
|
+
elapsed=elapsed,
|
|
291
|
+
overrides_all_parameters=overrides_all_parameters,
|
|
292
|
+
transition_id=transition_id,
|
|
293
|
+
)
|
|
187
294
|
|
|
188
295
|
@property
|
|
189
296
|
def formatted_path(self) -> str:
|
|
@@ -208,7 +315,7 @@ class Case:
|
|
|
208
315
|
|
|
209
316
|
def prepare_code_sample_data(self, headers: dict[str, Any] | None) -> PreparedRequestData:
|
|
210
317
|
base_url = self.get_full_base_url()
|
|
211
|
-
kwargs =
|
|
318
|
+
kwargs = RequestsTransport().serialize_case(self, base_url=base_url, headers=headers)
|
|
212
319
|
return prepare_request_data(kwargs)
|
|
213
320
|
|
|
214
321
|
def get_code_to_reproduce(
|
|
@@ -271,53 +378,31 @@ class Case:
|
|
|
271
378
|
final_headers.setdefault(SCHEMATHESIS_TEST_CASE_HEADER, self.id)
|
|
272
379
|
return final_headers
|
|
273
380
|
|
|
274
|
-
def _get_serializer(self) -> Serializer | None:
|
|
381
|
+
def _get_serializer(self, media_type: str | None = None) -> Serializer | None:
|
|
275
382
|
"""Get a serializer for the payload, if there is any."""
|
|
276
|
-
|
|
277
|
-
|
|
383
|
+
input_media_type = media_type or self.media_type
|
|
384
|
+
if input_media_type is not None:
|
|
385
|
+
media_type = serializers.get_first_matching_media_type(input_media_type)
|
|
278
386
|
if media_type is None:
|
|
279
387
|
# This media type is set manually. Otherwise, it should have been rejected during the data generation
|
|
280
|
-
raise SerializationNotPossible.for_media_type(
|
|
388
|
+
raise SerializationNotPossible.for_media_type(input_media_type)
|
|
281
389
|
# SAFETY: It is safe to assume that serializer will be found, because `media_type` returned above
|
|
282
390
|
# is registered. This intentionally ignores cases with concurrent serializers registry modification.
|
|
283
391
|
cls = cast(Type[serializers.Serializer], serializers.get(media_type))
|
|
284
392
|
return cls()
|
|
285
393
|
return None
|
|
286
394
|
|
|
395
|
+
def _get_body(self) -> Body | NotSet:
|
|
396
|
+
return self.body
|
|
397
|
+
|
|
398
|
+
@deprecated_function(removed_in="4.0", replacement="Case.as_transport_kwargs")
|
|
287
399
|
def as_requests_kwargs(self, base_url: str | None = None, headers: dict[str, str] | None = None) -> dict[str, Any]:
|
|
288
400
|
"""Convert the case into a dictionary acceptable by requests."""
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
base_url = self._get_base_url(base_url)
|
|
295
|
-
formatted_path = self.formatted_path.lstrip("/")
|
|
296
|
-
if not base_url.endswith("/"):
|
|
297
|
-
base_url += "/"
|
|
298
|
-
url = unquote(urljoin(base_url, quote(formatted_path)))
|
|
299
|
-
extra: dict[str, Any]
|
|
300
|
-
serializer = self._get_serializer()
|
|
301
|
-
if serializer is not None and not isinstance(self.body, NotSet):
|
|
302
|
-
context = SerializerContext(case=self)
|
|
303
|
-
extra = serializer.as_requests(context, self.body)
|
|
304
|
-
else:
|
|
305
|
-
extra = {}
|
|
306
|
-
if self._auth is not None:
|
|
307
|
-
extra["auth"] = self._auth
|
|
308
|
-
additional_headers = extra.pop("headers", None)
|
|
309
|
-
if additional_headers:
|
|
310
|
-
# Additional headers, needed for the serializer
|
|
311
|
-
for key, value in additional_headers.items():
|
|
312
|
-
final_headers.setdefault(key, value)
|
|
313
|
-
return {
|
|
314
|
-
"method": self.method,
|
|
315
|
-
"url": url,
|
|
316
|
-
"cookies": self.cookies,
|
|
317
|
-
"headers": final_headers,
|
|
318
|
-
"params": self.query,
|
|
319
|
-
**extra,
|
|
320
|
-
}
|
|
401
|
+
return RequestsTransport().serialize_case(self, base_url=base_url, headers=headers)
|
|
402
|
+
|
|
403
|
+
def as_transport_kwargs(self, base_url: str | None = None, headers: dict[str, str] | None = None) -> dict[str, Any]:
|
|
404
|
+
"""Convert the test case into a dictionary acceptable by the underlying transport call."""
|
|
405
|
+
return self.operation.schema.transport.serialize_case(self, base_url=base_url, headers=headers)
|
|
321
406
|
|
|
322
407
|
def call(
|
|
323
408
|
self,
|
|
@@ -327,83 +412,21 @@ class Case:
|
|
|
327
412
|
params: dict[str, Any] | None = None,
|
|
328
413
|
cookies: dict[str, Any] | None = None,
|
|
329
414
|
**kwargs: Any,
|
|
330
|
-
) ->
|
|
331
|
-
import requests
|
|
332
|
-
|
|
333
|
-
"""Make a network call with `requests`."""
|
|
415
|
+
) -> GenericResponse:
|
|
334
416
|
hook_context = HookContext(operation=self.operation)
|
|
335
417
|
dispatch("before_call", hook_context, self)
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
_merge_dict_to(data, "params", params)
|
|
340
|
-
if cookies is not None:
|
|
341
|
-
_merge_dict_to(data, "cookies", cookies)
|
|
342
|
-
data.setdefault("timeout", DEFAULT_RESPONSE_TIMEOUT / 1000)
|
|
343
|
-
if session is None:
|
|
344
|
-
validate_vanilla_requests_kwargs(data)
|
|
345
|
-
session = requests.Session()
|
|
346
|
-
close_session = True
|
|
347
|
-
else:
|
|
348
|
-
close_session = False
|
|
349
|
-
verify = data.get("verify", True)
|
|
350
|
-
try:
|
|
351
|
-
with self.operation.schema.ratelimit():
|
|
352
|
-
response = session.request(**data) # type: ignore
|
|
353
|
-
except (requests.Timeout, requests.ConnectionError) as exc:
|
|
354
|
-
if isinstance(exc, requests.ConnectionError):
|
|
355
|
-
if not isinstance(exc.args[0], ReadTimeoutError):
|
|
356
|
-
raise
|
|
357
|
-
req = requests.Request(
|
|
358
|
-
method=data["method"].upper(),
|
|
359
|
-
url=data["url"],
|
|
360
|
-
headers=data["headers"],
|
|
361
|
-
files=data.get("files"),
|
|
362
|
-
data=data.get("data") or {},
|
|
363
|
-
json=data.get("json"),
|
|
364
|
-
params=data.get("params") or {},
|
|
365
|
-
auth=data.get("auth"),
|
|
366
|
-
cookies=data["cookies"],
|
|
367
|
-
hooks=data.get("hooks"),
|
|
368
|
-
)
|
|
369
|
-
request = session.prepare_request(req)
|
|
370
|
-
else:
|
|
371
|
-
request = cast(requests.PreparedRequest, exc.request)
|
|
372
|
-
timeout = 1000 * data["timeout"] # It is defined and not empty, since the exception happened
|
|
373
|
-
code_message = self._get_code_message(self.operation.schema.code_sample_style, request, verify=verify)
|
|
374
|
-
message = f"The server failed to respond within the specified limit of {timeout:.2f}ms"
|
|
375
|
-
raise get_timeout_error(timeout)(
|
|
376
|
-
f"\n\n1. {failures.RequestTimeout.title}\n\n{message}\n\n{code_message}",
|
|
377
|
-
context=failures.RequestTimeout(message=message, timeout=timeout),
|
|
378
|
-
) from None
|
|
379
|
-
response.verify = verify # type: ignore[attr-defined]
|
|
418
|
+
response = self.operation.schema.transport.send(
|
|
419
|
+
self, session=session, base_url=base_url, headers=headers, params=params, cookies=cookies, **kwargs
|
|
420
|
+
)
|
|
380
421
|
dispatch("after_call", hook_context, self, response)
|
|
381
|
-
if close_session:
|
|
382
|
-
session.close()
|
|
383
422
|
return response
|
|
384
423
|
|
|
424
|
+
@deprecated_function(removed_in="4.0", replacement="Case.as_transport_kwargs")
|
|
385
425
|
def as_werkzeug_kwargs(self, headers: dict[str, str] | None = None) -> dict[str, Any]:
|
|
386
426
|
"""Convert the case into a dictionary acceptable by werkzeug.Client."""
|
|
387
|
-
|
|
388
|
-
if self.media_type and not isinstance(self.body, NotSet):
|
|
389
|
-
# If we need to send a payload, then the Content-Type header should be set
|
|
390
|
-
final_headers["Content-Type"] = self.media_type
|
|
391
|
-
extra: dict[str, Any]
|
|
392
|
-
serializer = self._get_serializer()
|
|
393
|
-
if serializer is not None and not isinstance(self.body, NotSet):
|
|
394
|
-
context = SerializerContext(case=self)
|
|
395
|
-
extra = serializer.as_werkzeug(context, self.body)
|
|
396
|
-
else:
|
|
397
|
-
extra = {}
|
|
398
|
-
return {
|
|
399
|
-
"method": self.method,
|
|
400
|
-
"path": self.operation.schema.get_full_path(self.formatted_path),
|
|
401
|
-
# Convert to a regular dictionary, as we use `CaseInsensitiveDict` which is not supported by Werkzeug
|
|
402
|
-
"headers": dict(final_headers),
|
|
403
|
-
"query_string": self.query,
|
|
404
|
-
**extra,
|
|
405
|
-
}
|
|
427
|
+
return WSGITransport(self.app).serialize_case(self, headers=headers)
|
|
406
428
|
|
|
429
|
+
@deprecated_function(removed_in="4.0", replacement="Case.call")
|
|
407
430
|
def call_wsgi(
|
|
408
431
|
self,
|
|
409
432
|
app: Any = None,
|
|
@@ -411,10 +434,6 @@ class Case:
|
|
|
411
434
|
query_string: dict[str, str] | None = None,
|
|
412
435
|
**kwargs: Any,
|
|
413
436
|
) -> WSGIResponse:
|
|
414
|
-
from .transports.responses import WSGIResponse
|
|
415
|
-
import werkzeug
|
|
416
|
-
import requests
|
|
417
|
-
|
|
418
437
|
application = app or self.app
|
|
419
438
|
if application is None:
|
|
420
439
|
raise RuntimeError(
|
|
@@ -423,17 +442,11 @@ class Case:
|
|
|
423
442
|
)
|
|
424
443
|
hook_context = HookContext(operation=self.operation)
|
|
425
444
|
dispatch("before_call", hook_context, self)
|
|
426
|
-
|
|
427
|
-
if query_string is not None:
|
|
428
|
-
_merge_dict_to(data, "query_string", query_string)
|
|
429
|
-
client = werkzeug.Client(application, WSGIResponse)
|
|
430
|
-
with cookie_handler(client, self.cookies), self.operation.schema.ratelimit():
|
|
431
|
-
response = client.open(**data, **kwargs)
|
|
432
|
-
requests_kwargs = self.as_requests_kwargs(base_url=self.get_full_base_url(), headers=headers)
|
|
433
|
-
response.request = requests.Request(**requests_kwargs).prepare()
|
|
445
|
+
response = WSGITransport(application).send(self, headers=headers, params=query_string, **kwargs)
|
|
434
446
|
dispatch("after_call", hook_context, self, response)
|
|
435
447
|
return response
|
|
436
448
|
|
|
449
|
+
@deprecated_function(removed_in="4.0", replacement="Case.call")
|
|
437
450
|
def call_asgi(
|
|
438
451
|
self,
|
|
439
452
|
app: Any = None,
|
|
@@ -441,19 +454,17 @@ class Case:
|
|
|
441
454
|
headers: dict[str, str] | None = None,
|
|
442
455
|
**kwargs: Any,
|
|
443
456
|
) -> requests.Response:
|
|
444
|
-
from starlette_testclient import TestClient as ASGIClient
|
|
445
|
-
|
|
446
457
|
application = app or self.app
|
|
447
458
|
if application is None:
|
|
448
459
|
raise RuntimeError(
|
|
449
460
|
"ASGI application instance is required. "
|
|
450
461
|
"Please, set `app` argument in the schema constructor or pass it to `call_asgi`"
|
|
451
462
|
)
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
463
|
+
hook_context = HookContext(operation=self.operation)
|
|
464
|
+
dispatch("before_call", hook_context, self)
|
|
465
|
+
response = ASGITransport(application).send(self, base_url=base_url, headers=headers, **kwargs)
|
|
466
|
+
dispatch("after_call", hook_context, self, response)
|
|
467
|
+
return response
|
|
457
468
|
|
|
458
469
|
def validate_response(
|
|
459
470
|
self,
|
|
@@ -462,6 +473,8 @@ class Case:
|
|
|
462
473
|
additional_checks: tuple[CheckFunction, ...] = (),
|
|
463
474
|
excluded_checks: tuple[CheckFunction, ...] = (),
|
|
464
475
|
code_sample_style: str | None = None,
|
|
476
|
+
headers: dict[str, Any] | None = None,
|
|
477
|
+
transport_kwargs: dict[str, Any] | None = None,
|
|
465
478
|
) -> None:
|
|
466
479
|
"""Validate application response.
|
|
467
480
|
|
|
@@ -475,17 +488,35 @@ class Case:
|
|
|
475
488
|
:param code_sample_style: Controls the style of code samples for failure reproduction.
|
|
476
489
|
"""
|
|
477
490
|
__tracebackhide__ = True
|
|
491
|
+
from requests.structures import CaseInsensitiveDict
|
|
492
|
+
|
|
478
493
|
from .checks import ALL_CHECKS
|
|
494
|
+
from .internal.checks import wrap_check
|
|
479
495
|
from .transports.responses import get_payload, get_reason
|
|
480
496
|
|
|
481
|
-
|
|
497
|
+
if checks:
|
|
498
|
+
_checks = tuple(wrap_check(check) for check in checks)
|
|
499
|
+
else:
|
|
500
|
+
_checks = checks
|
|
501
|
+
if additional_checks:
|
|
502
|
+
_additional_checks = tuple(wrap_check(check) for check in additional_checks)
|
|
503
|
+
else:
|
|
504
|
+
_additional_checks = additional_checks
|
|
505
|
+
|
|
506
|
+
checks = _checks or ALL_CHECKS
|
|
482
507
|
checks = tuple(check for check in checks if check not in excluded_checks)
|
|
483
|
-
additional_checks = tuple(check for check in
|
|
508
|
+
additional_checks = tuple(check for check in _additional_checks if check not in excluded_checks)
|
|
484
509
|
failed_checks = []
|
|
510
|
+
ctx = CheckContext(
|
|
511
|
+
override=self._override,
|
|
512
|
+
auth=None,
|
|
513
|
+
headers=CaseInsensitiveDict(headers) if headers else None,
|
|
514
|
+
transport_kwargs=transport_kwargs,
|
|
515
|
+
)
|
|
485
516
|
for check in chain(checks, additional_checks):
|
|
486
517
|
copied_case = self.partial_deepcopy()
|
|
487
518
|
try:
|
|
488
|
-
check(response, copied_case)
|
|
519
|
+
check(ctx, response, copied_case)
|
|
489
520
|
except AssertionError as exc:
|
|
490
521
|
maybe_set_assertion_message(exc, check.__name__)
|
|
491
522
|
failed_checks.append(exc)
|
|
@@ -515,7 +546,7 @@ class Case:
|
|
|
515
546
|
if not payload:
|
|
516
547
|
formatted += "\n\n <EMPTY>"
|
|
517
548
|
else:
|
|
518
|
-
payload = prepare_response_payload(payload)
|
|
549
|
+
payload = prepare_response_payload(payload, config=self.operation.schema.output_config)
|
|
519
550
|
payload = textwrap.indent(f"\n`{payload}`", prefix=" ")
|
|
520
551
|
formatted += f"\n{payload}"
|
|
521
552
|
code_sample_style = (
|
|
@@ -550,20 +581,37 @@ class Case:
|
|
|
550
581
|
session: requests.Session | None = None,
|
|
551
582
|
headers: dict[str, Any] | None = None,
|
|
552
583
|
checks: tuple[CheckFunction, ...] = (),
|
|
584
|
+
additional_checks: tuple[CheckFunction, ...] = (),
|
|
585
|
+
excluded_checks: tuple[CheckFunction, ...] = (),
|
|
553
586
|
code_sample_style: str | None = None,
|
|
554
587
|
**kwargs: Any,
|
|
555
588
|
) -> requests.Response:
|
|
556
589
|
__tracebackhide__ = True
|
|
557
590
|
response = self.call(base_url, session, headers, **kwargs)
|
|
558
|
-
self.validate_response(
|
|
591
|
+
self.validate_response(
|
|
592
|
+
response,
|
|
593
|
+
checks,
|
|
594
|
+
code_sample_style=code_sample_style,
|
|
595
|
+
headers=headers,
|
|
596
|
+
additional_checks=additional_checks,
|
|
597
|
+
excluded_checks=excluded_checks,
|
|
598
|
+
transport_kwargs=kwargs,
|
|
599
|
+
)
|
|
559
600
|
return response
|
|
560
601
|
|
|
602
|
+
def _get_url(self, base_url: str | None) -> str:
|
|
603
|
+
base_url = self._get_base_url(base_url)
|
|
604
|
+
formatted_path = self.formatted_path.lstrip("/")
|
|
605
|
+
if not base_url.endswith("/"):
|
|
606
|
+
base_url += "/"
|
|
607
|
+
return unquote(urljoin(base_url, quote(formatted_path)))
|
|
608
|
+
|
|
561
609
|
def get_full_url(self) -> str:
|
|
562
610
|
"""Make a full URL to the current API operation, including query parameters."""
|
|
563
611
|
import requests
|
|
564
612
|
|
|
565
613
|
base_url = self.base_url or "http://127.0.0.1"
|
|
566
|
-
kwargs =
|
|
614
|
+
kwargs = RequestsTransport().serialize_case(self, base_url=base_url)
|
|
567
615
|
request = requests.Request(**kwargs)
|
|
568
616
|
prepared = requests.Session().prepare_request(request) # type: ignore
|
|
569
617
|
return cast(str, prepared.url)
|
|
@@ -579,28 +627,12 @@ class Case:
|
|
|
579
627
|
cookies=fast_deepcopy(self.cookies),
|
|
580
628
|
query=fast_deepcopy(self.query),
|
|
581
629
|
body=fast_deepcopy(self.body),
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
original[key] = value
|
|
589
|
-
data[data_key] = original
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
def validate_vanilla_requests_kwargs(data: dict[str, Any]) -> None:
|
|
593
|
-
"""Check arguments for `requests.Session.request`.
|
|
594
|
-
|
|
595
|
-
Some arguments can be valid for cases like ASGI integration, but at the same time they won't work for the regular
|
|
596
|
-
`requests` calls. In such cases we need to avoid an obscure error message, that comes from `requests`.
|
|
597
|
-
"""
|
|
598
|
-
url = data["url"]
|
|
599
|
-
if not urlparse(url).netloc:
|
|
600
|
-
raise RuntimeError(
|
|
601
|
-
"The URL should be absolute, so Schemathesis knows where to send the data. \n"
|
|
602
|
-
f"If you use the ASGI integration, please supply your test client "
|
|
603
|
-
f"as the `session` argument to `call`.\nURL: {url}"
|
|
630
|
+
meta=self.meta,
|
|
631
|
+
generation_time=self.generation_time,
|
|
632
|
+
id=self.id,
|
|
633
|
+
_auth=self._auth,
|
|
634
|
+
_has_explicit_auth=self._has_explicit_auth,
|
|
635
|
+
_explicit_method=self._explicit_method,
|
|
604
636
|
)
|
|
605
637
|
|
|
606
638
|
|
|
@@ -628,7 +660,7 @@ D = TypeVar("D", bound=dict)
|
|
|
628
660
|
|
|
629
661
|
|
|
630
662
|
@dataclass
|
|
631
|
-
class OperationDefinition(Generic[
|
|
663
|
+
class OperationDefinition(Generic[D]):
|
|
632
664
|
"""A wrapper to store not resolved API operation definitions.
|
|
633
665
|
|
|
634
666
|
To prevent recursion errors we need to store definitions without resolving references. But operation definitions
|
|
@@ -639,16 +671,10 @@ class OperationDefinition(Generic[P, D]):
|
|
|
639
671
|
raw: D
|
|
640
672
|
resolved: D
|
|
641
673
|
scope: str
|
|
642
|
-
parameters: Sequence[P]
|
|
643
|
-
|
|
644
|
-
def __contains__(self, item: str | int) -> bool:
|
|
645
|
-
return item in self.resolved
|
|
646
674
|
|
|
647
|
-
|
|
648
|
-
return self.resolved[item]
|
|
675
|
+
__slots__ = ("raw", "resolved", "scope")
|
|
649
676
|
|
|
650
|
-
def
|
|
651
|
-
return self.resolved.get(item, default)
|
|
677
|
+
def _repr_pretty_(self, *args: Any, **kwargs: Any) -> None: ...
|
|
652
678
|
|
|
653
679
|
|
|
654
680
|
C = TypeVar("C", bound=Case)
|
|
@@ -770,9 +796,11 @@ class APIOperation(Generic[P, C]):
|
|
|
770
796
|
def get_security_requirements(self) -> list[str]:
|
|
771
797
|
return self.schema.get_security_requirements(self)
|
|
772
798
|
|
|
773
|
-
def get_strategies_from_examples(
|
|
799
|
+
def get_strategies_from_examples(
|
|
800
|
+
self, as_strategy_kwargs: dict[str, Any] | None = None
|
|
801
|
+
) -> list[st.SearchStrategy[Case]]:
|
|
774
802
|
"""Get examples from the API operation."""
|
|
775
|
-
return self.schema.get_strategies_from_examples(self)
|
|
803
|
+
return self.schema.get_strategies_from_examples(self, as_strategy_kwargs=as_strategy_kwargs)
|
|
776
804
|
|
|
777
805
|
def get_stateful_tests(self, response: GenericResponse, stateful: Stateful | None) -> Sequence[StatefulTest]:
|
|
778
806
|
return self.schema.get_stateful_tests(response, self, stateful)
|
|
@@ -791,6 +819,19 @@ class APIOperation(Generic[P, C]):
|
|
|
791
819
|
def get_request_payload_content_types(self) -> list[str]:
|
|
792
820
|
return self.schema.get_request_payload_content_types(self)
|
|
793
821
|
|
|
822
|
+
def _get_default_media_type(self) -> str:
|
|
823
|
+
# If the user wants to send payload, then there should be a media type, otherwise the payload is ignored
|
|
824
|
+
media_types = self.get_request_payload_content_types()
|
|
825
|
+
if len(media_types) == 1:
|
|
826
|
+
# The only available option
|
|
827
|
+
return media_types[0]
|
|
828
|
+
media_types_repr = ", ".join(media_types)
|
|
829
|
+
raise UsageError(
|
|
830
|
+
"Can not detect appropriate media type. "
|
|
831
|
+
"You can either specify one of the defined media types "
|
|
832
|
+
f"or pass any other media type available for serialization. Defined media types: {media_types_repr}"
|
|
833
|
+
)
|
|
834
|
+
|
|
794
835
|
def partial_deepcopy(self) -> APIOperation:
|
|
795
836
|
return self.__class__(
|
|
796
837
|
path=self.path, # string, immutable
|
|
@@ -911,6 +952,7 @@ class Request:
|
|
|
911
952
|
method: str
|
|
912
953
|
uri: str
|
|
913
954
|
body: str | None
|
|
955
|
+
body_size: int | None
|
|
914
956
|
headers: Headers
|
|
915
957
|
|
|
916
958
|
@classmethod
|
|
@@ -919,7 +961,7 @@ class Request:
|
|
|
919
961
|
import requests
|
|
920
962
|
|
|
921
963
|
base_url = case.get_full_base_url()
|
|
922
|
-
kwargs =
|
|
964
|
+
kwargs = RequestsTransport().serialize_case(case, base_url=base_url)
|
|
923
965
|
request = requests.Request(**kwargs)
|
|
924
966
|
prepared = session.prepare_request(request) # type: ignore
|
|
925
967
|
return cls.from_prepared_request(prepared)
|
|
@@ -941,8 +983,17 @@ class Request:
|
|
|
941
983
|
method=method,
|
|
942
984
|
headers={key: [value] for (key, value) in prepared.headers.items()},
|
|
943
985
|
body=serialize_payload(body) if body is not None else body,
|
|
986
|
+
body_size=len(body) if body is not None else None,
|
|
944
987
|
)
|
|
945
988
|
|
|
989
|
+
def deserialize_body(self) -> bytes | None:
|
|
990
|
+
"""Deserialize the request body.
|
|
991
|
+
|
|
992
|
+
`Request` should be serializable to JSON, therefore body is encoded as base64 string
|
|
993
|
+
to support arbitrary binary data.
|
|
994
|
+
"""
|
|
995
|
+
return deserialize_payload(self.body)
|
|
996
|
+
|
|
946
997
|
|
|
947
998
|
@dataclass(repr=False)
|
|
948
999
|
class Response:
|
|
@@ -952,6 +1003,7 @@ class Response:
|
|
|
952
1003
|
message: str
|
|
953
1004
|
headers: dict[str, list[str]]
|
|
954
1005
|
body: str | None
|
|
1006
|
+
body_size: int | None
|
|
955
1007
|
encoding: str | None
|
|
956
1008
|
http_version: str
|
|
957
1009
|
elapsed: float
|
|
@@ -960,9 +1012,12 @@ class Response:
|
|
|
960
1012
|
@classmethod
|
|
961
1013
|
def from_requests(cls, response: requests.Response) -> Response:
|
|
962
1014
|
"""Create a response from requests.Response."""
|
|
963
|
-
|
|
1015
|
+
raw = response.raw
|
|
1016
|
+
raw_headers = raw.headers if raw is not None else {}
|
|
1017
|
+
headers = {name: response.raw.headers.getlist(name) for name in raw_headers.keys()}
|
|
964
1018
|
# Similar to http.client:319 (HTTP version detection in stdlib's `http` package)
|
|
965
|
-
|
|
1019
|
+
version = raw.version if raw is not None else 10
|
|
1020
|
+
http_version = "1.0" if version == 10 else "1.1"
|
|
966
1021
|
|
|
967
1022
|
def is_empty(_response: requests.Response) -> bool:
|
|
968
1023
|
# Assume the response is empty if:
|
|
@@ -975,6 +1030,7 @@ class Response:
|
|
|
975
1030
|
status_code=response.status_code,
|
|
976
1031
|
message=response.reason,
|
|
977
1032
|
body=body,
|
|
1033
|
+
body_size=len(response.content) if body is not None else None,
|
|
978
1034
|
encoding=response.encoding,
|
|
979
1035
|
headers=headers,
|
|
980
1036
|
http_version=http_version,
|
|
@@ -1002,6 +1058,7 @@ class Response:
|
|
|
1002
1058
|
status_code=response.status_code,
|
|
1003
1059
|
message=message,
|
|
1004
1060
|
body=body,
|
|
1061
|
+
body_size=len(data) if body is not None else None,
|
|
1005
1062
|
encoding=encoding,
|
|
1006
1063
|
headers=headers,
|
|
1007
1064
|
http_version="1.1",
|
|
@@ -1009,35 +1066,76 @@ class Response:
|
|
|
1009
1066
|
verify=True,
|
|
1010
1067
|
)
|
|
1011
1068
|
|
|
1069
|
+
def deserialize_body(self) -> bytes | None:
|
|
1070
|
+
"""Deserialize the response body.
|
|
1071
|
+
|
|
1072
|
+
`Response` should be serializable to JSON, therefore body is encoded as base64 string
|
|
1073
|
+
to support arbitrary binary data.
|
|
1074
|
+
"""
|
|
1075
|
+
return deserialize_payload(self.body)
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
TIMEZONE = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo
|
|
1079
|
+
|
|
1012
1080
|
|
|
1013
1081
|
@dataclass
|
|
1014
1082
|
class Interaction:
|
|
1015
1083
|
"""A single interaction with the target app."""
|
|
1016
1084
|
|
|
1017
1085
|
request: Request
|
|
1018
|
-
response: Response
|
|
1086
|
+
response: Response | None
|
|
1019
1087
|
checks: list[Check]
|
|
1020
1088
|
status: Status
|
|
1021
1089
|
data_generation_method: DataGenerationMethod
|
|
1022
|
-
|
|
1090
|
+
phase: TestPhase | None
|
|
1091
|
+
# `description` & `location` are related to metadata about this interaction
|
|
1092
|
+
# NOTE: It will be better to keep it in a separate attribute
|
|
1093
|
+
description: str | None
|
|
1094
|
+
location: str | None
|
|
1095
|
+
parameter: str | None
|
|
1096
|
+
parameter_location: str | None
|
|
1097
|
+
recorded_at: str = field(default_factory=lambda: datetime.datetime.now(TIMEZONE).isoformat())
|
|
1023
1098
|
|
|
1024
1099
|
@classmethod
|
|
1025
|
-
def from_requests(
|
|
1100
|
+
def from_requests(
|
|
1101
|
+
cls,
|
|
1102
|
+
case: Case,
|
|
1103
|
+
response: requests.Response | None,
|
|
1104
|
+
status: Status,
|
|
1105
|
+
checks: list[Check],
|
|
1106
|
+
headers: dict[str, Any] | None,
|
|
1107
|
+
session: requests.Session | None,
|
|
1108
|
+
) -> Interaction:
|
|
1109
|
+
if response is not None:
|
|
1110
|
+
prepared = response.request
|
|
1111
|
+
request = Request.from_prepared_request(prepared)
|
|
1112
|
+
else:
|
|
1113
|
+
import requests
|
|
1114
|
+
|
|
1115
|
+
if session is None:
|
|
1116
|
+
session = requests.Session()
|
|
1117
|
+
session.headers.update(headers or {})
|
|
1118
|
+
request = Request.from_case(case, session)
|
|
1026
1119
|
return cls(
|
|
1027
|
-
request=
|
|
1028
|
-
response=Response.from_requests(response),
|
|
1120
|
+
request=request,
|
|
1121
|
+
response=Response.from_requests(response) if response is not None else None,
|
|
1029
1122
|
status=status,
|
|
1030
1123
|
checks=checks,
|
|
1031
1124
|
data_generation_method=cast(DataGenerationMethod, case.data_generation_method),
|
|
1125
|
+
phase=case.meta.phase if case.meta is not None else None,
|
|
1126
|
+
description=case.meta.description if case.meta is not None else None,
|
|
1127
|
+
location=case.meta.location if case.meta is not None else None,
|
|
1128
|
+
parameter=case.meta.parameter if case.meta is not None else None,
|
|
1129
|
+
parameter_location=case.meta.parameter_location if case.meta is not None else None,
|
|
1032
1130
|
)
|
|
1033
1131
|
|
|
1034
1132
|
@classmethod
|
|
1035
1133
|
def from_wsgi(
|
|
1036
1134
|
cls,
|
|
1037
1135
|
case: Case,
|
|
1038
|
-
response: WSGIResponse,
|
|
1136
|
+
response: WSGIResponse | None,
|
|
1039
1137
|
headers: dict[str, Any],
|
|
1040
|
-
elapsed: float,
|
|
1138
|
+
elapsed: float | None,
|
|
1041
1139
|
status: Status,
|
|
1042
1140
|
checks: list[Check],
|
|
1043
1141
|
) -> Interaction:
|
|
@@ -1047,10 +1145,15 @@ class Interaction:
|
|
|
1047
1145
|
session.headers.update(headers)
|
|
1048
1146
|
return cls(
|
|
1049
1147
|
request=Request.from_case(case, session),
|
|
1050
|
-
response=Response.from_wsgi(response, elapsed),
|
|
1148
|
+
response=Response.from_wsgi(response, elapsed) if response is not None and elapsed is not None else None,
|
|
1051
1149
|
status=status,
|
|
1052
1150
|
checks=checks,
|
|
1053
1151
|
data_generation_method=cast(DataGenerationMethod, case.data_generation_method),
|
|
1152
|
+
phase=case.meta.phase if case.meta is not None else None,
|
|
1153
|
+
description=case.meta.description if case.meta is not None else None,
|
|
1154
|
+
location=case.meta.location if case.meta is not None else None,
|
|
1155
|
+
parameter=case.meta.parameter if case.meta is not None else None,
|
|
1156
|
+
parameter_location=case.meta.parameter_location if case.meta is not None else None,
|
|
1054
1157
|
)
|
|
1055
1158
|
|
|
1056
1159
|
|
|
@@ -1076,6 +1179,8 @@ class TestResult:
|
|
|
1076
1179
|
# DEPRECATED: Seed is the same per test run
|
|
1077
1180
|
seed: int | None = None
|
|
1078
1181
|
|
|
1182
|
+
def _repr_pretty_(self, *args: Any, **kwargs: Any) -> None: ...
|
|
1183
|
+
|
|
1079
1184
|
def mark_errored(self) -> None:
|
|
1080
1185
|
self.is_errored = True
|
|
1081
1186
|
|
|
@@ -1136,16 +1241,22 @@ class TestResult:
|
|
|
1136
1241
|
self.errors.append(exception)
|
|
1137
1242
|
|
|
1138
1243
|
def store_requests_response(
|
|
1139
|
-
self,
|
|
1244
|
+
self,
|
|
1245
|
+
case: Case,
|
|
1246
|
+
response: requests.Response | None,
|
|
1247
|
+
status: Status,
|
|
1248
|
+
checks: list[Check],
|
|
1249
|
+
headers: dict[str, Any] | None,
|
|
1250
|
+
session: requests.Session | None,
|
|
1140
1251
|
) -> None:
|
|
1141
|
-
self.interactions.append(Interaction.from_requests(case, response, status, checks))
|
|
1252
|
+
self.interactions.append(Interaction.from_requests(case, response, status, checks, headers, session))
|
|
1142
1253
|
|
|
1143
1254
|
def store_wsgi_response(
|
|
1144
1255
|
self,
|
|
1145
1256
|
case: Case,
|
|
1146
|
-
response: WSGIResponse,
|
|
1257
|
+
response: WSGIResponse | None,
|
|
1147
1258
|
headers: dict[str, Any],
|
|
1148
|
-
elapsed: float,
|
|
1259
|
+
elapsed: float | None,
|
|
1149
1260
|
status: Status,
|
|
1150
1261
|
checks: list[Check],
|
|
1151
1262
|
) -> None:
|
|
@@ -1163,6 +1274,8 @@ class TestResultSet:
|
|
|
1163
1274
|
generic_errors: list[OperationSchemaError] = field(default_factory=list)
|
|
1164
1275
|
warnings: list[str] = field(default_factory=list)
|
|
1165
1276
|
|
|
1277
|
+
def _repr_pretty_(self, *args: Any, **kwargs: Any) -> None: ...
|
|
1278
|
+
|
|
1166
1279
|
def __iter__(self) -> Iterator[TestResult]:
|
|
1167
1280
|
return iter(self.results)
|
|
1168
1281
|
|
|
@@ -1226,6 +1339,3 @@ class TestResultSet:
|
|
|
1226
1339
|
def add_warning(self, warning: str) -> None:
|
|
1227
1340
|
"""Add a new warning to the warnings list."""
|
|
1228
1341
|
self.warnings.append(warning)
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
CheckFunction = Callable[["GenericResponse", Case], Optional[bool]]
|