schemathesis 3.25.5__py3-none-any.whl → 4.0.0a1__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 +27 -65
- schemathesis/auths.py +102 -82
- schemathesis/checks.py +126 -46
- schemathesis/cli/__init__.py +11 -1766
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/commands/__init__.py +37 -0
- schemathesis/cli/commands/run/__init__.py +662 -0
- schemathesis/cli/commands/run/checks.py +80 -0
- schemathesis/cli/commands/run/context.py +117 -0
- schemathesis/cli/commands/run/events.py +35 -0
- schemathesis/cli/commands/run/executor.py +138 -0
- schemathesis/cli/commands/run/filters.py +194 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +18 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +494 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
- schemathesis/cli/commands/run/handlers/output.py +746 -0
- schemathesis/cli/commands/run/hypothesis.py +105 -0
- schemathesis/cli/commands/run/loaders.py +129 -0
- schemathesis/cli/{callbacks.py → commands/run/validation.py} +103 -174
- schemathesis/cli/constants.py +5 -52
- schemathesis/cli/core.py +17 -0
- schemathesis/cli/ext/fs.py +14 -0
- schemathesis/cli/ext/groups.py +55 -0
- schemathesis/cli/{options.py → ext/options.py} +39 -10
- schemathesis/cli/hooks.py +36 -0
- schemathesis/contrib/__init__.py +1 -3
- schemathesis/contrib/openapi/__init__.py +1 -3
- schemathesis/contrib/openapi/fill_missing_examples.py +3 -5
- schemathesis/core/__init__.py +58 -0
- schemathesis/core/compat.py +25 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +58 -0
- schemathesis/core/deserialization.py +65 -0
- schemathesis/core/errors.py +370 -0
- schemathesis/core/failures.py +285 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/{_lazy_import.py → core/lazy_import.py} +1 -0
- schemathesis/core/loaders.py +104 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/{transports/content_types.py → core/media_types.py} +17 -13
- schemathesis/core/output/__init__.py +69 -0
- schemathesis/core/output/sanitization.py +197 -0
- schemathesis/core/rate_limit.py +60 -0
- schemathesis/core/registries.py +31 -0
- schemathesis/{internal → core}/result.py +1 -1
- schemathesis/core/transforms.py +113 -0
- schemathesis/core/transport.py +108 -0
- schemathesis/core/validation.py +38 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +30 -0
- schemathesis/engine/config.py +59 -0
- schemathesis/engine/context.py +119 -0
- schemathesis/engine/control.py +36 -0
- schemathesis/engine/core.py +157 -0
- schemathesis/engine/errors.py +394 -0
- schemathesis/engine/events.py +337 -0
- schemathesis/engine/phases/__init__.py +66 -0
- schemathesis/{cli → engine/phases}/probes.py +63 -70
- schemathesis/engine/phases/stateful/__init__.py +65 -0
- schemathesis/engine/phases/stateful/_executor.py +326 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +174 -0
- schemathesis/engine/phases/unit/_executor.py +321 -0
- schemathesis/engine/phases/unit/_pool.py +74 -0
- schemathesis/engine/recorder.py +241 -0
- schemathesis/errors.py +31 -0
- schemathesis/experimental/__init__.py +18 -14
- schemathesis/filters.py +103 -14
- schemathesis/generation/__init__.py +21 -37
- schemathesis/generation/case.py +190 -0
- schemathesis/generation/coverage.py +931 -0
- schemathesis/generation/hypothesis/__init__.py +30 -0
- schemathesis/generation/hypothesis/builder.py +585 -0
- schemathesis/generation/hypothesis/examples.py +50 -0
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +14 -0
- schemathesis/generation/hypothesis/strategies.py +16 -0
- schemathesis/generation/meta.py +115 -0
- schemathesis/generation/modes.py +28 -0
- schemathesis/generation/overrides.py +96 -0
- schemathesis/generation/stateful/__init__.py +20 -0
- schemathesis/{stateful → generation/stateful}/state_machine.py +68 -81
- schemathesis/generation/targets.py +69 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +115 -0
- schemathesis/graphql/loaders.py +131 -0
- schemathesis/hooks.py +99 -67
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +412 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +63 -0
- schemathesis/openapi/loaders.py +178 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +273 -0
- schemathesis/pytest/loaders.py +12 -0
- schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +106 -127
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +537 -261
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/_cache.py +25 -0
- schemathesis/specs/graphql/nodes.py +1 -0
- schemathesis/specs/graphql/scalars.py +7 -5
- schemathesis/specs/graphql/schemas.py +215 -187
- schemathesis/specs/graphql/validation.py +11 -18
- schemathesis/specs/openapi/__init__.py +7 -1
- schemathesis/specs/openapi/_cache.py +122 -0
- schemathesis/specs/openapi/_hypothesis.py +146 -165
- schemathesis/specs/openapi/checks.py +565 -67
- schemathesis/specs/openapi/converter.py +33 -6
- schemathesis/specs/openapi/definitions.py +11 -18
- schemathesis/specs/openapi/examples.py +153 -39
- schemathesis/specs/openapi/expressions/__init__.py +37 -2
- schemathesis/specs/openapi/expressions/context.py +4 -6
- schemathesis/specs/openapi/expressions/extractors.py +23 -0
- schemathesis/specs/openapi/expressions/lexer.py +20 -18
- schemathesis/specs/openapi/expressions/nodes.py +38 -14
- schemathesis/specs/openapi/expressions/parser.py +26 -5
- schemathesis/specs/openapi/formats.py +45 -0
- schemathesis/specs/openapi/links.py +65 -165
- schemathesis/specs/openapi/media_types.py +32 -0
- schemathesis/specs/openapi/negative/__init__.py +7 -3
- schemathesis/specs/openapi/negative/mutations.py +24 -8
- schemathesis/specs/openapi/parameters.py +46 -30
- schemathesis/specs/openapi/patterns.py +137 -0
- schemathesis/specs/openapi/references.py +47 -57
- schemathesis/specs/openapi/schemas.py +483 -367
- schemathesis/specs/openapi/security.py +25 -7
- schemathesis/specs/openapi/serialization.py +11 -6
- schemathesis/specs/openapi/stateful/__init__.py +185 -73
- schemathesis/specs/openapi/utils.py +6 -1
- schemathesis/transport/__init__.py +104 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +99 -0
- schemathesis/transport/requests.py +221 -0
- schemathesis/{_xml.py → transport/serialization.py} +143 -28
- schemathesis/transport/wsgi.py +165 -0
- schemathesis-4.0.0a1.dist-info/METADATA +297 -0
- schemathesis-4.0.0a1.dist-info/RECORD +152 -0
- {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/WHEEL +1 -1
- {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/entry_points.txt +1 -1
- schemathesis/_compat.py +0 -74
- schemathesis/_dependency_versions.py +0 -17
- schemathesis/_hypothesis.py +0 -246
- schemathesis/_override.py +0 -49
- schemathesis/cli/cassettes.py +0 -375
- schemathesis/cli/context.py +0 -55
- schemathesis/cli/debug.py +0 -26
- schemathesis/cli/handlers.py +0 -16
- schemathesis/cli/junitxml.py +0 -43
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -765
- schemathesis/cli/output/short.py +0 -40
- schemathesis/cli/sanitization.py +0 -20
- schemathesis/code_samples.py +0 -149
- schemathesis/constants.py +0 -55
- schemathesis/contrib/openapi/formats/__init__.py +0 -9
- schemathesis/contrib/openapi/formats/uuid.py +0 -15
- schemathesis/contrib/unique_data.py +0 -41
- schemathesis/exceptions.py +0 -560
- schemathesis/extra/_aiohttp.py +0 -27
- schemathesis/extra/_flask.py +0 -10
- schemathesis/extra/_server.py +0 -17
- schemathesis/failures.py +0 -209
- schemathesis/fixups/__init__.py +0 -36
- schemathesis/fixups/fast_api.py +0 -41
- schemathesis/fixups/utf8_bom.py +0 -29
- schemathesis/graphql.py +0 -4
- schemathesis/internal/__init__.py +0 -7
- schemathesis/internal/copy.py +0 -13
- schemathesis/internal/datetime.py +0 -5
- schemathesis/internal/deprecation.py +0 -34
- schemathesis/internal/jsonschema.py +0 -35
- schemathesis/internal/transformation.py +0 -15
- schemathesis/internal/validation.py +0 -34
- schemathesis/lazy.py +0 -361
- schemathesis/loaders.py +0 -120
- schemathesis/models.py +0 -1231
- schemathesis/parameters.py +0 -86
- schemathesis/runner/__init__.py +0 -555
- schemathesis/runner/events.py +0 -309
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/core.py +0 -986
- schemathesis/runner/impl/solo.py +0 -90
- schemathesis/runner/impl/threadpool.py +0 -400
- schemathesis/runner/serialization.py +0 -411
- schemathesis/sanitization.py +0 -248
- schemathesis/serializers.py +0 -315
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -11
- schemathesis/service/ci.py +0 -201
- schemathesis/service/client.py +0 -100
- schemathesis/service/constants.py +0 -38
- schemathesis/service/events.py +0 -57
- schemathesis/service/hosts.py +0 -107
- schemathesis/service/metadata.py +0 -46
- schemathesis/service/models.py +0 -49
- schemathesis/service/report.py +0 -255
- schemathesis/service/serialization.py +0 -184
- schemathesis/service/usage.py +0 -65
- schemathesis/specs/graphql/loaders.py +0 -344
- schemathesis/specs/openapi/filters.py +0 -49
- schemathesis/specs/openapi/loaders.py +0 -667
- schemathesis/specs/openapi/stateful/links.py +0 -92
- schemathesis/specs/openapi/validation.py +0 -25
- schemathesis/stateful/__init__.py +0 -133
- schemathesis/targets.py +0 -45
- schemathesis/throttling.py +0 -41
- schemathesis/transports/__init__.py +0 -5
- schemathesis/transports/auth.py +0 -15
- schemathesis/transports/headers.py +0 -35
- schemathesis/transports/responses.py +0 -52
- schemathesis/types.py +0 -35
- schemathesis/utils.py +0 -169
- schemathesis-3.25.5.dist-info/METADATA +0 -356
- schemathesis-3.25.5.dist-info/RECORD +0 -134
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
- {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,113 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import Any, Callable, Dict, List, Mapping, Union, overload
|
4
|
+
|
5
|
+
|
6
|
+
def deepclone(value: Any) -> Any:
|
7
|
+
"""A specialized version of `deepcopy` that copies only `dict` and `list` and does unrolling.
|
8
|
+
|
9
|
+
It is on average 3x faster than `deepcopy` and given the amount of calls, it is an important optimization.
|
10
|
+
"""
|
11
|
+
if isinstance(value, dict):
|
12
|
+
return {
|
13
|
+
k1: (
|
14
|
+
{k2: deepclone(v2) for k2, v2 in v1.items()}
|
15
|
+
if isinstance(v1, dict)
|
16
|
+
else [deepclone(v2) for v2 in v1]
|
17
|
+
if isinstance(v1, list)
|
18
|
+
else v1
|
19
|
+
)
|
20
|
+
for k1, v1 in value.items()
|
21
|
+
}
|
22
|
+
if isinstance(value, list):
|
23
|
+
return [
|
24
|
+
{k2: deepclone(v2) for k2, v2 in v1.items()}
|
25
|
+
if isinstance(v1, dict)
|
26
|
+
else [deepclone(v2) for v2 in v1]
|
27
|
+
if isinstance(v1, list)
|
28
|
+
else v1
|
29
|
+
for v1 in value
|
30
|
+
]
|
31
|
+
return value
|
32
|
+
|
33
|
+
|
34
|
+
def diff(left: Mapping[str, Any], right: Mapping[str, Any]) -> dict[str, Any]:
|
35
|
+
"""Calculate the difference between two dictionaries."""
|
36
|
+
diff = {}
|
37
|
+
for key, value in right.items():
|
38
|
+
if key not in left or left[key] != value:
|
39
|
+
diff[key] = value
|
40
|
+
return diff
|
41
|
+
|
42
|
+
|
43
|
+
def merge_at(data: dict[str, Any], data_key: str, new: dict[str, Any]) -> None:
|
44
|
+
original = data[data_key] or {}
|
45
|
+
for key, value in new.items():
|
46
|
+
original[key] = value
|
47
|
+
data[data_key] = original
|
48
|
+
|
49
|
+
|
50
|
+
JsonValue = Union[Dict[str, Any], List, str, float, int]
|
51
|
+
|
52
|
+
|
53
|
+
@overload
|
54
|
+
def transform(schema: dict[str, Any], callback: Callable, *args: Any, **kwargs: Any) -> dict[str, Any]: ...
|
55
|
+
|
56
|
+
|
57
|
+
@overload
|
58
|
+
def transform(schema: list, callback: Callable, *args: Any, **kwargs: Any) -> list: ...
|
59
|
+
|
60
|
+
|
61
|
+
@overload
|
62
|
+
def transform(schema: str, callback: Callable, *args: Any, **kwargs: Any) -> str: ...
|
63
|
+
|
64
|
+
|
65
|
+
@overload
|
66
|
+
def transform(schema: float, callback: Callable, *args: Any, **kwargs: Any) -> float: ...
|
67
|
+
|
68
|
+
|
69
|
+
def transform(schema: JsonValue, callback: Callable[..., dict[str, Any]], *args: Any, **kwargs: Any) -> JsonValue:
|
70
|
+
"""Apply callback recursively to the given schema."""
|
71
|
+
if isinstance(schema, dict):
|
72
|
+
schema = callback(schema, *args, **kwargs)
|
73
|
+
for key, sub_item in schema.items():
|
74
|
+
schema[key] = transform(sub_item, callback, *args, **kwargs)
|
75
|
+
elif isinstance(schema, list):
|
76
|
+
schema = [transform(sub_item, callback, *args, **kwargs) for sub_item in schema]
|
77
|
+
return schema
|
78
|
+
|
79
|
+
|
80
|
+
class Unresolvable: ...
|
81
|
+
|
82
|
+
|
83
|
+
UNRESOLVABLE = Unresolvable()
|
84
|
+
|
85
|
+
|
86
|
+
def resolve_pointer(document: Any, pointer: str) -> dict | list | str | int | float | None | Unresolvable:
|
87
|
+
"""Implementation is adapted from Rust's `serde-json` crate.
|
88
|
+
|
89
|
+
Ref: https://github.com/serde-rs/json/blob/master/src/value/mod.rs#L751
|
90
|
+
"""
|
91
|
+
if not pointer:
|
92
|
+
return document
|
93
|
+
if not pointer.startswith("/"):
|
94
|
+
return UNRESOLVABLE
|
95
|
+
|
96
|
+
def replace(value: str) -> str:
|
97
|
+
return value.replace("~1", "/").replace("~0", "~")
|
98
|
+
|
99
|
+
tokens = map(replace, pointer.split("/")[1:])
|
100
|
+
target = document
|
101
|
+
for token in tokens:
|
102
|
+
if isinstance(target, dict):
|
103
|
+
target = target.get(token, UNRESOLVABLE)
|
104
|
+
if target is UNRESOLVABLE:
|
105
|
+
return UNRESOLVABLE
|
106
|
+
elif isinstance(target, list):
|
107
|
+
try:
|
108
|
+
target = target[int(token)]
|
109
|
+
except IndexError:
|
110
|
+
return UNRESOLVABLE
|
111
|
+
else:
|
112
|
+
return UNRESOLVABLE
|
113
|
+
return target
|
@@ -0,0 +1,108 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import base64
|
4
|
+
import json
|
5
|
+
from typing import TYPE_CHECKING, Any, Mapping
|
6
|
+
|
7
|
+
from schemathesis.core.version import SCHEMATHESIS_VERSION
|
8
|
+
|
9
|
+
if TYPE_CHECKING:
|
10
|
+
import requests
|
11
|
+
|
12
|
+
USER_AGENT = f"schemathesis/{SCHEMATHESIS_VERSION}"
|
13
|
+
DEFAULT_RESPONSE_TIMEOUT = 10
|
14
|
+
|
15
|
+
|
16
|
+
def prepare_urlencoded(data: Any) -> Any:
|
17
|
+
if isinstance(data, list):
|
18
|
+
output = []
|
19
|
+
for item in data:
|
20
|
+
if isinstance(item, dict):
|
21
|
+
for key, value in item.items():
|
22
|
+
output.append((key, value))
|
23
|
+
else:
|
24
|
+
output.append((item, "arbitrary-value"))
|
25
|
+
return output
|
26
|
+
return data
|
27
|
+
|
28
|
+
|
29
|
+
class Response:
|
30
|
+
"""Unified response for both testing and reporting purposes."""
|
31
|
+
|
32
|
+
__slots__ = (
|
33
|
+
"status_code",
|
34
|
+
"headers",
|
35
|
+
"content",
|
36
|
+
"request",
|
37
|
+
"elapsed",
|
38
|
+
"verify",
|
39
|
+
"_json",
|
40
|
+
"message",
|
41
|
+
"http_version",
|
42
|
+
"encoding",
|
43
|
+
"_encoded_body",
|
44
|
+
)
|
45
|
+
|
46
|
+
def __init__(
|
47
|
+
self,
|
48
|
+
status_code: int,
|
49
|
+
headers: Mapping[str, list[str]],
|
50
|
+
content: bytes,
|
51
|
+
request: requests.PreparedRequest,
|
52
|
+
elapsed: float,
|
53
|
+
verify: bool,
|
54
|
+
message: str = "",
|
55
|
+
http_version: str = "1.1",
|
56
|
+
encoding: str | None = None,
|
57
|
+
):
|
58
|
+
self.status_code = status_code
|
59
|
+
self.headers = {key.lower(): value for key, value in headers.items()}
|
60
|
+
assert all(isinstance(v, list) for v in headers.values())
|
61
|
+
self.content = content
|
62
|
+
self.request = request
|
63
|
+
self.elapsed = elapsed
|
64
|
+
self.verify = verify
|
65
|
+
self._json = None
|
66
|
+
self._encoded_body: str | None = None
|
67
|
+
self.message = message
|
68
|
+
self.http_version = http_version
|
69
|
+
self.encoding = encoding
|
70
|
+
|
71
|
+
@classmethod
|
72
|
+
def from_requests(cls, response: requests.Response, verify: bool) -> Response:
|
73
|
+
raw = response.raw
|
74
|
+
raw_headers = raw.headers if raw is not None else {}
|
75
|
+
headers = {name: response.raw.headers.getlist(name) for name in raw_headers.keys()}
|
76
|
+
# Similar to http.client:319 (HTTP version detection in stdlib's `http` package)
|
77
|
+
version = raw.version if raw is not None else 10
|
78
|
+
http_version = "1.0" if version == 10 else "1.1"
|
79
|
+
return Response(
|
80
|
+
status_code=response.status_code,
|
81
|
+
headers=headers,
|
82
|
+
content=response.content,
|
83
|
+
request=response.request,
|
84
|
+
elapsed=response.elapsed.total_seconds(),
|
85
|
+
message=response.reason,
|
86
|
+
encoding=response.encoding,
|
87
|
+
http_version=http_version,
|
88
|
+
verify=verify,
|
89
|
+
)
|
90
|
+
|
91
|
+
@property
|
92
|
+
def text(self) -> str:
|
93
|
+
return self.content.decode(self.encoding if self.encoding else "utf-8")
|
94
|
+
|
95
|
+
def json(self) -> Any:
|
96
|
+
if self._json is None:
|
97
|
+
self._json = json.loads(self.text)
|
98
|
+
return self._json
|
99
|
+
|
100
|
+
@property
|
101
|
+
def body_size(self) -> int | None:
|
102
|
+
return len(self.content) if self.content else None
|
103
|
+
|
104
|
+
@property
|
105
|
+
def encoded_body(self) -> str | None:
|
106
|
+
if self._encoded_body is None and self.content:
|
107
|
+
self._encoded_body = base64.b64encode(self.content).decode()
|
108
|
+
return self._encoded_body
|
@@ -0,0 +1,38 @@
|
|
1
|
+
import re
|
2
|
+
|
3
|
+
# Adapted from http.client._is_illegal_header_value
|
4
|
+
INVALID_HEADER_RE = re.compile(r"\n(?![ \t])|\r(?![ \t\n])")
|
5
|
+
|
6
|
+
|
7
|
+
def has_invalid_characters(name: str, value: object) -> bool:
|
8
|
+
from requests.exceptions import InvalidHeader
|
9
|
+
from requests.utils import check_header_validity
|
10
|
+
|
11
|
+
if not isinstance(value, str):
|
12
|
+
return False
|
13
|
+
try:
|
14
|
+
check_header_validity((name, value))
|
15
|
+
return bool(INVALID_HEADER_RE.search(value))
|
16
|
+
except InvalidHeader:
|
17
|
+
return True
|
18
|
+
|
19
|
+
|
20
|
+
def is_latin_1_encodable(value: object) -> bool:
|
21
|
+
"""Check if a value is a Latin-1 encodable string."""
|
22
|
+
if not isinstance(value, str):
|
23
|
+
return False
|
24
|
+
try:
|
25
|
+
value.encode("latin-1")
|
26
|
+
return True
|
27
|
+
except UnicodeEncodeError:
|
28
|
+
return False
|
29
|
+
|
30
|
+
|
31
|
+
SURROGATE_PAIR_RE = re.compile(r"[\ud800-\udfff]")
|
32
|
+
_contains_surrogate_pair = SURROGATE_PAIR_RE.search
|
33
|
+
|
34
|
+
|
35
|
+
def contains_unicode_surrogate_pair(item: object) -> bool:
|
36
|
+
if isinstance(item, list):
|
37
|
+
return any(isinstance(item_, str) and bool(_contains_surrogate_pair(item_)) for item_ in item)
|
38
|
+
return isinstance(item, str) and bool(_contains_surrogate_pair(item))
|
@@ -0,0 +1,30 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from enum import Enum
|
4
|
+
from typing import TYPE_CHECKING
|
5
|
+
|
6
|
+
from schemathesis.engine.config import EngineConfig
|
7
|
+
|
8
|
+
if TYPE_CHECKING:
|
9
|
+
from schemathesis.engine.core import Engine
|
10
|
+
from schemathesis.schemas import BaseSchema
|
11
|
+
|
12
|
+
|
13
|
+
class Status(str, Enum):
|
14
|
+
SUCCESS = "success"
|
15
|
+
FAILURE = "failure"
|
16
|
+
ERROR = "error"
|
17
|
+
INTERRUPTED = "interrupted"
|
18
|
+
SKIP = "skip"
|
19
|
+
|
20
|
+
def __lt__(self, other: Status) -> bool: # type: ignore[override]
|
21
|
+
return _STATUS_ORDER[self] < _STATUS_ORDER[other]
|
22
|
+
|
23
|
+
|
24
|
+
_STATUS_ORDER = {Status.SUCCESS: 0, Status.FAILURE: 1, Status.ERROR: 2, Status.INTERRUPTED: 3, Status.SKIP: 4}
|
25
|
+
|
26
|
+
|
27
|
+
def from_schema(schema: BaseSchema, *, config: EngineConfig | None = None) -> Engine:
|
28
|
+
from .core import Engine
|
29
|
+
|
30
|
+
return Engine(schema=schema, config=config or EngineConfig())
|
@@ -0,0 +1,59 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from dataclasses import dataclass, field
|
4
|
+
from typing import TYPE_CHECKING, Any
|
5
|
+
|
6
|
+
from schemathesis.checks import CheckFunction, not_a_server_error
|
7
|
+
from schemathesis.engine.phases import PhaseName
|
8
|
+
from schemathesis.generation import GenerationConfig
|
9
|
+
from schemathesis.generation.overrides import Override
|
10
|
+
|
11
|
+
if TYPE_CHECKING:
|
12
|
+
import hypothesis
|
13
|
+
|
14
|
+
from schemathesis.checks import ChecksConfig
|
15
|
+
from schemathesis.generation.targets import TargetFunction
|
16
|
+
|
17
|
+
|
18
|
+
def _default_hypothesis_settings() -> hypothesis.settings:
|
19
|
+
import hypothesis
|
20
|
+
|
21
|
+
return hypothesis.settings(deadline=None)
|
22
|
+
|
23
|
+
|
24
|
+
@dataclass
|
25
|
+
class ExecutionConfig:
|
26
|
+
"""Configuration for test execution."""
|
27
|
+
|
28
|
+
phases: list[PhaseName] = field(default_factory=lambda: [PhaseName.UNIT_TESTING, PhaseName.STATEFUL_TESTING])
|
29
|
+
checks: list[CheckFunction] = field(default_factory=lambda: [not_a_server_error])
|
30
|
+
targets: list[TargetFunction] = field(default_factory=list)
|
31
|
+
hypothesis_settings: hypothesis.settings = field(default_factory=_default_hypothesis_settings)
|
32
|
+
generation: GenerationConfig = field(default_factory=GenerationConfig)
|
33
|
+
max_failures: int | None = None
|
34
|
+
unique_inputs: bool = False
|
35
|
+
no_failfast: bool = False
|
36
|
+
seed: int | None = None
|
37
|
+
workers_num: int = 1
|
38
|
+
|
39
|
+
|
40
|
+
@dataclass
|
41
|
+
class NetworkConfig:
|
42
|
+
"""Network-related configuration."""
|
43
|
+
|
44
|
+
auth: tuple[str, str] | None = None
|
45
|
+
headers: dict[str, Any] = field(default_factory=dict)
|
46
|
+
timeout: int | None = None
|
47
|
+
tls_verify: bool | str = True
|
48
|
+
proxy: str | None = None
|
49
|
+
cert: str | tuple[str, str] | None = None
|
50
|
+
|
51
|
+
|
52
|
+
@dataclass
|
53
|
+
class EngineConfig:
|
54
|
+
"""Complete engine configuration."""
|
55
|
+
|
56
|
+
execution: ExecutionConfig = field(default_factory=ExecutionConfig)
|
57
|
+
network: NetworkConfig = field(default_factory=NetworkConfig)
|
58
|
+
checks_config: ChecksConfig = field(default_factory=dict)
|
59
|
+
override: Override | None = None
|
@@ -0,0 +1,119 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import time
|
4
|
+
from dataclasses import dataclass
|
5
|
+
from functools import cached_property
|
6
|
+
from typing import TYPE_CHECKING, Any
|
7
|
+
|
8
|
+
from schemathesis.checks import CheckContext
|
9
|
+
from schemathesis.core import NOT_SET, NotSet
|
10
|
+
from schemathesis.engine.recorder import ScenarioRecorder
|
11
|
+
from schemathesis.generation.case import Case
|
12
|
+
from schemathesis.schemas import BaseSchema
|
13
|
+
|
14
|
+
from .control import ExecutionControl
|
15
|
+
|
16
|
+
if TYPE_CHECKING:
|
17
|
+
import threading
|
18
|
+
|
19
|
+
import requests
|
20
|
+
|
21
|
+
from schemathesis.engine.config import EngineConfig
|
22
|
+
|
23
|
+
|
24
|
+
@dataclass
|
25
|
+
class EngineContext:
|
26
|
+
"""Holds context shared for a test run."""
|
27
|
+
|
28
|
+
schema: BaseSchema
|
29
|
+
control: ExecutionControl
|
30
|
+
outcome_cache: dict[int, BaseException | None]
|
31
|
+
config: EngineConfig
|
32
|
+
start_time: float
|
33
|
+
|
34
|
+
def __init__(
|
35
|
+
self,
|
36
|
+
*,
|
37
|
+
schema: BaseSchema,
|
38
|
+
stop_event: threading.Event,
|
39
|
+
config: EngineConfig,
|
40
|
+
session: requests.Session | None = None,
|
41
|
+
) -> None:
|
42
|
+
self.schema = schema
|
43
|
+
self.control = ExecutionControl(stop_event=stop_event, max_failures=config.execution.max_failures)
|
44
|
+
self.outcome_cache = {}
|
45
|
+
self.config = config
|
46
|
+
self.start_time = time.monotonic()
|
47
|
+
self._session = session
|
48
|
+
|
49
|
+
def _repr_pretty_(self, *args: Any, **kwargs: Any) -> None: ...
|
50
|
+
|
51
|
+
@property
|
52
|
+
def running_time(self) -> float:
|
53
|
+
return time.monotonic() - self.start_time
|
54
|
+
|
55
|
+
@property
|
56
|
+
def has_to_stop(self) -> bool:
|
57
|
+
"""Check if execution should stop."""
|
58
|
+
return self.control.is_stopped
|
59
|
+
|
60
|
+
@property
|
61
|
+
def is_interrupted(self) -> bool:
|
62
|
+
return self.control.is_interrupted
|
63
|
+
|
64
|
+
@property
|
65
|
+
def has_reached_the_failure_limit(self) -> bool:
|
66
|
+
return self.control.has_reached_the_failure_limit
|
67
|
+
|
68
|
+
def stop(self) -> None:
|
69
|
+
self.control.stop()
|
70
|
+
|
71
|
+
def cache_outcome(self, case: Case, outcome: BaseException | None) -> None:
|
72
|
+
self.outcome_cache[hash(case)] = outcome
|
73
|
+
|
74
|
+
def get_cached_outcome(self, case: Case) -> BaseException | None | NotSet:
|
75
|
+
return self.outcome_cache.get(hash(case), NOT_SET)
|
76
|
+
|
77
|
+
@cached_property
|
78
|
+
def session(self) -> requests.Session:
|
79
|
+
if self._session is not None:
|
80
|
+
return self._session
|
81
|
+
import requests
|
82
|
+
|
83
|
+
session = requests.Session()
|
84
|
+
config = self.config.network
|
85
|
+
session.verify = config.tls_verify
|
86
|
+
if config.auth is not None:
|
87
|
+
session.auth = config.auth
|
88
|
+
if config.headers:
|
89
|
+
session.headers.update(config.headers)
|
90
|
+
if config.cert is not None:
|
91
|
+
session.cert = config.cert
|
92
|
+
if config.proxy is not None:
|
93
|
+
session.proxies["all"] = config.proxy
|
94
|
+
return session
|
95
|
+
|
96
|
+
@property
|
97
|
+
def transport_kwargs(self) -> dict[str, Any]:
|
98
|
+
kwargs: dict[str, Any] = {
|
99
|
+
"session": self.session,
|
100
|
+
"headers": self.config.network.headers,
|
101
|
+
"timeout": self.config.network.timeout,
|
102
|
+
"verify": self.config.network.tls_verify,
|
103
|
+
"cert": self.config.network.cert,
|
104
|
+
}
|
105
|
+
if self.config.network.proxy is not None:
|
106
|
+
kwargs["proxies"] = {"all": self.config.network.proxy}
|
107
|
+
return kwargs
|
108
|
+
|
109
|
+
def get_check_context(self, recorder: ScenarioRecorder) -> CheckContext:
|
110
|
+
from requests.models import CaseInsensitiveDict
|
111
|
+
|
112
|
+
return CheckContext(
|
113
|
+
override=self.config.override,
|
114
|
+
auth=self.config.network.auth,
|
115
|
+
headers=CaseInsensitiveDict(self.config.network.headers) if self.config.network.headers else None,
|
116
|
+
config=self.config.checks_config,
|
117
|
+
transport_kwargs=self.transport_kwargs,
|
118
|
+
recorder=recorder,
|
119
|
+
)
|
@@ -0,0 +1,36 @@
|
|
1
|
+
"""Control for the Schemathesis Engine execution."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import threading
|
6
|
+
from dataclasses import dataclass
|
7
|
+
|
8
|
+
|
9
|
+
@dataclass
|
10
|
+
class ExecutionControl:
|
11
|
+
"""Controls engine execution flow and tracks failures."""
|
12
|
+
|
13
|
+
stop_event: threading.Event
|
14
|
+
max_failures: int | None
|
15
|
+
_failures_counter: int = 0
|
16
|
+
has_reached_the_failure_limit: bool = False
|
17
|
+
|
18
|
+
@property
|
19
|
+
def is_stopped(self) -> bool:
|
20
|
+
"""Check if execution should stop."""
|
21
|
+
return self.is_interrupted or self.has_reached_the_failure_limit
|
22
|
+
|
23
|
+
@property
|
24
|
+
def is_interrupted(self) -> bool:
|
25
|
+
return self.stop_event.is_set()
|
26
|
+
|
27
|
+
def stop(self) -> None:
|
28
|
+
"""Signal to stop execution."""
|
29
|
+
self.stop_event.set()
|
30
|
+
|
31
|
+
def count_failure(self) -> None:
|
32
|
+
# N failures limit
|
33
|
+
if self.max_failures is not None:
|
34
|
+
self._failures_counter += 1
|
35
|
+
if self._failures_counter >= self.max_failures:
|
36
|
+
self.has_reached_the_failure_limit = True
|
@@ -0,0 +1,157 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import threading
|
4
|
+
from dataclasses import dataclass
|
5
|
+
from typing import Sequence
|
6
|
+
|
7
|
+
from schemathesis.auths import unregister as unregister_auth
|
8
|
+
from schemathesis.core import SpecificationFeature
|
9
|
+
from schemathesis.engine import Status, events, phases
|
10
|
+
from schemathesis.schemas import BaseSchema
|
11
|
+
|
12
|
+
from .config import EngineConfig
|
13
|
+
from .context import EngineContext
|
14
|
+
from .events import EventGenerator
|
15
|
+
from .phases import Phase, PhaseName, PhaseSkipReason
|
16
|
+
|
17
|
+
|
18
|
+
@dataclass
|
19
|
+
class Engine:
|
20
|
+
schema: BaseSchema
|
21
|
+
config: EngineConfig
|
22
|
+
|
23
|
+
def execute(self) -> EventStream:
|
24
|
+
"""Execute all test phases."""
|
25
|
+
# Unregister auth if explicitly provided
|
26
|
+
if self.config.network.auth is not None:
|
27
|
+
unregister_auth()
|
28
|
+
|
29
|
+
ctx = EngineContext(schema=self.schema, stop_event=threading.Event(), config=self.config)
|
30
|
+
plan = self._create_execution_plan()
|
31
|
+
return EventStream(plan.execute(ctx), ctx.control.stop_event)
|
32
|
+
|
33
|
+
def _create_execution_plan(self) -> ExecutionPlan:
|
34
|
+
"""Create execution plan based on configuration."""
|
35
|
+
phases = [
|
36
|
+
self.get_phase_config(PhaseName.PROBING, is_supported=True, requires_links=False),
|
37
|
+
self.get_phase_config(PhaseName.UNIT_TESTING, is_supported=True, requires_links=False),
|
38
|
+
self.get_phase_config(
|
39
|
+
PhaseName.STATEFUL_TESTING,
|
40
|
+
is_supported=self.schema.specification.supports_feature(SpecificationFeature.STATEFUL_TESTING),
|
41
|
+
requires_links=True,
|
42
|
+
),
|
43
|
+
]
|
44
|
+
return ExecutionPlan(phases)
|
45
|
+
|
46
|
+
def get_phase_config(
|
47
|
+
self,
|
48
|
+
phase_name: PhaseName,
|
49
|
+
*,
|
50
|
+
is_supported: bool = True,
|
51
|
+
requires_links: bool = False,
|
52
|
+
) -> Phase:
|
53
|
+
"""Helper to determine phase configuration with proper skip reasons."""
|
54
|
+
# Check if feature is supported by the schema
|
55
|
+
if not is_supported:
|
56
|
+
return Phase(
|
57
|
+
name=phase_name,
|
58
|
+
is_supported=False,
|
59
|
+
is_enabled=False,
|
60
|
+
skip_reason=PhaseSkipReason.NOT_SUPPORTED,
|
61
|
+
)
|
62
|
+
|
63
|
+
if phase_name not in self.config.execution.phases:
|
64
|
+
return Phase(
|
65
|
+
name=phase_name,
|
66
|
+
is_supported=True,
|
67
|
+
is_enabled=False,
|
68
|
+
skip_reason=PhaseSkipReason.DISABLED,
|
69
|
+
)
|
70
|
+
|
71
|
+
if requires_links and self.schema.links_count == 0:
|
72
|
+
return Phase(
|
73
|
+
name=phase_name,
|
74
|
+
is_supported=True,
|
75
|
+
is_enabled=False,
|
76
|
+
skip_reason=PhaseSkipReason.NOT_APPLICABLE,
|
77
|
+
)
|
78
|
+
|
79
|
+
# Phase can be executed
|
80
|
+
return Phase(
|
81
|
+
name=phase_name,
|
82
|
+
is_supported=True,
|
83
|
+
is_enabled=True,
|
84
|
+
skip_reason=None,
|
85
|
+
)
|
86
|
+
|
87
|
+
|
88
|
+
@dataclass
|
89
|
+
class ExecutionPlan:
|
90
|
+
"""Manages test execution phases."""
|
91
|
+
|
92
|
+
phases: Sequence[Phase]
|
93
|
+
|
94
|
+
def execute(self, engine: EngineContext) -> EventGenerator:
|
95
|
+
"""Execute all phases in sequence."""
|
96
|
+
yield events.EngineStarted()
|
97
|
+
try:
|
98
|
+
if engine.is_interrupted:
|
99
|
+
yield from self._finish(engine)
|
100
|
+
return
|
101
|
+
if engine.is_interrupted:
|
102
|
+
yield from self._finish(engine) # type: ignore[unreachable]
|
103
|
+
return
|
104
|
+
|
105
|
+
# Run main phases
|
106
|
+
for phase in self.phases:
|
107
|
+
if engine.has_reached_the_failure_limit:
|
108
|
+
phase.skip_reason = PhaseSkipReason.FAILURE_LIMIT_REACHED
|
109
|
+
yield events.PhaseStarted(phase=phase)
|
110
|
+
if phase.should_execute(engine):
|
111
|
+
yield from phases.execute(engine, phase)
|
112
|
+
else:
|
113
|
+
if engine.has_reached_the_failure_limit:
|
114
|
+
phase.skip_reason = PhaseSkipReason.FAILURE_LIMIT_REACHED
|
115
|
+
yield events.PhaseFinished(phase=phase, status=Status.SKIP, payload=None)
|
116
|
+
if engine.is_interrupted:
|
117
|
+
break # type: ignore[unreachable]
|
118
|
+
|
119
|
+
except KeyboardInterrupt:
|
120
|
+
engine.stop()
|
121
|
+
yield events.Interrupted(phase=None)
|
122
|
+
|
123
|
+
# Always finish
|
124
|
+
yield from self._finish(engine)
|
125
|
+
|
126
|
+
def _finish(self, ctx: EngineContext) -> EventGenerator:
|
127
|
+
"""Finish the test run."""
|
128
|
+
yield events.EngineFinished(running_time=ctx.running_time)
|
129
|
+
|
130
|
+
|
131
|
+
@dataclass
|
132
|
+
class EventStream:
|
133
|
+
"""Schemathesis event stream.
|
134
|
+
|
135
|
+
Provides an API to control the execution flow.
|
136
|
+
"""
|
137
|
+
|
138
|
+
generator: EventGenerator
|
139
|
+
stop_event: threading.Event
|
140
|
+
|
141
|
+
def __next__(self) -> events.EngineEvent:
|
142
|
+
return next(self.generator)
|
143
|
+
|
144
|
+
def __iter__(self) -> EventGenerator:
|
145
|
+
return self.generator
|
146
|
+
|
147
|
+
def stop(self) -> None:
|
148
|
+
"""Stop the event stream.
|
149
|
+
|
150
|
+
Its next value will be the last one (Finished).
|
151
|
+
"""
|
152
|
+
self.stop_event.set()
|
153
|
+
|
154
|
+
def finish(self) -> events.EngineEvent:
|
155
|
+
"""Stop the event stream & return the last event."""
|
156
|
+
self.stop()
|
157
|
+
return next(self)
|