schemathesis 3.19.7__py3-none-any.whl → 3.20.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- schemathesis/_compat.py +3 -2
- schemathesis/_hypothesis.py +21 -6
- schemathesis/_xml.py +177 -0
- schemathesis/auths.py +48 -10
- schemathesis/cli/__init__.py +77 -19
- schemathesis/cli/callbacks.py +42 -18
- schemathesis/cli/context.py +2 -1
- schemathesis/cli/output/default.py +102 -34
- schemathesis/cli/sanitization.py +15 -0
- schemathesis/code_samples.py +141 -0
- schemathesis/constants.py +1 -24
- schemathesis/exceptions.py +127 -26
- schemathesis/experimental/__init__.py +85 -0
- schemathesis/extra/pytest_plugin.py +10 -4
- schemathesis/fixups/__init__.py +8 -2
- schemathesis/fixups/fast_api.py +11 -1
- schemathesis/fixups/utf8_bom.py +7 -1
- schemathesis/hooks.py +63 -0
- schemathesis/lazy.py +10 -4
- schemathesis/loaders.py +57 -0
- schemathesis/models.py +120 -96
- schemathesis/parameters.py +3 -0
- schemathesis/runner/__init__.py +3 -0
- schemathesis/runner/events.py +55 -20
- schemathesis/runner/impl/core.py +54 -54
- schemathesis/runner/serialization.py +75 -34
- schemathesis/sanitization.py +248 -0
- schemathesis/schemas.py +21 -6
- schemathesis/serializers.py +32 -3
- schemathesis/service/serialization.py +5 -1
- schemathesis/specs/graphql/loaders.py +44 -13
- schemathesis/specs/graphql/schemas.py +56 -25
- schemathesis/specs/openapi/_hypothesis.py +11 -23
- schemathesis/specs/openapi/definitions.py +572 -0
- schemathesis/specs/openapi/loaders.py +100 -49
- schemathesis/specs/openapi/parameters.py +2 -2
- schemathesis/specs/openapi/schemas.py +87 -13
- schemathesis/specs/openapi/security.py +1 -0
- schemathesis/stateful.py +2 -2
- schemathesis/utils.py +30 -9
- schemathesis-3.20.1.dist-info/METADATA +342 -0
- {schemathesis-3.19.7.dist-info → schemathesis-3.20.1.dist-info}/RECORD +45 -39
- schemathesis-3.19.7.dist-info/METADATA +0 -291
- {schemathesis-3.19.7.dist-info → schemathesis-3.20.1.dist-info}/WHEEL +0 -0
- {schemathesis-3.19.7.dist-info → schemathesis-3.20.1.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.19.7.dist-info → schemathesis-3.20.1.dist-info}/licenses/LICENSE +0 -0
schemathesis/models.py
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import base64
|
|
2
2
|
import datetime
|
|
3
3
|
import http
|
|
4
|
+
import inspect
|
|
4
5
|
from collections import Counter
|
|
5
6
|
from contextlib import contextmanager
|
|
6
7
|
from dataclasses import dataclass, field
|
|
7
8
|
from enum import Enum
|
|
9
|
+
from functools import partial
|
|
8
10
|
from itertools import chain
|
|
9
11
|
from logging import LogRecord
|
|
10
12
|
from typing import (
|
|
@@ -26,9 +28,7 @@ from typing import (
|
|
|
26
28
|
cast,
|
|
27
29
|
)
|
|
28
30
|
from urllib.parse import quote, unquote, urljoin, urlparse, urlsplit, urlunsplit
|
|
29
|
-
from uuid import uuid4
|
|
30
31
|
|
|
31
|
-
import curlify
|
|
32
32
|
import requests.auth
|
|
33
33
|
import werkzeug
|
|
34
34
|
from hypothesis import event, note, reject
|
|
@@ -37,19 +37,20 @@ from requests.structures import CaseInsensitiveDict
|
|
|
37
37
|
from starlette_testclient import TestClient as ASGIClient
|
|
38
38
|
|
|
39
39
|
from . import failures, serializers
|
|
40
|
+
from ._compat import IS_WERKZEUG_ABOVE_3
|
|
40
41
|
from .auths import AuthStorage
|
|
42
|
+
from .code_samples import CodeSampleStyle
|
|
41
43
|
from .constants import (
|
|
42
44
|
DEFAULT_RESPONSE_TIMEOUT,
|
|
43
45
|
SCHEMATHESIS_TEST_CASE_HEADER,
|
|
44
46
|
SERIALIZERS_SUGGESTION_MESSAGE,
|
|
45
47
|
USER_AGENT,
|
|
46
|
-
CodeSampleStyle,
|
|
47
48
|
DataGenerationMethod,
|
|
48
49
|
)
|
|
49
50
|
from .exceptions import (
|
|
50
51
|
CheckFailed,
|
|
51
52
|
FailureContext,
|
|
52
|
-
|
|
53
|
+
OperationSchemaError,
|
|
53
54
|
SerializationNotPossible,
|
|
54
55
|
deduplicate_failed_checks,
|
|
55
56
|
get_grouped_exception,
|
|
@@ -57,16 +58,17 @@ from .exceptions import (
|
|
|
57
58
|
)
|
|
58
59
|
from .hooks import GLOBAL_HOOK_DISPATCHER, HookContext, HookDispatcher, dispatch
|
|
59
60
|
from .parameters import Parameter, ParameterSet, PayloadAlternatives
|
|
61
|
+
from .sanitization import sanitize_request, sanitize_response
|
|
60
62
|
from .serializers import Serializer, SerializerContext
|
|
61
63
|
from .types import Body, Cookies, FormData, Headers, NotSet, PathParameters, Query
|
|
62
64
|
from .utils import (
|
|
63
|
-
IGNORED_HEADERS,
|
|
64
65
|
NOT_SET,
|
|
65
66
|
GenericResponse,
|
|
66
67
|
WSGIResponse,
|
|
67
68
|
copy_response,
|
|
68
69
|
deprecated_property,
|
|
69
70
|
fast_deepcopy,
|
|
71
|
+
generate_random_case_id,
|
|
70
72
|
get_response_payload,
|
|
71
73
|
maybe_set_assertion_message,
|
|
72
74
|
)
|
|
@@ -98,11 +100,33 @@ def cant_serialize(media_type: str) -> NoReturn: # type: ignore
|
|
|
98
100
|
reject() # type: ignore
|
|
99
101
|
|
|
100
102
|
|
|
103
|
+
REQUEST_SIGNATURE = inspect.signature(requests.Request)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass()
|
|
107
|
+
class PreparedRequestData:
|
|
108
|
+
method: str
|
|
109
|
+
url: str
|
|
110
|
+
body: Optional[Union[str, bytes]]
|
|
111
|
+
headers: Headers
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def prepare_request_data(kwargs: Dict[str, Any]) -> PreparedRequestData:
|
|
115
|
+
"""Prepare request data for generating code samples."""
|
|
116
|
+
kwargs = {key: value for key, value in kwargs.items() if key in REQUEST_SIGNATURE.parameters}
|
|
117
|
+
request = requests.Request(**kwargs).prepare()
|
|
118
|
+
return PreparedRequestData(
|
|
119
|
+
method=str(request.method), url=str(request.url), body=request.body, headers=dict(request.headers)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
101
123
|
@dataclass(repr=False)
|
|
102
124
|
class Case:
|
|
103
125
|
"""A single test case parameters."""
|
|
104
126
|
|
|
105
127
|
operation: "APIOperation"
|
|
128
|
+
# Unique test case identifier
|
|
129
|
+
id: str = field(default_factory=generate_random_case_id, compare=False)
|
|
106
130
|
path_parameters: Optional[PathParameters] = None
|
|
107
131
|
headers: Optional[CaseInsensitiveDict] = None
|
|
108
132
|
cookies: Optional[Cookies] = None
|
|
@@ -110,14 +134,12 @@ class Case:
|
|
|
110
134
|
# By default, there is no body, but we can't use `None` as the default value because it clashes with `null`
|
|
111
135
|
# which is a valid payload.
|
|
112
136
|
body: Union[Body, NotSet] = NOT_SET
|
|
113
|
-
|
|
114
|
-
source: Optional[CaseSource] = None
|
|
115
137
|
# The media type for cases with a payload. For example, "application/json"
|
|
116
138
|
media_type: Optional[str] = None
|
|
139
|
+
source: Optional[CaseSource] = None
|
|
140
|
+
|
|
117
141
|
# The way the case was generated (None for manually crafted ones)
|
|
118
142
|
data_generation_method: Optional[DataGenerationMethod] = None
|
|
119
|
-
# Unique test case identifier
|
|
120
|
-
id: str = field(default_factory=lambda: uuid4().hex, compare=False)
|
|
121
143
|
_auth: Optional[requests.auth.AuthBase] = None
|
|
122
144
|
|
|
123
145
|
def __repr__(self) -> str:
|
|
@@ -171,10 +193,10 @@ class Case:
|
|
|
171
193
|
# This may happen when a path template has a placeholder for variable "X", but parameter "X" is not defined
|
|
172
194
|
# in the parameters list.
|
|
173
195
|
# When `exc` is formatted, it is the missing key name in quotes. E.g. 'id'
|
|
174
|
-
raise
|
|
196
|
+
raise OperationSchemaError(f"Path parameter {exc} is not defined") from exc
|
|
175
197
|
except ValueError as exc:
|
|
176
198
|
# A single unmatched `}` inside the path template may cause this
|
|
177
|
-
raise
|
|
199
|
+
raise OperationSchemaError(f"Malformed path template: `{self.path}`\n\n {exc}") from exc
|
|
178
200
|
|
|
179
201
|
def get_full_base_url(self) -> Optional[str]:
|
|
180
202
|
"""Create a full base url, adding "localhost" for WSGI apps."""
|
|
@@ -184,59 +206,49 @@ class Case:
|
|
|
184
206
|
return urlunsplit(("http", "localhost", path or "", "", ""))
|
|
185
207
|
return self.base_url
|
|
186
208
|
|
|
209
|
+
def prepare_code_sample_data(self, headers: Optional[Dict[str, Any]]) -> PreparedRequestData:
|
|
210
|
+
base_url = self.get_full_base_url()
|
|
211
|
+
kwargs = self.as_requests_kwargs(base_url, headers=headers)
|
|
212
|
+
return prepare_request_data(kwargs)
|
|
213
|
+
|
|
187
214
|
def get_code_to_reproduce(
|
|
188
|
-
self,
|
|
215
|
+
self,
|
|
216
|
+
headers: Optional[Dict[str, Any]] = None,
|
|
217
|
+
request: Optional[requests.PreparedRequest] = None,
|
|
218
|
+
verify: bool = True,
|
|
189
219
|
) -> str:
|
|
190
220
|
"""Construct a Python code to reproduce this case with `requests`."""
|
|
191
221
|
if request is not None:
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
222
|
+
request_data = prepare_request_data(
|
|
223
|
+
{
|
|
224
|
+
"method": request.method,
|
|
225
|
+
"url": request.url,
|
|
226
|
+
"headers": request.headers,
|
|
227
|
+
"data": request.body,
|
|
228
|
+
}
|
|
229
|
+
)
|
|
198
230
|
else:
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
def should_display(key: str, value: Any) -> bool:
|
|
209
|
-
if key in ("method", "url"):
|
|
210
|
-
return False
|
|
211
|
-
# Parameters are either absent because they are not defined or are optional
|
|
212
|
-
return value not in (None, {})
|
|
213
|
-
|
|
214
|
-
printed_kwargs = ", ".join(
|
|
215
|
-
f"{key}={repr(value)}" for key, value in kwargs.items() if should_display(key, value)
|
|
231
|
+
request_data = self.prepare_code_sample_data(headers)
|
|
232
|
+
return CodeSampleStyle.python.generate(
|
|
233
|
+
method=request_data.method,
|
|
234
|
+
url=request_data.url,
|
|
235
|
+
body=request_data.body,
|
|
236
|
+
headers=dict(self.headers) if self.headers is not None else None,
|
|
237
|
+
verify=verify,
|
|
238
|
+
extra_headers=request_data.headers,
|
|
216
239
|
)
|
|
217
|
-
url = _escape_single_quotes(kwargs["url"])
|
|
218
|
-
args_repr = f"'{url}'"
|
|
219
|
-
if printed_kwargs:
|
|
220
|
-
args_repr += f", {printed_kwargs}"
|
|
221
|
-
return f"requests.{method}({args_repr})"
|
|
222
240
|
|
|
223
|
-
def as_curl_command(self, headers: Optional[Dict[str, Any]] = None) -> str:
|
|
241
|
+
def as_curl_command(self, headers: Optional[Dict[str, Any]] = None, verify: bool = True) -> str:
|
|
224
242
|
"""Construct a curl command for a given case."""
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
# Note, it may be not sufficient to reproduce the error :(
|
|
235
|
-
prepared.body = prepared.body.decode("utf-8", errors="replace")
|
|
236
|
-
for header in tuple(prepared.headers):
|
|
237
|
-
if header in IGNORED_HEADERS and header not in (self.headers or {}):
|
|
238
|
-
del prepared.headers[header]
|
|
239
|
-
return curlify.to_curl(prepared)
|
|
243
|
+
request_data = self.prepare_code_sample_data(headers)
|
|
244
|
+
return CodeSampleStyle.curl.generate(
|
|
245
|
+
method=request_data.method,
|
|
246
|
+
url=request_data.url,
|
|
247
|
+
body=request_data.body,
|
|
248
|
+
headers=dict(self.headers) if self.headers is not None else None,
|
|
249
|
+
verify=verify,
|
|
250
|
+
extra_headers=request_data.headers,
|
|
251
|
+
)
|
|
240
252
|
|
|
241
253
|
def _get_base_url(self, base_url: Optional[str] = None) -> str:
|
|
242
254
|
if base_url is None:
|
|
@@ -332,16 +344,19 @@ class Case:
|
|
|
332
344
|
close_session = True
|
|
333
345
|
else:
|
|
334
346
|
close_session = False
|
|
347
|
+
verify = data.get("verify", True)
|
|
335
348
|
try:
|
|
336
349
|
with self.operation.schema.ratelimit():
|
|
337
350
|
response = session.request(**data) # type: ignore
|
|
338
351
|
except requests.Timeout as exc:
|
|
339
352
|
timeout = 1000 * data["timeout"] # It is defined and not empty, since the exception happened
|
|
340
|
-
|
|
353
|
+
request = cast(requests.PreparedRequest, exc.request)
|
|
354
|
+
code_message = self._get_code_message(self.operation.schema.code_sample_style, request, verify=verify)
|
|
341
355
|
raise get_timeout_error(timeout)(
|
|
342
356
|
f"\n\n1. Request timed out after {timeout:.2f}ms\n\n----------\n\n{code_message}",
|
|
343
357
|
context=failures.RequestTimeout(timeout=timeout),
|
|
344
358
|
) from None
|
|
359
|
+
response.verify = verify # type: ignore[attr-defined]
|
|
345
360
|
dispatch("after_call", hook_context, self, response)
|
|
346
361
|
if close_session:
|
|
347
362
|
session.close()
|
|
@@ -410,9 +425,9 @@ class Case:
|
|
|
410
425
|
)
|
|
411
426
|
if base_url is None:
|
|
412
427
|
base_url = self.get_full_base_url()
|
|
413
|
-
client = ASGIClient(application)
|
|
414
428
|
|
|
415
|
-
|
|
429
|
+
with ASGIClient(application) as client:
|
|
430
|
+
return self.call(base_url=base_url, session=client, headers=headers, **kwargs)
|
|
416
431
|
|
|
417
432
|
def validate_response(
|
|
418
433
|
self,
|
|
@@ -430,6 +445,7 @@ class Case:
|
|
|
430
445
|
:param checks: A tuple of check functions that accept ``response`` and ``case``.
|
|
431
446
|
:param additional_checks: A tuple of additional checks that will be executed after ones from the ``checks``
|
|
432
447
|
argument.
|
|
448
|
+
:param excluded_checks: Checks excluded from the default ones.
|
|
433
449
|
:param code_sample_style: Controls the style of code samples for failure reproduction.
|
|
434
450
|
"""
|
|
435
451
|
__tracebackhide__ = True
|
|
@@ -456,7 +472,11 @@ class Case:
|
|
|
456
472
|
if code_sample_style is not None
|
|
457
473
|
else self.operation.schema.code_sample_style
|
|
458
474
|
)
|
|
459
|
-
|
|
475
|
+
verify = getattr(response, "verify", True)
|
|
476
|
+
if self.operation.schema.sanitize_output:
|
|
477
|
+
sanitize_request(response.request)
|
|
478
|
+
sanitize_response(response)
|
|
479
|
+
code_message = self._get_code_message(code_sample_style, response.request, verify=verify)
|
|
460
480
|
payload = get_response_payload(response)
|
|
461
481
|
raise exception_cls(
|
|
462
482
|
f"\n\n{formatted_failures}\n\n"
|
|
@@ -467,12 +487,14 @@ class Case:
|
|
|
467
487
|
causes=tuple(failed_checks),
|
|
468
488
|
)
|
|
469
489
|
|
|
470
|
-
def _get_code_message(
|
|
490
|
+
def _get_code_message(
|
|
491
|
+
self, code_sample_style: CodeSampleStyle, request: requests.PreparedRequest, verify: bool
|
|
492
|
+
) -> str:
|
|
471
493
|
if code_sample_style == CodeSampleStyle.python:
|
|
472
|
-
code = self.get_code_to_reproduce(request=request)
|
|
494
|
+
code = self.get_code_to_reproduce(request=request, verify=verify)
|
|
473
495
|
return f"Run this Python code to reproduce this response: \n\n {code}\n"
|
|
474
496
|
if code_sample_style == CodeSampleStyle.curl:
|
|
475
|
-
code = self.as_curl_command(headers=dict(request.headers))
|
|
497
|
+
code = self.as_curl_command(headers=dict(request.headers), verify=verify)
|
|
476
498
|
return f"Run this cURL command to reproduce this response: \n\n {code}\n"
|
|
477
499
|
raise ValueError(f"Unknown code sample style: {code_sample_style.name}")
|
|
478
500
|
|
|
@@ -492,7 +514,7 @@ class Case:
|
|
|
492
514
|
|
|
493
515
|
def get_full_url(self) -> str:
|
|
494
516
|
"""Make a full URL to the current API operation, including query parameters."""
|
|
495
|
-
base_url = self.base_url or "http://
|
|
517
|
+
base_url = self.base_url or "http://127.0.0.1"
|
|
496
518
|
kwargs = self.as_requests_kwargs(base_url)
|
|
497
519
|
request = requests.Request(**kwargs)
|
|
498
520
|
prepared = requests.Session().prepare_request(request) # type: ignore
|
|
@@ -534,27 +556,6 @@ def validate_vanilla_requests_kwargs(data: Dict[str, Any]) -> None:
|
|
|
534
556
|
)
|
|
535
557
|
|
|
536
558
|
|
|
537
|
-
def _escape_single_quotes(url: str) -> str:
|
|
538
|
-
"""Escape single quotes in a string, so it is usable as in generated Python code.
|
|
539
|
-
|
|
540
|
-
The usual ``str.replace`` is not suitable as it may convert already escaped quotes to not-escaped.
|
|
541
|
-
"""
|
|
542
|
-
result = []
|
|
543
|
-
escape = False
|
|
544
|
-
for char in url:
|
|
545
|
-
if escape:
|
|
546
|
-
result.append(char)
|
|
547
|
-
escape = False
|
|
548
|
-
elif char == "\\":
|
|
549
|
-
result.append(char)
|
|
550
|
-
escape = True
|
|
551
|
-
elif char == "'":
|
|
552
|
-
result.append("\\'")
|
|
553
|
-
else:
|
|
554
|
-
result.append(char)
|
|
555
|
-
return "".join(result)
|
|
556
|
-
|
|
557
|
-
|
|
558
559
|
@contextmanager
|
|
559
560
|
def cookie_handler(client: werkzeug.Client, cookies: Optional[Cookies]) -> Generator[None, None, None]:
|
|
560
561
|
"""Set cookies required for a call."""
|
|
@@ -562,10 +563,16 @@ def cookie_handler(client: werkzeug.Client, cookies: Optional[Cookies]) -> Gener
|
|
|
562
563
|
yield
|
|
563
564
|
else:
|
|
564
565
|
for key, value in cookies.items():
|
|
565
|
-
|
|
566
|
+
if IS_WERKZEUG_ABOVE_3:
|
|
567
|
+
client.set_cookie(key=key, value=value, domain="localhost")
|
|
568
|
+
else:
|
|
569
|
+
client.set_cookie("localhost", key=key, value=value)
|
|
566
570
|
yield
|
|
567
571
|
for key in cookies:
|
|
568
|
-
|
|
572
|
+
if IS_WERKZEUG_ABOVE_3:
|
|
573
|
+
client.delete_cookie(key=key, domain="localhost")
|
|
574
|
+
else:
|
|
575
|
+
client.delete_cookie("localhost", key=key)
|
|
569
576
|
|
|
570
577
|
|
|
571
578
|
P = TypeVar("P", bound=Parameter)
|
|
@@ -685,8 +692,18 @@ class APIOperation(Generic[P, C]):
|
|
|
685
692
|
strategy = self.schema.get_case_strategy(self, hooks, auth_storage, data_generation_method, **kwargs)
|
|
686
693
|
|
|
687
694
|
def _apply_hooks(dispatcher: HookDispatcher, _strategy: st.SearchStrategy[Case]) -> st.SearchStrategy[Case]:
|
|
695
|
+
context = HookContext(self)
|
|
688
696
|
for hook in dispatcher.get_all_by_name("before_generate_case"):
|
|
689
|
-
_strategy = hook(
|
|
697
|
+
_strategy = hook(context, _strategy)
|
|
698
|
+
for hook in dispatcher.get_all_by_name("filter_case"):
|
|
699
|
+
hook = partial(hook, context)
|
|
700
|
+
_strategy = _strategy.filter(hook)
|
|
701
|
+
for hook in dispatcher.get_all_by_name("map_case"):
|
|
702
|
+
hook = partial(hook, context)
|
|
703
|
+
_strategy = _strategy.map(hook)
|
|
704
|
+
for hook in dispatcher.get_all_by_name("flatmap_case"):
|
|
705
|
+
hook = partial(hook, context)
|
|
706
|
+
_strategy = _strategy.flatmap(hook)
|
|
690
707
|
return _strategy
|
|
691
708
|
|
|
692
709
|
strategy = _apply_hooks(GLOBAL_HOOK_DISPATCHER, strategy)
|
|
@@ -797,6 +814,12 @@ class APIOperation(Generic[P, C]):
|
|
|
797
814
|
except CheckFailed:
|
|
798
815
|
return False
|
|
799
816
|
|
|
817
|
+
def get_raw_payload_schema(self, media_type: str) -> Optional[Dict[str, Any]]:
|
|
818
|
+
return self.schema._get_payload_schema(self.definition.raw, media_type)
|
|
819
|
+
|
|
820
|
+
def get_resolved_payload_schema(self, media_type: str) -> Optional[Dict[str, Any]]:
|
|
821
|
+
return self.schema._get_payload_schema(self.definition.resolved, media_type)
|
|
822
|
+
|
|
800
823
|
|
|
801
824
|
# backward-compatibility
|
|
802
825
|
Endpoint = APIOperation
|
|
@@ -879,6 +902,7 @@ class Response:
|
|
|
879
902
|
encoding: Optional[str]
|
|
880
903
|
http_version: str
|
|
881
904
|
elapsed: float
|
|
905
|
+
verify: bool
|
|
882
906
|
|
|
883
907
|
@classmethod
|
|
884
908
|
def from_requests(cls, response: requests.Response) -> "Response":
|
|
@@ -902,6 +926,7 @@ class Response:
|
|
|
902
926
|
headers=headers,
|
|
903
927
|
http_version=http_version,
|
|
904
928
|
elapsed=response.elapsed.total_seconds(),
|
|
929
|
+
verify=getattr(response, "verify", True),
|
|
905
930
|
)
|
|
906
931
|
|
|
907
932
|
@classmethod
|
|
@@ -914,7 +939,8 @@ class Response:
|
|
|
914
939
|
body = None if response.response == [] else serialize_payload(data)
|
|
915
940
|
encoding: Optional[str]
|
|
916
941
|
if body is not None:
|
|
917
|
-
|
|
942
|
+
# Werkzeug <3.0 had `charset` attr, newer versions always have UTF-8
|
|
943
|
+
encoding = response.mimetype_params.get("charset", getattr(response, "charset", "utf-8"))
|
|
918
944
|
else:
|
|
919
945
|
encoding = None
|
|
920
946
|
return cls(
|
|
@@ -925,6 +951,7 @@ class Response:
|
|
|
925
951
|
headers=headers,
|
|
926
952
|
http_version="1.1",
|
|
927
953
|
elapsed=elapsed,
|
|
954
|
+
verify=True,
|
|
928
955
|
)
|
|
929
956
|
|
|
930
957
|
|
|
@@ -983,7 +1010,7 @@ class TestResult:
|
|
|
983
1010
|
verbose_name: str
|
|
984
1011
|
data_generation_method: List[DataGenerationMethod]
|
|
985
1012
|
checks: List[Check] = field(default_factory=list)
|
|
986
|
-
errors: List[
|
|
1013
|
+
errors: List[Exception] = field(default_factory=list)
|
|
987
1014
|
interactions: List[Interaction] = field(default_factory=list)
|
|
988
1015
|
logs: List[LogRecord] = field(default_factory=list)
|
|
989
1016
|
is_errored: bool = False
|
|
@@ -991,9 +1018,6 @@ class TestResult:
|
|
|
991
1018
|
is_skipped: bool = False
|
|
992
1019
|
is_executed: bool = False
|
|
993
1020
|
seed: Optional[int] = None
|
|
994
|
-
# To show a proper reproduction code if an error happens and there is no way to get actual headers that were
|
|
995
|
-
# sent over the network. Or there could be no actual requests at all
|
|
996
|
-
overridden_headers: Optional[Dict[str, Any]] = None
|
|
997
1021
|
|
|
998
1022
|
def mark_errored(self) -> None:
|
|
999
1023
|
self.is_errored = True
|
|
@@ -1049,8 +1073,8 @@ class TestResult:
|
|
|
1049
1073
|
self.checks.append(check)
|
|
1050
1074
|
return check
|
|
1051
1075
|
|
|
1052
|
-
def add_error(self, exception: Exception
|
|
1053
|
-
self.errors.append(
|
|
1076
|
+
def add_error(self, exception: Exception) -> None:
|
|
1077
|
+
self.errors.append(exception)
|
|
1054
1078
|
|
|
1055
1079
|
def store_requests_response(
|
|
1056
1080
|
self, case: Case, response: requests.Response, status: Status, checks: List[Check]
|
|
@@ -1076,7 +1100,7 @@ class TestResultSet:
|
|
|
1076
1100
|
__test__ = False
|
|
1077
1101
|
|
|
1078
1102
|
results: List[TestResult] = field(default_factory=list)
|
|
1079
|
-
generic_errors: List[
|
|
1103
|
+
generic_errors: List[OperationSchemaError] = field(default_factory=list)
|
|
1080
1104
|
warnings: List[str] = field(default_factory=list)
|
|
1081
1105
|
|
|
1082
1106
|
def __iter__(self) -> Iterator[TestResult]:
|
schemathesis/parameters.py
CHANGED
|
@@ -67,6 +67,9 @@ class ParameterSet(Generic[P]):
|
|
|
67
67
|
return parameter
|
|
68
68
|
return None
|
|
69
69
|
|
|
70
|
+
def contains(self, name: str) -> bool:
|
|
71
|
+
return self.get(name) is not None
|
|
72
|
+
|
|
70
73
|
@property
|
|
71
74
|
def example(self) -> Dict[str, Any]:
|
|
72
75
|
"""Composite example gathered from individual parameters."""
|
schemathesis/runner/__init__.py
CHANGED
|
@@ -13,6 +13,7 @@ from ..constants import (
|
|
|
13
13
|
HYPOTHESIS_IN_MEMORY_DATABASE_IDENTIFIER,
|
|
14
14
|
DataGenerationMethod,
|
|
15
15
|
)
|
|
16
|
+
from ..exceptions import SchemaError
|
|
16
17
|
from ..models import CheckFunction
|
|
17
18
|
from ..schemas import BaseSchema
|
|
18
19
|
from ..specs.graphql import loaders as gql_loaders
|
|
@@ -236,6 +237,8 @@ def execute_from_schema(
|
|
|
236
237
|
stateful_recursion_limit=stateful_recursion_limit,
|
|
237
238
|
count_operations=count_operations,
|
|
238
239
|
).execute()
|
|
240
|
+
except SchemaError as error:
|
|
241
|
+
yield events.InternalError.from_schema_error(error)
|
|
239
242
|
except Exception as exc:
|
|
240
243
|
yield events.InternalError.from_exc(exc)
|
|
241
244
|
|
schemathesis/runner/events.py
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
+
import enum
|
|
1
2
|
import threading
|
|
2
3
|
import time
|
|
3
4
|
from dataclasses import asdict, dataclass, field
|
|
4
5
|
from typing import Any, Dict, List, Optional, Union
|
|
5
6
|
|
|
6
|
-
from
|
|
7
|
-
|
|
8
|
-
from ..constants import USE_WAIT_FOR_SCHEMA_SUGGESTION_MESSAGE, DataGenerationMethod
|
|
9
|
-
from ..exceptions import HTTPError
|
|
7
|
+
from ..constants import DataGenerationMethod
|
|
8
|
+
from ..exceptions import SchemaError, SchemaErrorType
|
|
10
9
|
from ..models import APIOperation, Status, TestResult, TestResultSet
|
|
11
10
|
from ..schemas import BaseSchema
|
|
12
11
|
from ..utils import current_datetime, format_exception
|
|
@@ -168,37 +167,73 @@ class Interrupted(ExecutionEvent):
|
|
|
168
167
|
thread_id: int = field(default_factory=threading.get_ident)
|
|
169
168
|
|
|
170
169
|
|
|
170
|
+
@enum.unique
|
|
171
|
+
class InternalErrorType(str, enum.Enum):
|
|
172
|
+
SCHEMA = "schema"
|
|
173
|
+
OTHER = "other"
|
|
174
|
+
|
|
175
|
+
|
|
171
176
|
@dataclass
|
|
172
177
|
class InternalError(ExecutionEvent):
|
|
173
178
|
"""An error that happened inside the runner."""
|
|
174
179
|
|
|
175
180
|
is_terminal = True
|
|
176
181
|
|
|
182
|
+
# Main error info
|
|
183
|
+
type: InternalErrorType
|
|
184
|
+
subtype: Optional[SchemaErrorType]
|
|
185
|
+
title: str
|
|
177
186
|
message: str
|
|
187
|
+
extras: List[str]
|
|
188
|
+
|
|
189
|
+
# Exception info
|
|
178
190
|
exception_type: str
|
|
179
|
-
exception:
|
|
180
|
-
exception_with_traceback:
|
|
191
|
+
exception: str
|
|
192
|
+
exception_with_traceback: str
|
|
193
|
+
# Auxiliary data
|
|
181
194
|
thread_id: int = field(default_factory=threading.get_ident)
|
|
182
195
|
|
|
183
196
|
@classmethod
|
|
184
|
-
def
|
|
197
|
+
def from_schema_error(cls, error: SchemaError) -> "InternalError":
|
|
198
|
+
return cls.with_exception(
|
|
199
|
+
error,
|
|
200
|
+
type_=InternalErrorType.SCHEMA,
|
|
201
|
+
subtype=error.type,
|
|
202
|
+
title="Schema Loading Error",
|
|
203
|
+
message=error.message,
|
|
204
|
+
extra=error.extras,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
@classmethod
|
|
208
|
+
def from_exc(cls, exc: Exception) -> "InternalError":
|
|
209
|
+
return cls.with_exception(
|
|
210
|
+
exc,
|
|
211
|
+
type_=InternalErrorType.OTHER,
|
|
212
|
+
subtype=None,
|
|
213
|
+
title="Test Execution Error",
|
|
214
|
+
message="An internal error occurred during the test run",
|
|
215
|
+
extra=[],
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
@classmethod
|
|
219
|
+
def with_exception(
|
|
220
|
+
cls,
|
|
221
|
+
exc: Exception,
|
|
222
|
+
type_: InternalErrorType,
|
|
223
|
+
subtype: Optional[SchemaErrorType],
|
|
224
|
+
title: str,
|
|
225
|
+
message: str,
|
|
226
|
+
extra: List[str],
|
|
227
|
+
) -> "InternalError":
|
|
185
228
|
exception_type = f"{exc.__class__.__module__}.{exc.__class__.__qualname__}"
|
|
186
|
-
if isinstance(exc, HTTPError):
|
|
187
|
-
if exc.response.status_code == 404:
|
|
188
|
-
message = f"Schema was not found at {exc.url}"
|
|
189
|
-
else:
|
|
190
|
-
message = f"Failed to load schema, code {exc.response.status_code} was returned from {exc.url}"
|
|
191
|
-
return cls(message=message, exception_type=exception_type)
|
|
192
229
|
exception = format_exception(exc)
|
|
193
230
|
exception_with_traceback = format_exception(exc, include_traceback=True)
|
|
194
|
-
if isinstance(exc, exceptions.ConnectionError):
|
|
195
|
-
message = f"Failed to load schema from {exc.request.url}"
|
|
196
|
-
if wait_for_schema is None:
|
|
197
|
-
message += f"\n{USE_WAIT_FOR_SCHEMA_SUGGESTION_MESSAGE}"
|
|
198
|
-
else:
|
|
199
|
-
message = "An internal error happened during a test run"
|
|
200
231
|
return cls(
|
|
232
|
+
type=type_,
|
|
233
|
+
subtype=subtype,
|
|
234
|
+
title=title,
|
|
201
235
|
message=message,
|
|
236
|
+
extras=extra,
|
|
202
237
|
exception_type=exception_type,
|
|
203
238
|
exception=exception,
|
|
204
239
|
exception_with_traceback=exception_with_traceback,
|
|
@@ -245,7 +280,7 @@ class Finished(ExecutionEvent):
|
|
|
245
280
|
is_empty=results.is_empty,
|
|
246
281
|
total=results.total,
|
|
247
282
|
generic_errors=[
|
|
248
|
-
SerializedError.from_error(error,
|
|
283
|
+
SerializedError.from_error(exception=error, title=error.full_path) for error in results.generic_errors
|
|
249
284
|
],
|
|
250
285
|
warnings=results.warnings,
|
|
251
286
|
running_time=running_time,
|