schemathesis 3.26.2__py3-none-any.whl → 3.27.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- schemathesis/cli/__init__.py +1 -1
- schemathesis/cli/junitxml.py +79 -10
- schemathesis/cli/output/default.py +9 -47
- schemathesis/cli/reporting.py +72 -0
- schemathesis/models.py +51 -143
- schemathesis/runner/impl/core.py +10 -8
- schemathesis/runner/serialization.py +32 -10
- schemathesis/schemas.py +11 -7
- schemathesis/specs/graphql/loaders.py +2 -0
- schemathesis/specs/graphql/schemas.py +7 -40
- schemathesis/specs/openapi/loaders.py +3 -0
- schemathesis/stateful/state_machine.py +3 -13
- schemathesis/transports/__init__.py +306 -0
- schemathesis/transports/responses.py +2 -0
- {schemathesis-3.26.2.dist-info → schemathesis-3.27.0.dist-info}/METADATA +1 -1
- {schemathesis-3.26.2.dist-info → schemathesis-3.27.0.dist-info}/RECORD +19 -18
- {schemathesis-3.26.2.dist-info → schemathesis-3.27.0.dist-info}/WHEEL +0 -0
- {schemathesis-3.26.2.dist-info → schemathesis-3.27.0.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.26.2.dist-info → schemathesis-3.27.0.dist-info}/licenses/LICENSE +0 -0
schemathesis/runner/impl/core.py
CHANGED
|
@@ -21,6 +21,8 @@ from jsonschema.exceptions import SchemaError as JsonSchemaError
|
|
|
21
21
|
from jsonschema.exceptions import ValidationError
|
|
22
22
|
from requests.auth import HTTPDigestAuth, _basic_auth_str
|
|
23
23
|
|
|
24
|
+
from schemathesis.transports import RequestsTransport
|
|
25
|
+
|
|
24
26
|
from ... import failures, hooks
|
|
25
27
|
from ..._compat import MultipleFailures
|
|
26
28
|
from ..._hypothesis import (
|
|
@@ -849,7 +851,7 @@ def _network_test(
|
|
|
849
851
|
response = case.call(**kwargs)
|
|
850
852
|
except CheckFailed as exc:
|
|
851
853
|
check_name = "request_timeout"
|
|
852
|
-
requests_kwargs =
|
|
854
|
+
requests_kwargs = RequestsTransport().serialize_case(case, base_url=case.get_full_base_url(), headers=headers)
|
|
853
855
|
request = requests.Request(**requests_kwargs).prepare()
|
|
854
856
|
elapsed = cast(float, timeout) # It is defined and not empty, since the exception happened
|
|
855
857
|
check_result = result.add_failure(
|
|
@@ -939,14 +941,14 @@ def _wsgi_test(
|
|
|
939
941
|
feedback: Feedback,
|
|
940
942
|
max_response_time: int | None,
|
|
941
943
|
) -> WSGIResponse:
|
|
944
|
+
from ...transports.responses import WSGIResponse
|
|
945
|
+
|
|
942
946
|
with catching_logs(LogCaptureHandler(), level=logging.DEBUG) as recorded:
|
|
943
|
-
start = time.monotonic()
|
|
944
947
|
hook_context = HookContext(operation=case.operation)
|
|
945
|
-
kwargs = {"headers": headers}
|
|
948
|
+
kwargs: dict[str, Any] = {"headers": headers}
|
|
946
949
|
hooks.dispatch("process_call_kwargs", hook_context, case, kwargs)
|
|
947
|
-
response = case.
|
|
948
|
-
|
|
949
|
-
context = TargetContext(case=case, response=response, response_time=elapsed)
|
|
950
|
+
response = cast(WSGIResponse, case.call(**kwargs))
|
|
951
|
+
context = TargetContext(case=case, response=response, response_time=response.elapsed.total_seconds())
|
|
950
952
|
run_targets(targets, context)
|
|
951
953
|
result.logs.extend(recorded.records)
|
|
952
954
|
status = Status.success
|
|
@@ -967,7 +969,7 @@ def _wsgi_test(
|
|
|
967
969
|
finally:
|
|
968
970
|
feedback.add_test_case(case, response)
|
|
969
971
|
if store_interactions:
|
|
970
|
-
result.store_wsgi_response(case, response, headers, elapsed, status, check_results)
|
|
972
|
+
result.store_wsgi_response(case, response, headers, response.elapsed.total_seconds(), status, check_results)
|
|
971
973
|
return response
|
|
972
974
|
|
|
973
975
|
|
|
@@ -1037,7 +1039,7 @@ def _asgi_test(
|
|
|
1037
1039
|
hook_context = HookContext(operation=case.operation)
|
|
1038
1040
|
kwargs: dict[str, Any] = {"headers": headers}
|
|
1039
1041
|
hooks.dispatch("process_call_kwargs", hook_context, case, kwargs)
|
|
1040
|
-
response = case.
|
|
1042
|
+
response = case.call(**kwargs)
|
|
1041
1043
|
context = TargetContext(case=case, response=response, response_time=response.elapsed.total_seconds())
|
|
1042
1044
|
run_targets(targets, context)
|
|
1043
1045
|
status = Status.success
|
|
@@ -4,28 +4,30 @@ They all consist of primitive types and don't have references to schemas, app, e
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
|
+
|
|
7
8
|
import logging
|
|
8
9
|
import re
|
|
10
|
+
import textwrap
|
|
9
11
|
from dataclasses import dataclass, field
|
|
10
|
-
from typing import
|
|
12
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
11
13
|
|
|
12
|
-
from ..transports import serialize_payload
|
|
13
14
|
from ..code_samples import get_excluded_headers
|
|
14
15
|
from ..exceptions import (
|
|
16
|
+
BodyInGetRequestError,
|
|
17
|
+
DeadlineExceeded,
|
|
15
18
|
FailureContext,
|
|
16
19
|
InternalError,
|
|
17
|
-
make_unique_by_key,
|
|
18
|
-
format_exception,
|
|
19
|
-
extract_requests_exception_details,
|
|
20
|
-
RuntimeErrorType,
|
|
21
|
-
DeadlineExceeded,
|
|
22
|
-
OperationSchemaError,
|
|
23
|
-
BodyInGetRequestError,
|
|
24
20
|
InvalidRegularExpression,
|
|
21
|
+
OperationSchemaError,
|
|
22
|
+
RuntimeErrorType,
|
|
25
23
|
SerializationError,
|
|
26
24
|
UnboundPrefixError,
|
|
25
|
+
extract_requests_exception_details,
|
|
26
|
+
format_exception,
|
|
27
|
+
make_unique_by_key,
|
|
27
28
|
)
|
|
28
29
|
from ..models import Case, Check, Interaction, Request, Response, Status, TestResult
|
|
30
|
+
from ..transports import serialize_payload
|
|
29
31
|
|
|
30
32
|
if TYPE_CHECKING:
|
|
31
33
|
import hypothesis.errors
|
|
@@ -108,6 +110,7 @@ class SerializedCheck:
|
|
|
108
110
|
@classmethod
|
|
109
111
|
def from_check(cls, check: Check) -> SerializedCheck:
|
|
110
112
|
import requests
|
|
113
|
+
|
|
111
114
|
from ..transports.responses import WSGIResponse
|
|
112
115
|
|
|
113
116
|
if check.response is not None:
|
|
@@ -140,6 +143,25 @@ class SerializedCheck:
|
|
|
140
143
|
history=history,
|
|
141
144
|
)
|
|
142
145
|
|
|
146
|
+
@property
|
|
147
|
+
def title(self) -> str:
|
|
148
|
+
if self.context is not None:
|
|
149
|
+
return self.context.title
|
|
150
|
+
return f"Custom check failed: `{self.name}`"
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def formatted_message(self) -> str | None:
|
|
154
|
+
if self.context is not None:
|
|
155
|
+
if self.context.message:
|
|
156
|
+
message = self.context.message
|
|
157
|
+
else:
|
|
158
|
+
message = None
|
|
159
|
+
else:
|
|
160
|
+
message = self.message
|
|
161
|
+
if message is not None:
|
|
162
|
+
message = textwrap.indent(message, prefix=" ")
|
|
163
|
+
return message
|
|
164
|
+
|
|
143
165
|
|
|
144
166
|
def _get_headers(headers: dict[str, Any] | CaseInsensitiveDict) -> dict[str, str]:
|
|
145
167
|
return {key: value[0] for key, value in headers.items() if key not in get_excluded_headers()}
|
|
@@ -203,8 +225,8 @@ class SerializedError:
|
|
|
203
225
|
|
|
204
226
|
@classmethod
|
|
205
227
|
def from_exception(cls, exception: Exception) -> SerializedError:
|
|
206
|
-
import requests
|
|
207
228
|
import hypothesis.errors
|
|
229
|
+
import requests
|
|
208
230
|
from hypothesis import HealthCheck
|
|
209
231
|
|
|
210
232
|
title = "Runtime Error"
|
schemathesis/schemas.py
CHANGED
|
@@ -8,11 +8,13 @@ They give only static definitions of paths.
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
|
+
|
|
11
12
|
from collections.abc import Mapping, MutableMapping
|
|
12
13
|
from contextlib import nullcontext
|
|
13
14
|
from dataclasses import dataclass, field
|
|
14
15
|
from functools import lru_cache
|
|
15
16
|
from typing import (
|
|
17
|
+
TYPE_CHECKING,
|
|
16
18
|
Any,
|
|
17
19
|
Callable,
|
|
18
20
|
ContextManager,
|
|
@@ -22,7 +24,6 @@ from typing import (
|
|
|
22
24
|
NoReturn,
|
|
23
25
|
Sequence,
|
|
24
26
|
TypeVar,
|
|
25
|
-
TYPE_CHECKING,
|
|
26
27
|
)
|
|
27
28
|
from urllib.parse import quote, unquote, urljoin, urlparse, urlsplit, urlunsplit
|
|
28
29
|
|
|
@@ -30,23 +31,23 @@ import hypothesis
|
|
|
30
31
|
from hypothesis.strategies import SearchStrategy
|
|
31
32
|
from pyrate_limiter import Limiter
|
|
32
33
|
|
|
33
|
-
from .
|
|
34
|
+
from ._dependency_versions import IS_PYRATE_LIMITER_ABOVE_3
|
|
34
35
|
from ._hypothesis import create_test
|
|
35
36
|
from .auths import AuthStorage
|
|
36
37
|
from .code_samples import CodeSampleStyle
|
|
38
|
+
from .constants import NOT_SET
|
|
39
|
+
from .exceptions import OperationSchemaError, UsageError
|
|
37
40
|
from .generation import (
|
|
38
41
|
DEFAULT_DATA_GENERATION_METHODS,
|
|
39
42
|
DataGenerationMethod,
|
|
40
43
|
DataGenerationMethodInput,
|
|
41
44
|
GenerationConfig,
|
|
42
45
|
)
|
|
43
|
-
from ._dependency_versions import IS_PYRATE_LIMITER_ABOVE_3
|
|
44
|
-
from .exceptions import OperationSchemaError, UsageError
|
|
45
46
|
from .hooks import HookContext, HookDispatcher, HookScope, dispatch
|
|
46
|
-
from .internal.result import
|
|
47
|
+
from .internal.result import Ok, Result
|
|
47
48
|
from .models import APIOperation, Case
|
|
48
|
-
from .stateful.state_machine import APIStateMachine
|
|
49
49
|
from .stateful import Stateful, StatefulTest
|
|
50
|
+
from .stateful.state_machine import APIStateMachine
|
|
50
51
|
from .types import (
|
|
51
52
|
Body,
|
|
52
53
|
Cookies,
|
|
@@ -58,9 +59,10 @@ from .types import (
|
|
|
58
59
|
PathParameters,
|
|
59
60
|
Query,
|
|
60
61
|
)
|
|
61
|
-
from .utils import PARAMETRIZE_MARKER, GivenInput,
|
|
62
|
+
from .utils import PARAMETRIZE_MARKER, GivenInput, combine_strategies, given_proxy
|
|
62
63
|
|
|
63
64
|
if TYPE_CHECKING:
|
|
65
|
+
from .transports import Transport
|
|
64
66
|
from .transports.responses import GenericResponse
|
|
65
67
|
|
|
66
68
|
|
|
@@ -75,6 +77,7 @@ def get_full_path(base_path: str, path: str) -> str:
|
|
|
75
77
|
@dataclass(eq=False)
|
|
76
78
|
class BaseSchema(Mapping):
|
|
77
79
|
raw_schema: dict[str, Any]
|
|
80
|
+
transport: Transport
|
|
78
81
|
location: str | None = None
|
|
79
82
|
base_url: str | None = None
|
|
80
83
|
method: Filter | None = None
|
|
@@ -346,6 +349,7 @@ class BaseSchema(Mapping):
|
|
|
346
349
|
code_sample_style=code_sample_style, # type: ignore
|
|
347
350
|
rate_limiter=rate_limiter, # type: ignore
|
|
348
351
|
sanitize_output=sanitize_output, # type: ignore
|
|
352
|
+
transport=self.transport,
|
|
349
353
|
)
|
|
350
354
|
|
|
351
355
|
def get_local_hook_dispatcher(self) -> HookDispatcher | None:
|
|
@@ -234,6 +234,7 @@ def from_dict(
|
|
|
234
234
|
:return: GraphQLSchema
|
|
235
235
|
"""
|
|
236
236
|
from .schemas import GraphQLSchema
|
|
237
|
+
from ... import transports
|
|
237
238
|
|
|
238
239
|
_code_sample_style = CodeSampleStyle.from_str(code_sample_style)
|
|
239
240
|
hook_context = HookContext()
|
|
@@ -252,6 +253,7 @@ def from_dict(
|
|
|
252
253
|
code_sample_style=_code_sample_style,
|
|
253
254
|
rate_limiter=rate_limiter,
|
|
254
255
|
sanitize_output=sanitize_output,
|
|
256
|
+
transport=transports.get(app),
|
|
255
257
|
) # type: ignore
|
|
256
258
|
dispatch("after_load_schema", hook_context, instance)
|
|
257
259
|
return instance
|
|
@@ -20,7 +20,6 @@ from typing import (
|
|
|
20
20
|
from urllib.parse import urlsplit, urlunsplit
|
|
21
21
|
|
|
22
22
|
import graphql
|
|
23
|
-
import requests
|
|
24
23
|
from graphql import GraphQLNamedType
|
|
25
24
|
from hypothesis import strategies as st
|
|
26
25
|
from hypothesis.strategies import SearchStrategy
|
|
@@ -60,39 +59,15 @@ class RootType(enum.Enum):
|
|
|
60
59
|
|
|
61
60
|
@dataclass(repr=False)
|
|
62
61
|
class GraphQLCase(Case):
|
|
63
|
-
def
|
|
64
|
-
final_headers = self._get_headers(headers)
|
|
62
|
+
def _get_url(self, base_url: str | None) -> str:
|
|
65
63
|
base_url = self._get_base_url(base_url)
|
|
66
64
|
# Replace the path, in case if the user provided any path parameters via hooks
|
|
67
65
|
parts = list(urlsplit(base_url))
|
|
68
66
|
parts[2] = self.formatted_path
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
"cookies": self.cookies,
|
|
74
|
-
"params": self.query,
|
|
75
|
-
}
|
|
76
|
-
# There is no direct way to have bytes here, but it is a useful pattern to support.
|
|
77
|
-
# It also unifies GraphQLCase with its Open API counterpart where bytes may come from external examples
|
|
78
|
-
if isinstance(self.body, bytes):
|
|
79
|
-
kwargs["data"] = self.body
|
|
80
|
-
# Assume that the payload is JSON, not raw GraphQL queries
|
|
81
|
-
kwargs["headers"].setdefault("Content-Type", "application/json")
|
|
82
|
-
else:
|
|
83
|
-
kwargs["json"] = {"query": self.body}
|
|
84
|
-
return kwargs
|
|
85
|
-
|
|
86
|
-
def as_werkzeug_kwargs(self, headers: dict[str, str] | None = None) -> dict[str, Any]:
|
|
87
|
-
final_headers = self._get_headers(headers)
|
|
88
|
-
return {
|
|
89
|
-
"method": self.method,
|
|
90
|
-
"path": self.operation.schema.get_full_path(self.formatted_path),
|
|
91
|
-
# Convert to a regular dictionary, as we use `CaseInsensitiveDict` which is not supported by Werkzeug
|
|
92
|
-
"headers": dict(final_headers),
|
|
93
|
-
"query_string": self.query,
|
|
94
|
-
"json": {"query": self.body},
|
|
95
|
-
}
|
|
67
|
+
return urlunsplit(parts)
|
|
68
|
+
|
|
69
|
+
def _get_body(self) -> Body | NotSet:
|
|
70
|
+
return self.body if isinstance(self.body, (NotSet, bytes)) else {"query": self.body}
|
|
96
71
|
|
|
97
72
|
def validate_response(
|
|
98
73
|
self,
|
|
@@ -107,15 +82,6 @@ class GraphQLCase(Case):
|
|
|
107
82
|
checks = tuple(check for check in checks if check not in excluded_checks)
|
|
108
83
|
return super().validate_response(response, checks, code_sample_style=code_sample_style)
|
|
109
84
|
|
|
110
|
-
def call_asgi(
|
|
111
|
-
self,
|
|
112
|
-
app: Any = None,
|
|
113
|
-
base_url: str | None = None,
|
|
114
|
-
headers: dict[str, str] | None = None,
|
|
115
|
-
**kwargs: Any,
|
|
116
|
-
) -> requests.Response:
|
|
117
|
-
return super().call_asgi(app=app, base_url=base_url, headers=headers, **kwargs)
|
|
118
|
-
|
|
119
85
|
|
|
120
86
|
C = TypeVar("C", bound=Case)
|
|
121
87
|
|
|
@@ -287,7 +253,7 @@ class GraphQLSchema(BaseSchema):
|
|
|
287
253
|
cookies=cookies,
|
|
288
254
|
query=query,
|
|
289
255
|
body=body,
|
|
290
|
-
media_type=media_type,
|
|
256
|
+
media_type=media_type or "application/json",
|
|
291
257
|
generation_time=0.0,
|
|
292
258
|
)
|
|
293
259
|
|
|
@@ -373,6 +339,7 @@ def get_case_strategy(
|
|
|
373
339
|
operation=operation,
|
|
374
340
|
data_generation_method=data_generation_method,
|
|
375
341
|
generation_time=time.monotonic() - start,
|
|
342
|
+
media_type="application/json",
|
|
376
343
|
) # type: ignore
|
|
377
344
|
context = auths.AuthContext(
|
|
378
345
|
operation=operation,
|
|
@@ -304,6 +304,7 @@ def from_dict(
|
|
|
304
304
|
:param dict raw_schema: A schema to load.
|
|
305
305
|
"""
|
|
306
306
|
from .schemas import OpenApi30, SwaggerV20
|
|
307
|
+
from ... import transports
|
|
307
308
|
|
|
308
309
|
if not isinstance(raw_schema, dict):
|
|
309
310
|
raise SchemaError(SchemaErrorType.OPEN_API_INVALID_SCHEMA, SCHEMA_INVALID_ERROR)
|
|
@@ -338,6 +339,7 @@ def from_dict(
|
|
|
338
339
|
location=location,
|
|
339
340
|
rate_limiter=rate_limiter,
|
|
340
341
|
sanitize_output=sanitize_output,
|
|
342
|
+
transport=transports.get(app),
|
|
341
343
|
)
|
|
342
344
|
dispatch("after_load_schema", hook_context, instance)
|
|
343
345
|
return instance
|
|
@@ -379,6 +381,7 @@ def from_dict(
|
|
|
379
381
|
location=location,
|
|
380
382
|
rate_limiter=rate_limiter,
|
|
381
383
|
sanitize_output=sanitize_output,
|
|
384
|
+
transport=transports.get(app),
|
|
382
385
|
)
|
|
383
386
|
dispatch("after_load_schema", hook_context, instance)
|
|
384
387
|
return instance
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import time
|
|
4
4
|
import re
|
|
5
5
|
from dataclasses import dataclass
|
|
6
|
-
from typing import TYPE_CHECKING, Any,
|
|
6
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
7
7
|
|
|
8
8
|
from hypothesis.errors import InvalidDefinition
|
|
9
9
|
from hypothesis.stateful import RuleBasedStateMachine
|
|
@@ -189,13 +189,12 @@ class APIStateMachine(RuleBasedStateMachine):
|
|
|
189
189
|
:return: Response from the application under test.
|
|
190
190
|
|
|
191
191
|
Note that WSGI/ASGI applications are detected automatically in this method. Depending on the result of this
|
|
192
|
-
detection the state machine will call ``call
|
|
192
|
+
detection the state machine will call the ``call`` method.
|
|
193
193
|
|
|
194
194
|
Usually, you don't need to override this method unless you are building a different state machine on top of this
|
|
195
195
|
one and want to customize the transport layer itself.
|
|
196
196
|
"""
|
|
197
|
-
|
|
198
|
-
return method(**kwargs)
|
|
197
|
+
return case.call(**kwargs)
|
|
199
198
|
|
|
200
199
|
def get_call_kwargs(self, case: Case) -> dict[str, Any]:
|
|
201
200
|
"""Create custom keyword arguments that will be passed to the :meth:`Case.call` method.
|
|
@@ -214,15 +213,6 @@ class APIStateMachine(RuleBasedStateMachine):
|
|
|
214
213
|
"""
|
|
215
214
|
return {}
|
|
216
215
|
|
|
217
|
-
def _get_call_method(self, case: Case) -> Callable:
|
|
218
|
-
if case.app is not None:
|
|
219
|
-
from starlette.applications import Starlette
|
|
220
|
-
|
|
221
|
-
if isinstance(case.app, Starlette):
|
|
222
|
-
return case.call_asgi
|
|
223
|
-
return case.call_wsgi
|
|
224
|
-
return case.call
|
|
225
|
-
|
|
226
216
|
def validate_response(
|
|
227
217
|
self, response: GenericResponse, case: Case, additional_checks: tuple[CheckFunction, ...] = ()
|
|
228
218
|
) -> None:
|
|
@@ -1,5 +1,311 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import base64
|
|
4
|
+
import time
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Generator, Protocol, TypeVar, cast
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
from .. import failures
|
|
12
|
+
from .._dependency_versions import IS_WERKZEUG_ABOVE_3
|
|
13
|
+
from ..constants import DEFAULT_RESPONSE_TIMEOUT
|
|
14
|
+
from ..exceptions import get_timeout_error
|
|
15
|
+
from ..serializers import SerializerContext
|
|
16
|
+
from ..types import Cookies, NotSet
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
import requests
|
|
20
|
+
import werkzeug
|
|
21
|
+
from _typeshed.wsgi import WSGIApplication
|
|
22
|
+
from starlette_testclient._testclient import ASGI2App, ASGI3App
|
|
23
|
+
|
|
24
|
+
from ..models import Case
|
|
25
|
+
from .responses import WSGIResponse
|
|
2
26
|
|
|
3
27
|
|
|
4
28
|
def serialize_payload(payload: bytes) -> str:
|
|
5
29
|
return base64.b64encode(payload).decode()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get(app: Any) -> Transport:
|
|
33
|
+
"""Get transport to send the data to the application."""
|
|
34
|
+
from starlette.applications import Starlette
|
|
35
|
+
|
|
36
|
+
if app is None:
|
|
37
|
+
return RequestsTransport()
|
|
38
|
+
if isinstance(app, Starlette):
|
|
39
|
+
return ASGITransport(app=app)
|
|
40
|
+
return WSGITransport(app=app)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
S = TypeVar("S", contravariant=True)
|
|
44
|
+
R = TypeVar("R", covariant=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Transport(Protocol[S, R]):
|
|
48
|
+
def serialize_case(
|
|
49
|
+
self,
|
|
50
|
+
case: Case,
|
|
51
|
+
*,
|
|
52
|
+
base_url: str | None = None,
|
|
53
|
+
headers: dict[str, Any] | None = None,
|
|
54
|
+
params: dict[str, Any] | None = None,
|
|
55
|
+
cookies: dict[str, Any] | None = None,
|
|
56
|
+
) -> dict[str, Any]:
|
|
57
|
+
raise NotImplementedError
|
|
58
|
+
|
|
59
|
+
def send(
|
|
60
|
+
self,
|
|
61
|
+
case: Case,
|
|
62
|
+
*,
|
|
63
|
+
session: S | None = None,
|
|
64
|
+
base_url: str | None = None,
|
|
65
|
+
headers: dict[str, Any] | None = None,
|
|
66
|
+
params: dict[str, Any] | None = None,
|
|
67
|
+
cookies: dict[str, Any] | None = None,
|
|
68
|
+
**kwargs: Any,
|
|
69
|
+
) -> R:
|
|
70
|
+
raise NotImplementedError
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class RequestsTransport:
|
|
74
|
+
def serialize_case(
|
|
75
|
+
self,
|
|
76
|
+
case: Case,
|
|
77
|
+
*,
|
|
78
|
+
base_url: str | None = None,
|
|
79
|
+
headers: dict[str, Any] | None = None,
|
|
80
|
+
params: dict[str, Any] | None = None,
|
|
81
|
+
cookies: dict[str, Any] | None = None,
|
|
82
|
+
) -> dict[str, Any]:
|
|
83
|
+
final_headers = case._get_headers(headers)
|
|
84
|
+
if case.media_type and case.media_type != "multipart/form-data" and not isinstance(case.body, NotSet):
|
|
85
|
+
# `requests` will handle multipart form headers with the proper `boundary` value.
|
|
86
|
+
if "content-type" not in final_headers:
|
|
87
|
+
final_headers["Content-Type"] = case.media_type
|
|
88
|
+
url = case._get_url(base_url)
|
|
89
|
+
serializer = case._get_serializer()
|
|
90
|
+
if serializer is not None and not isinstance(case.body, NotSet):
|
|
91
|
+
context = SerializerContext(case=case)
|
|
92
|
+
extra = serializer.as_requests(context, case._get_body())
|
|
93
|
+
else:
|
|
94
|
+
extra = {}
|
|
95
|
+
if case._auth is not None:
|
|
96
|
+
extra["auth"] = case._auth
|
|
97
|
+
additional_headers = extra.pop("headers", None)
|
|
98
|
+
if additional_headers:
|
|
99
|
+
# Additional headers, needed for the serializer
|
|
100
|
+
for key, value in additional_headers.items():
|
|
101
|
+
final_headers.setdefault(key, value)
|
|
102
|
+
data = {
|
|
103
|
+
"method": case.method,
|
|
104
|
+
"url": url,
|
|
105
|
+
"cookies": case.cookies,
|
|
106
|
+
"headers": final_headers,
|
|
107
|
+
"params": case.query,
|
|
108
|
+
**extra,
|
|
109
|
+
}
|
|
110
|
+
if params is not None:
|
|
111
|
+
_merge_dict_to(data, "params", params)
|
|
112
|
+
if cookies is not None:
|
|
113
|
+
_merge_dict_to(data, "cookies", cookies)
|
|
114
|
+
return data
|
|
115
|
+
|
|
116
|
+
def send(
|
|
117
|
+
self,
|
|
118
|
+
case: Case,
|
|
119
|
+
*,
|
|
120
|
+
session: requests.Session | None = None,
|
|
121
|
+
base_url: str | None = None,
|
|
122
|
+
headers: dict[str, Any] | None = None,
|
|
123
|
+
params: dict[str, Any] | None = None,
|
|
124
|
+
cookies: dict[str, Any] | None = None,
|
|
125
|
+
**kwargs: Any,
|
|
126
|
+
) -> requests.Response:
|
|
127
|
+
import requests
|
|
128
|
+
from urllib3.exceptions import ReadTimeoutError
|
|
129
|
+
|
|
130
|
+
data = self.serialize_case(case, base_url=base_url, headers=headers, params=params, cookies=cookies)
|
|
131
|
+
data.update(kwargs)
|
|
132
|
+
data.setdefault("timeout", DEFAULT_RESPONSE_TIMEOUT / 1000)
|
|
133
|
+
if session is None:
|
|
134
|
+
validate_vanilla_requests_kwargs(data)
|
|
135
|
+
session = requests.Session()
|
|
136
|
+
close_session = True
|
|
137
|
+
else:
|
|
138
|
+
close_session = False
|
|
139
|
+
verify = data.get("verify", True)
|
|
140
|
+
try:
|
|
141
|
+
with case.operation.schema.ratelimit():
|
|
142
|
+
response = session.request(**data) # type: ignore
|
|
143
|
+
except (requests.Timeout, requests.ConnectionError) as exc:
|
|
144
|
+
if isinstance(exc, requests.ConnectionError):
|
|
145
|
+
if not isinstance(exc.args[0], ReadTimeoutError):
|
|
146
|
+
raise
|
|
147
|
+
req = requests.Request(
|
|
148
|
+
method=data["method"].upper(),
|
|
149
|
+
url=data["url"],
|
|
150
|
+
headers=data["headers"],
|
|
151
|
+
files=data.get("files"),
|
|
152
|
+
data=data.get("data") or {},
|
|
153
|
+
json=data.get("json"),
|
|
154
|
+
params=data.get("params") or {},
|
|
155
|
+
auth=data.get("auth"),
|
|
156
|
+
cookies=data["cookies"],
|
|
157
|
+
hooks=data.get("hooks"),
|
|
158
|
+
)
|
|
159
|
+
request = session.prepare_request(req)
|
|
160
|
+
else:
|
|
161
|
+
request = cast(requests.PreparedRequest, exc.request)
|
|
162
|
+
timeout = 1000 * data["timeout"] # It is defined and not empty, since the exception happened
|
|
163
|
+
code_message = case._get_code_message(case.operation.schema.code_sample_style, request, verify=verify)
|
|
164
|
+
message = f"The server failed to respond within the specified limit of {timeout:.2f}ms"
|
|
165
|
+
raise get_timeout_error(timeout)(
|
|
166
|
+
f"\n\n1. {failures.RequestTimeout.title}\n\n{message}\n\n{code_message}",
|
|
167
|
+
context=failures.RequestTimeout(message=message, timeout=timeout),
|
|
168
|
+
) from None
|
|
169
|
+
response.verify = verify # type: ignore[attr-defined]
|
|
170
|
+
if close_session:
|
|
171
|
+
session.close()
|
|
172
|
+
return response
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _merge_dict_to(data: dict[str, Any], data_key: str, new: dict[str, Any]) -> None:
|
|
176
|
+
original = data[data_key] or {}
|
|
177
|
+
for key, value in new.items():
|
|
178
|
+
original[key] = value
|
|
179
|
+
data[data_key] = original
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def validate_vanilla_requests_kwargs(data: dict[str, Any]) -> None:
|
|
183
|
+
"""Check arguments for `requests.Session.request`.
|
|
184
|
+
|
|
185
|
+
Some arguments can be valid for cases like ASGI integration, but at the same time they won't work for the regular
|
|
186
|
+
`requests` calls. In such cases we need to avoid an obscure error message, that comes from `requests`.
|
|
187
|
+
"""
|
|
188
|
+
url = data["url"]
|
|
189
|
+
if not urlparse(url).netloc:
|
|
190
|
+
raise RuntimeError(
|
|
191
|
+
"The URL should be absolute, so Schemathesis knows where to send the data. \n"
|
|
192
|
+
f"If you use the ASGI integration, please supply your test client "
|
|
193
|
+
f"as the `session` argument to `call`.\nURL: {url}"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass
|
|
198
|
+
class ASGITransport(RequestsTransport):
|
|
199
|
+
app: ASGI2App | ASGI3App
|
|
200
|
+
|
|
201
|
+
def send(
|
|
202
|
+
self,
|
|
203
|
+
case: Case,
|
|
204
|
+
*,
|
|
205
|
+
session: requests.Session | None = None,
|
|
206
|
+
base_url: str | None = None,
|
|
207
|
+
headers: dict[str, Any] | None = None,
|
|
208
|
+
params: dict[str, Any] | None = None,
|
|
209
|
+
cookies: dict[str, Any] | None = None,
|
|
210
|
+
**kwargs: Any,
|
|
211
|
+
) -> requests.Response:
|
|
212
|
+
from starlette_testclient import TestClient as ASGIClient
|
|
213
|
+
|
|
214
|
+
if base_url is None:
|
|
215
|
+
base_url = case.get_full_base_url()
|
|
216
|
+
with ASGIClient(self.app) as client:
|
|
217
|
+
return super().send(
|
|
218
|
+
case, session=client, base_url=base_url, headers=headers, params=params, cookies=cookies, **kwargs
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass
|
|
223
|
+
class WSGITransport:
|
|
224
|
+
app: WSGIApplication
|
|
225
|
+
|
|
226
|
+
def serialize_case(
|
|
227
|
+
self,
|
|
228
|
+
case: Case,
|
|
229
|
+
*,
|
|
230
|
+
base_url: str | None = None,
|
|
231
|
+
headers: dict[str, Any] | None = None,
|
|
232
|
+
params: dict[str, Any] | None = None,
|
|
233
|
+
cookies: dict[str, Any] | None = None,
|
|
234
|
+
) -> dict[str, Any]:
|
|
235
|
+
final_headers = case._get_headers(headers)
|
|
236
|
+
if case.media_type and not isinstance(case.body, NotSet):
|
|
237
|
+
# If we need to send a payload, then the Content-Type header should be set
|
|
238
|
+
final_headers["Content-Type"] = case.media_type
|
|
239
|
+
extra: dict[str, Any]
|
|
240
|
+
serializer = case._get_serializer()
|
|
241
|
+
if serializer is not None and not isinstance(case.body, NotSet):
|
|
242
|
+
context = SerializerContext(case=case)
|
|
243
|
+
extra = serializer.as_werkzeug(context, case._get_body())
|
|
244
|
+
else:
|
|
245
|
+
extra = {}
|
|
246
|
+
data = {
|
|
247
|
+
"method": case.method,
|
|
248
|
+
"path": case.operation.schema.get_full_path(case.formatted_path),
|
|
249
|
+
# Convert to a regular dictionary, as we use `CaseInsensitiveDict` which is not supported by Werkzeug
|
|
250
|
+
"headers": dict(final_headers),
|
|
251
|
+
"query_string": case.query,
|
|
252
|
+
**extra,
|
|
253
|
+
}
|
|
254
|
+
if params is not None:
|
|
255
|
+
_merge_dict_to(data, "query_string", params)
|
|
256
|
+
return data
|
|
257
|
+
|
|
258
|
+
def send(
|
|
259
|
+
self,
|
|
260
|
+
case: Case,
|
|
261
|
+
*,
|
|
262
|
+
session: Any = None,
|
|
263
|
+
base_url: str | None = None,
|
|
264
|
+
headers: dict[str, Any] | None = None,
|
|
265
|
+
params: dict[str, Any] | None = None,
|
|
266
|
+
cookies: dict[str, Any] | None = None,
|
|
267
|
+
**kwargs: Any,
|
|
268
|
+
) -> WSGIResponse:
|
|
269
|
+
import requests
|
|
270
|
+
import werkzeug
|
|
271
|
+
|
|
272
|
+
from .responses import WSGIResponse
|
|
273
|
+
|
|
274
|
+
application = kwargs.pop("app", self.app) or self.app
|
|
275
|
+
data = self.serialize_case(case, headers=headers, params=params)
|
|
276
|
+
data.update(kwargs)
|
|
277
|
+
client = werkzeug.Client(application, WSGIResponse)
|
|
278
|
+
cookies = {**(case.cookies or {}), **(cookies or {})}
|
|
279
|
+
with cookie_handler(client, cookies), case.operation.schema.ratelimit():
|
|
280
|
+
start = time.monotonic()
|
|
281
|
+
response = client.open(**data)
|
|
282
|
+
elapsed = time.monotonic() - start
|
|
283
|
+
requests_kwargs = RequestsTransport().serialize_case(
|
|
284
|
+
case,
|
|
285
|
+
base_url=case.get_full_base_url(),
|
|
286
|
+
headers=headers,
|
|
287
|
+
params=params,
|
|
288
|
+
cookies=cookies,
|
|
289
|
+
)
|
|
290
|
+
response.request = requests.Request(**requests_kwargs).prepare()
|
|
291
|
+
response.elapsed = timedelta(seconds=elapsed)
|
|
292
|
+
return response
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@contextmanager
|
|
296
|
+
def cookie_handler(client: werkzeug.Client, cookies: Cookies | None) -> Generator[None, None, None]:
|
|
297
|
+
"""Set cookies required for a call."""
|
|
298
|
+
if not cookies:
|
|
299
|
+
yield
|
|
300
|
+
else:
|
|
301
|
+
for key, value in cookies.items():
|
|
302
|
+
if IS_WERKZEUG_ABOVE_3:
|
|
303
|
+
client.set_cookie(key=key, value=value, domain="localhost")
|
|
304
|
+
else:
|
|
305
|
+
client.set_cookie("localhost", key=key, value=value)
|
|
306
|
+
yield
|
|
307
|
+
for key in cookies:
|
|
308
|
+
if IS_WERKZEUG_ABOVE_3:
|
|
309
|
+
client.delete_cookie(key=key, domain="localhost")
|
|
310
|
+
else:
|
|
311
|
+
client.delete_cookie("localhost", key=key)
|