schemathesis 3.39.7__py3-none-any.whl → 4.0.0a2__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 +26 -68
- schemathesis/checks.py +130 -60
- schemathesis/cli/__init__.py +5 -2105
- 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 +30 -0
- schemathesis/cli/commands/run/executor.py +141 -0
- schemathesis/cli/commands/run/filters.py +202 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +18 -0
- schemathesis/cli/{cassettes.py → commands/run/handlers/cassettes.py} +178 -247
- schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
- schemathesis/cli/commands/run/handlers/output.py +1368 -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} +59 -175
- schemathesis/cli/constants.py +5 -58
- 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} +37 -16
- 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 -7
- 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 +315 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/loaders.py +104 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/{transports/content_types.py → core/media_types.py} +14 -12
- schemathesis/{internal/output.py → core/output/__init__.py} +1 -0
- schemathesis/core/output/sanitization.py +197 -0
- schemathesis/{throttling.py → core/rate_limit.py} +16 -17
- schemathesis/core/registries.py +31 -0
- 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 +243 -0
- schemathesis/engine/phases/__init__.py +66 -0
- schemathesis/{runner → engine/phases}/probes.py +49 -68
- schemathesis/engine/phases/stateful/__init__.py +66 -0
- schemathesis/engine/phases/stateful/_executor.py +301 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +175 -0
- schemathesis/engine/phases/unit/_executor.py +322 -0
- schemathesis/engine/phases/unit/_pool.py +74 -0
- schemathesis/engine/recorder.py +246 -0
- schemathesis/errors.py +31 -0
- schemathesis/experimental/__init__.py +9 -40
- schemathesis/filters.py +7 -95
- schemathesis/generation/__init__.py +3 -3
- schemathesis/generation/case.py +190 -0
- schemathesis/generation/coverage.py +22 -22
- schemathesis/{_patches.py → generation/hypothesis/__init__.py} +15 -6
- schemathesis/generation/hypothesis/builder.py +585 -0
- schemathesis/generation/{_hypothesis.py → hypothesis/examples.py} +2 -11
- 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 +84 -109
- schemathesis/generation/targets.py +69 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +131 -0
- schemathesis/hooks.py +17 -62
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +387 -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} +94 -107
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +456 -228
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/_cache.py +1 -2
- schemathesis/specs/graphql/scalars.py +5 -3
- schemathesis/specs/graphql/schemas.py +122 -123
- schemathesis/specs/graphql/validation.py +11 -17
- schemathesis/specs/openapi/__init__.py +6 -1
- schemathesis/specs/openapi/_cache.py +1 -2
- schemathesis/specs/openapi/_hypothesis.py +97 -134
- schemathesis/specs/openapi/checks.py +238 -219
- schemathesis/specs/openapi/converter.py +4 -4
- schemathesis/specs/openapi/definitions.py +1 -1
- schemathesis/specs/openapi/examples.py +22 -20
- schemathesis/specs/openapi/expressions/__init__.py +11 -15
- schemathesis/specs/openapi/expressions/extractors.py +1 -4
- schemathesis/specs/openapi/expressions/nodes.py +33 -32
- schemathesis/specs/openapi/formats.py +3 -2
- schemathesis/specs/openapi/links.py +123 -299
- schemathesis/specs/openapi/media_types.py +10 -12
- schemathesis/specs/openapi/negative/__init__.py +2 -1
- schemathesis/specs/openapi/negative/mutations.py +3 -2
- schemathesis/specs/openapi/parameters.py +8 -6
- schemathesis/specs/openapi/patterns.py +1 -1
- schemathesis/specs/openapi/references.py +11 -51
- schemathesis/specs/openapi/schemas.py +177 -191
- schemathesis/specs/openapi/security.py +1 -1
- schemathesis/specs/openapi/serialization.py +10 -6
- schemathesis/specs/openapi/stateful/__init__.py +97 -91
- 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} +69 -7
- schemathesis/transport/wsgi.py +165 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/METADATA +18 -14
- schemathesis-4.0.0a2.dist-info/RECORD +151 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/entry_points.txt +1 -1
- schemathesis/_compat.py +0 -74
- schemathesis/_dependency_versions.py +0 -19
- schemathesis/_hypothesis.py +0 -559
- schemathesis/_override.py +0 -50
- schemathesis/_rate_limiter.py +0 -7
- schemathesis/cli/context.py +0 -75
- schemathesis/cli/debug.py +0 -27
- schemathesis/cli/handlers.py +0 -19
- schemathesis/cli/junitxml.py +0 -124
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -936
- schemathesis/cli/output/short.py +0 -59
- schemathesis/cli/reporting.py +0 -79
- schemathesis/cli/sanitization.py +0 -26
- schemathesis/code_samples.py +0 -151
- schemathesis/constants.py +0 -56
- schemathesis/contrib/openapi/formats/__init__.py +0 -9
- schemathesis/contrib/openapi/formats/uuid.py +0 -16
- schemathesis/contrib/unique_data.py +0 -41
- schemathesis/exceptions.py +0 -571
- schemathesis/extra/_aiohttp.py +0 -28
- schemathesis/extra/_flask.py +0 -13
- schemathesis/extra/_server.py +0 -18
- schemathesis/failures.py +0 -277
- schemathesis/fixups/__init__.py +0 -37
- schemathesis/fixups/fast_api.py +0 -41
- schemathesis/fixups/utf8_bom.py +0 -28
- schemathesis/generation/_methods.py +0 -44
- schemathesis/graphql.py +0 -3
- schemathesis/internal/__init__.py +0 -7
- schemathesis/internal/checks.py +0 -84
- schemathesis/internal/copy.py +0 -32
- schemathesis/internal/datetime.py +0 -5
- schemathesis/internal/deprecation.py +0 -38
- schemathesis/internal/diff.py +0 -15
- schemathesis/internal/extensions.py +0 -27
- schemathesis/internal/jsonschema.py +0 -36
- schemathesis/internal/transformation.py +0 -26
- schemathesis/internal/validation.py +0 -34
- schemathesis/lazy.py +0 -474
- schemathesis/loaders.py +0 -122
- schemathesis/models.py +0 -1341
- schemathesis/parameters.py +0 -90
- schemathesis/runner/__init__.py +0 -605
- schemathesis/runner/events.py +0 -389
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/context.py +0 -104
- schemathesis/runner/impl/core.py +0 -1246
- schemathesis/runner/impl/solo.py +0 -80
- schemathesis/runner/impl/threadpool.py +0 -391
- schemathesis/runner/serialization.py +0 -544
- schemathesis/sanitization.py +0 -252
- schemathesis/serializers.py +0 -328
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -11
- schemathesis/service/ci.py +0 -202
- schemathesis/service/client.py +0 -133
- schemathesis/service/constants.py +0 -38
- schemathesis/service/events.py +0 -61
- schemathesis/service/extensions.py +0 -224
- schemathesis/service/hosts.py +0 -111
- schemathesis/service/metadata.py +0 -71
- schemathesis/service/models.py +0 -258
- schemathesis/service/report.py +0 -255
- schemathesis/service/serialization.py +0 -173
- schemathesis/service/usage.py +0 -66
- schemathesis/specs/graphql/loaders.py +0 -364
- schemathesis/specs/openapi/expressions/context.py +0 -16
- schemathesis/specs/openapi/loaders.py +0 -708
- schemathesis/specs/openapi/stateful/statistic.py +0 -198
- schemathesis/specs/openapi/stateful/types.py +0 -14
- schemathesis/specs/openapi/validation.py +0 -26
- schemathesis/stateful/__init__.py +0 -147
- schemathesis/stateful/config.py +0 -97
- schemathesis/stateful/context.py +0 -135
- schemathesis/stateful/events.py +0 -274
- schemathesis/stateful/runner.py +0 -309
- schemathesis/stateful/sink.py +0 -68
- schemathesis/stateful/statistic.py +0 -22
- schemathesis/stateful/validation.py +0 -100
- schemathesis/targets.py +0 -77
- schemathesis/transports/__init__.py +0 -359
- schemathesis/transports/asgi.py +0 -7
- schemathesis/transports/auth.py +0 -38
- schemathesis/transports/headers.py +0 -36
- schemathesis/transports/responses.py +0 -57
- schemathesis/types.py +0 -44
- schemathesis/utils.py +0 -164
- schemathesis-3.39.7.dist-info/RECORD +0 -160
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
- /schemathesis/{_lazy_import.py → core/lazy_import.py} +0 -0
- /schemathesis/{internal → core}/result.py +0 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/WHEEL +0 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,104 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import http.client
|
4
|
+
from typing import TYPE_CHECKING, Any, Callable, NoReturn
|
5
|
+
|
6
|
+
from schemathesis.core.errors import LoaderError, LoaderErrorKind, get_request_error_extras, get_request_error_message
|
7
|
+
from schemathesis.core.transport import DEFAULT_RESPONSE_TIMEOUT, USER_AGENT
|
8
|
+
|
9
|
+
if TYPE_CHECKING:
|
10
|
+
import requests
|
11
|
+
|
12
|
+
|
13
|
+
def prepare_request_kwargs(kwargs: dict[str, Any]) -> None:
|
14
|
+
"""Prepare common request kwargs."""
|
15
|
+
headers = kwargs.setdefault("headers", {})
|
16
|
+
if "user-agent" not in {header.lower() for header in headers}:
|
17
|
+
kwargs["headers"]["User-Agent"] = USER_AGENT
|
18
|
+
|
19
|
+
|
20
|
+
def handle_request_error(exc: requests.RequestException) -> NoReturn:
|
21
|
+
"""Handle request-level errors."""
|
22
|
+
import requests
|
23
|
+
|
24
|
+
url = exc.request.url if exc.request is not None else None
|
25
|
+
if isinstance(exc, requests.exceptions.SSLError):
|
26
|
+
kind = LoaderErrorKind.CONNECTION_SSL
|
27
|
+
elif isinstance(exc, requests.exceptions.ConnectionError):
|
28
|
+
kind = LoaderErrorKind.CONNECTION_OTHER
|
29
|
+
else:
|
30
|
+
kind = LoaderErrorKind.NETWORK_OTHER
|
31
|
+
raise LoaderError(
|
32
|
+
message=get_request_error_message(exc),
|
33
|
+
kind=kind,
|
34
|
+
url=url,
|
35
|
+
extras=get_request_error_extras(exc),
|
36
|
+
) from exc
|
37
|
+
|
38
|
+
|
39
|
+
def raise_for_status(response: requests.Response) -> requests.Response:
|
40
|
+
"""Handle response status codes."""
|
41
|
+
status_code = response.status_code
|
42
|
+
if status_code < 400:
|
43
|
+
return response
|
44
|
+
|
45
|
+
reason = http.client.responses.get(status_code, "Unknown")
|
46
|
+
if status_code >= 500:
|
47
|
+
message = f"Failed to load schema due to server error (HTTP {status_code} {reason})"
|
48
|
+
kind = LoaderErrorKind.HTTP_SERVER_ERROR
|
49
|
+
else:
|
50
|
+
message = f"Failed to load schema due to client error (HTTP {status_code} {reason})"
|
51
|
+
kind = (
|
52
|
+
LoaderErrorKind.HTTP_FORBIDDEN
|
53
|
+
if status_code == 403
|
54
|
+
else LoaderErrorKind.HTTP_NOT_FOUND
|
55
|
+
if status_code == 404
|
56
|
+
else LoaderErrorKind.HTTP_CLIENT_ERROR
|
57
|
+
)
|
58
|
+
raise LoaderError(message=message, kind=kind, url=response.request.url, extras=[])
|
59
|
+
|
60
|
+
|
61
|
+
def make_request(func: Callable[..., requests.Response], url: str, **kwargs: Any) -> requests.Response:
|
62
|
+
"""Make HTTP request with error handling."""
|
63
|
+
import requests
|
64
|
+
|
65
|
+
try:
|
66
|
+
response = func(url, **kwargs)
|
67
|
+
return raise_for_status(response)
|
68
|
+
except requests.RequestException as exc:
|
69
|
+
handle_request_error(exc)
|
70
|
+
|
71
|
+
|
72
|
+
WAIT_FOR_SCHEMA_INTERVAL = 0.05
|
73
|
+
|
74
|
+
|
75
|
+
def load_from_url(
|
76
|
+
func: Callable[..., requests.Response],
|
77
|
+
*,
|
78
|
+
url: str,
|
79
|
+
wait_for_schema: float | None = None,
|
80
|
+
**kwargs: Any,
|
81
|
+
) -> requests.Response:
|
82
|
+
"""Load schema from URL with retries."""
|
83
|
+
import backoff
|
84
|
+
import requests
|
85
|
+
|
86
|
+
kwargs.setdefault("timeout", DEFAULT_RESPONSE_TIMEOUT)
|
87
|
+
prepare_request_kwargs(kwargs)
|
88
|
+
|
89
|
+
if wait_for_schema is not None:
|
90
|
+
func = backoff.on_exception(
|
91
|
+
backoff.constant,
|
92
|
+
requests.exceptions.ConnectionError,
|
93
|
+
max_time=wait_for_schema,
|
94
|
+
interval=WAIT_FOR_SCHEMA_INTERVAL,
|
95
|
+
)(func)
|
96
|
+
|
97
|
+
return make_request(func, url, **kwargs)
|
98
|
+
|
99
|
+
|
100
|
+
def require_relative_url(url: str) -> None:
|
101
|
+
"""Raise an error if the URL is not relative."""
|
102
|
+
# Deliberately simplistic approach
|
103
|
+
if "://" in url or url.startswith("//"):
|
104
|
+
raise ValueError("Schema path should be relative for WSGI/ASGI loaders")
|
@@ -0,0 +1,66 @@
|
|
1
|
+
"""A lightweight mechanism to attach Schemathesis-specific metadata to test functions."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
from dataclasses import dataclass
|
6
|
+
from typing import Callable, Generic, TypeVar
|
7
|
+
|
8
|
+
from schemathesis.core import NOT_SET, NotSet
|
9
|
+
|
10
|
+
METADATA_ATTR = "_schemathesis_metadata"
|
11
|
+
|
12
|
+
|
13
|
+
@dataclass
|
14
|
+
class SchemathesisMetadata:
|
15
|
+
"""Container for all Schemathesis-specific data attached to test functions."""
|
16
|
+
|
17
|
+
|
18
|
+
T = TypeVar("T")
|
19
|
+
|
20
|
+
|
21
|
+
class Mark(Generic[T]):
|
22
|
+
"""Access to specific attributes in SchemathesisMetadata."""
|
23
|
+
|
24
|
+
def __init__(
|
25
|
+
self, *, attr_name: str, default: T | Callable[[], T] | None = None, check: Callable[[T], bool] | None = None
|
26
|
+
) -> None:
|
27
|
+
self.attr_name = attr_name
|
28
|
+
self._default = default
|
29
|
+
self._check = check
|
30
|
+
|
31
|
+
def _get_default(self) -> T | None:
|
32
|
+
if callable(self._default):
|
33
|
+
return self._default()
|
34
|
+
return self._default
|
35
|
+
|
36
|
+
def _check_value(self, value: T) -> bool:
|
37
|
+
if self._check is not None:
|
38
|
+
return self._check(value)
|
39
|
+
return True
|
40
|
+
|
41
|
+
def get(self, func: Callable) -> T | None:
|
42
|
+
"""Get marker value if it's set."""
|
43
|
+
metadata = getattr(func, METADATA_ATTR, None)
|
44
|
+
if metadata is None:
|
45
|
+
return self._get_default()
|
46
|
+
value = getattr(metadata, self.attr_name, NOT_SET)
|
47
|
+
if value is NOT_SET:
|
48
|
+
return self._get_default()
|
49
|
+
assert not isinstance(value, NotSet)
|
50
|
+
if self._check_value(value):
|
51
|
+
return value
|
52
|
+
return self._get_default()
|
53
|
+
|
54
|
+
def set(self, func: Callable, value: T) -> None:
|
55
|
+
"""Set marker value, creating metadata if needed."""
|
56
|
+
if not hasattr(func, METADATA_ATTR):
|
57
|
+
setattr(func, METADATA_ATTR, SchemathesisMetadata())
|
58
|
+
metadata = getattr(func, METADATA_ATTR)
|
59
|
+
setattr(metadata, self.attr_name, value)
|
60
|
+
|
61
|
+
def is_set(self, func: Callable) -> bool:
|
62
|
+
"""Check if function has metadata with this marker set."""
|
63
|
+
metadata = getattr(func, METADATA_ATTR, None)
|
64
|
+
if metadata is None:
|
65
|
+
return False
|
66
|
+
return hasattr(metadata, self.attr_name)
|
@@ -1,6 +1,8 @@
|
|
1
1
|
from functools import lru_cache
|
2
2
|
from typing import Generator, Tuple
|
3
3
|
|
4
|
+
from schemathesis.core.errors import MalformedMediaType
|
5
|
+
|
4
6
|
|
5
7
|
def _parseparam(s: str) -> Generator[str, None, None]:
|
6
8
|
while s[:1] == ";":
|
@@ -15,7 +17,7 @@ def _parseparam(s: str) -> Generator[str, None, None]:
|
|
15
17
|
s = s[end:]
|
16
18
|
|
17
19
|
|
18
|
-
def
|
20
|
+
def _parse_header(line: str) -> Tuple[str, dict]:
|
19
21
|
parts = _parseparam(";" + line)
|
20
22
|
key = parts.__next__()
|
21
23
|
pdict = {}
|
@@ -32,36 +34,36 @@ def parse_header(line: str) -> Tuple[str, dict]:
|
|
32
34
|
|
33
35
|
|
34
36
|
@lru_cache
|
35
|
-
def
|
37
|
+
def parse(media_type: str) -> Tuple[str, str]:
|
36
38
|
"""Parse Content Type and return main type and subtype."""
|
37
39
|
try:
|
38
|
-
|
39
|
-
main_type, sub_type =
|
40
|
+
media_type, _ = _parse_header(media_type)
|
41
|
+
main_type, sub_type = media_type.split("/", 1)
|
40
42
|
except ValueError as exc:
|
41
|
-
raise
|
43
|
+
raise MalformedMediaType(f"Malformed media type: `{media_type}`") from exc
|
42
44
|
return main_type.lower(), sub_type.lower()
|
43
45
|
|
44
46
|
|
45
|
-
def
|
47
|
+
def is_json(value: str) -> bool:
|
46
48
|
"""Detect whether the content type is JSON-compatible.
|
47
49
|
|
48
50
|
For example - ``application/problem+json`` matches.
|
49
51
|
"""
|
50
|
-
main, sub =
|
52
|
+
main, sub = parse(value)
|
51
53
|
return main == "application" and (sub == "json" or sub.endswith("+json"))
|
52
54
|
|
53
55
|
|
54
|
-
def
|
56
|
+
def is_yaml(value: str) -> bool:
|
55
57
|
"""Detect whether the content type is YAML-compatible."""
|
56
58
|
return value in ("text/yaml", "text/x-yaml", "application/x-yaml", "text/vnd.yaml")
|
57
59
|
|
58
60
|
|
59
|
-
def
|
61
|
+
def is_plain_text(value: str) -> bool:
|
60
62
|
"""Detect variations of the ``text/plain`` media type."""
|
61
|
-
return
|
63
|
+
return parse(value) == ("text", "plain")
|
62
64
|
|
63
65
|
|
64
|
-
def
|
66
|
+
def is_xml(value: str) -> bool:
|
65
67
|
"""Detect variations of the ``application/xml`` media type."""
|
66
|
-
_, sub =
|
68
|
+
_, sub = parse(value)
|
67
69
|
return sub == "xml" or sub.endswith("+xml")
|
@@ -0,0 +1,197 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from collections.abc import MutableMapping, MutableSequence
|
4
|
+
from dataclasses import dataclass, replace
|
5
|
+
from typing import Any
|
6
|
+
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
|
7
|
+
|
8
|
+
from schemathesis.core import NOT_SET, NotSet
|
9
|
+
|
10
|
+
# Exact keys to sanitize
|
11
|
+
DEFAULT_KEYS_TO_SANITIZE = frozenset(
|
12
|
+
(
|
13
|
+
"phpsessid",
|
14
|
+
"xsrf-token",
|
15
|
+
"_csrf",
|
16
|
+
"_csrf_token",
|
17
|
+
"_session",
|
18
|
+
"_xsrf",
|
19
|
+
"aiohttp_session",
|
20
|
+
"api_key",
|
21
|
+
"api-key",
|
22
|
+
"apikey",
|
23
|
+
"auth",
|
24
|
+
"authorization",
|
25
|
+
"connect.sid",
|
26
|
+
"cookie",
|
27
|
+
"credentials",
|
28
|
+
"csrf",
|
29
|
+
"csrf_token",
|
30
|
+
"csrf-token",
|
31
|
+
"csrftoken",
|
32
|
+
"ip_address",
|
33
|
+
"mysql_pwd",
|
34
|
+
"passwd",
|
35
|
+
"password",
|
36
|
+
"private_key",
|
37
|
+
"private-key",
|
38
|
+
"privatekey",
|
39
|
+
"remote_addr",
|
40
|
+
"remote-addr",
|
41
|
+
"secret",
|
42
|
+
"session",
|
43
|
+
"sessionid",
|
44
|
+
"set_cookie",
|
45
|
+
"set-cookie",
|
46
|
+
"token",
|
47
|
+
"x_api_key",
|
48
|
+
"x-api-key",
|
49
|
+
"x_csrftoken",
|
50
|
+
"x-csrftoken",
|
51
|
+
"x_forwarded_for",
|
52
|
+
"x-forwarded-for",
|
53
|
+
"x_real_ip",
|
54
|
+
"x-real-ip",
|
55
|
+
)
|
56
|
+
)
|
57
|
+
|
58
|
+
# Markers indicating potentially sensitive keys
|
59
|
+
DEFAULT_SENSITIVE_MARKERS = frozenset(
|
60
|
+
(
|
61
|
+
"token",
|
62
|
+
"key",
|
63
|
+
"secret",
|
64
|
+
"password",
|
65
|
+
"auth",
|
66
|
+
"session",
|
67
|
+
"passwd",
|
68
|
+
"credential",
|
69
|
+
)
|
70
|
+
)
|
71
|
+
|
72
|
+
DEFAULT_REPLACEMENT = "[Filtered]"
|
73
|
+
|
74
|
+
|
75
|
+
@dataclass
|
76
|
+
class SanitizationConfig:
|
77
|
+
"""Configuration class for sanitizing sensitive data."""
|
78
|
+
|
79
|
+
keys_to_sanitize: frozenset[str] = DEFAULT_KEYS_TO_SANITIZE
|
80
|
+
sensitive_markers: frozenset[str] = DEFAULT_SENSITIVE_MARKERS
|
81
|
+
replacement: str = DEFAULT_REPLACEMENT
|
82
|
+
|
83
|
+
@classmethod
|
84
|
+
def from_config(
|
85
|
+
cls,
|
86
|
+
base_config: SanitizationConfig,
|
87
|
+
*,
|
88
|
+
replacement: str | NotSet = NOT_SET,
|
89
|
+
keys_to_sanitize: list[str] | NotSet = NOT_SET,
|
90
|
+
sensitive_markers: list[str] | NotSet = NOT_SET,
|
91
|
+
) -> SanitizationConfig:
|
92
|
+
"""Create a new config by replacing specified values."""
|
93
|
+
kwargs: dict[str, Any] = {}
|
94
|
+
if not isinstance(replacement, NotSet):
|
95
|
+
kwargs["replacement"] = replacement
|
96
|
+
if not isinstance(keys_to_sanitize, NotSet):
|
97
|
+
kwargs["keys_to_sanitize"] = frozenset(key.lower() for key in keys_to_sanitize)
|
98
|
+
if not isinstance(sensitive_markers, NotSet):
|
99
|
+
kwargs["sensitive_markers"] = frozenset(marker.lower() for marker in sensitive_markers)
|
100
|
+
return replace(base_config, **kwargs)
|
101
|
+
|
102
|
+
def extend(
|
103
|
+
self,
|
104
|
+
*,
|
105
|
+
keys_to_sanitize: list[str] | NotSet = NOT_SET,
|
106
|
+
sensitive_markers: list[str] | NotSet = NOT_SET,
|
107
|
+
) -> SanitizationConfig:
|
108
|
+
"""Create a new config by extending current sets."""
|
109
|
+
config = self
|
110
|
+
if not isinstance(keys_to_sanitize, NotSet):
|
111
|
+
new_keys = config.keys_to_sanitize.union(key.lower() for key in keys_to_sanitize)
|
112
|
+
config = replace(config, keys_to_sanitize=new_keys)
|
113
|
+
|
114
|
+
if not isinstance(sensitive_markers, NotSet):
|
115
|
+
new_markers = config.sensitive_markers.union(marker.lower() for marker in sensitive_markers)
|
116
|
+
config = replace(config, sensitive_markers=new_markers)
|
117
|
+
|
118
|
+
return config
|
119
|
+
|
120
|
+
|
121
|
+
_DEFAULT_SANITIZATION_CONFIG = SanitizationConfig()
|
122
|
+
|
123
|
+
|
124
|
+
def configure(
|
125
|
+
replacement: str | NotSet = NOT_SET,
|
126
|
+
keys_to_sanitize: list[str] | NotSet = NOT_SET,
|
127
|
+
sensitive_markers: list[str] | NotSet = NOT_SET,
|
128
|
+
) -> None:
|
129
|
+
"""Replace current sanitization configuration."""
|
130
|
+
global _DEFAULT_SANITIZATION_CONFIG
|
131
|
+
_DEFAULT_SANITIZATION_CONFIG = SanitizationConfig.from_config(
|
132
|
+
_DEFAULT_SANITIZATION_CONFIG,
|
133
|
+
replacement=replacement,
|
134
|
+
keys_to_sanitize=keys_to_sanitize,
|
135
|
+
sensitive_markers=sensitive_markers,
|
136
|
+
)
|
137
|
+
|
138
|
+
|
139
|
+
def extend(
|
140
|
+
keys_to_sanitize: list[str] | NotSet = NOT_SET,
|
141
|
+
sensitive_markers: list[str] | NotSet = NOT_SET,
|
142
|
+
) -> None:
|
143
|
+
"""Extend current sanitization configuration."""
|
144
|
+
global _DEFAULT_SANITIZATION_CONFIG
|
145
|
+
_DEFAULT_SANITIZATION_CONFIG = _DEFAULT_SANITIZATION_CONFIG.extend(
|
146
|
+
keys_to_sanitize=keys_to_sanitize,
|
147
|
+
sensitive_markers=sensitive_markers,
|
148
|
+
)
|
149
|
+
|
150
|
+
|
151
|
+
def sanitize_value(item: Any, *, config: SanitizationConfig | None = None) -> None:
|
152
|
+
"""Sanitize sensitive values within a given item.
|
153
|
+
|
154
|
+
This function is recursive and will sanitize sensitive data within nested
|
155
|
+
dictionaries and lists as well.
|
156
|
+
"""
|
157
|
+
config = config or _DEFAULT_SANITIZATION_CONFIG
|
158
|
+
if isinstance(item, MutableMapping):
|
159
|
+
for key in list(item.keys()):
|
160
|
+
lower_key = key.lower()
|
161
|
+
if lower_key in config.keys_to_sanitize or any(marker in lower_key for marker in config.sensitive_markers):
|
162
|
+
if isinstance(item[key], list):
|
163
|
+
item[key] = [config.replacement]
|
164
|
+
else:
|
165
|
+
item[key] = config.replacement
|
166
|
+
for value in item.values():
|
167
|
+
if isinstance(value, (MutableMapping, MutableSequence)):
|
168
|
+
sanitize_value(value, config=config)
|
169
|
+
elif isinstance(item, MutableSequence):
|
170
|
+
for value in item:
|
171
|
+
if isinstance(value, (MutableMapping, MutableSequence)):
|
172
|
+
sanitize_value(value, config=config)
|
173
|
+
|
174
|
+
|
175
|
+
def sanitize_url(url: str, *, config: SanitizationConfig | None = None) -> str:
|
176
|
+
"""Sanitize sensitive parts of a given URL.
|
177
|
+
|
178
|
+
This function will sanitize the authority and query parameters in the URL.
|
179
|
+
"""
|
180
|
+
config = config or _DEFAULT_SANITIZATION_CONFIG
|
181
|
+
parsed = urlsplit(url)
|
182
|
+
|
183
|
+
# Sanitize authority
|
184
|
+
netloc_parts = parsed.netloc.split("@")
|
185
|
+
if len(netloc_parts) > 1:
|
186
|
+
netloc = f"{config.replacement}@{netloc_parts[-1]}"
|
187
|
+
else:
|
188
|
+
netloc = parsed.netloc
|
189
|
+
|
190
|
+
# Sanitize query parameters
|
191
|
+
query = parse_qs(parsed.query, keep_blank_values=True)
|
192
|
+
sanitize_value(query, config=config)
|
193
|
+
sanitized_query = urlencode(query, doseq=True)
|
194
|
+
|
195
|
+
# Reconstruct the URL
|
196
|
+
sanitized_url_parts = parsed._replace(netloc=netloc, query=sanitized_query)
|
197
|
+
return urlunsplit(sanitized_url_parts)
|
@@ -1,14 +1,23 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
from
|
3
|
+
from contextlib import nullcontext
|
4
|
+
from typing import TYPE_CHECKING, ContextManager
|
5
|
+
from urllib.parse import urlparse
|
4
6
|
|
5
|
-
from .
|
6
|
-
from .exceptions import UsageError
|
7
|
+
from schemathesis.core.errors import InvalidRateLimit
|
7
8
|
|
8
9
|
if TYPE_CHECKING:
|
9
10
|
from pyrate_limiter import Duration, Limiter
|
10
11
|
|
11
12
|
|
13
|
+
def ratelimit(rate_limiter: Limiter | None, base_url: str | None) -> ContextManager:
|
14
|
+
"""Limit the rate of sending generated requests."""
|
15
|
+
label = urlparse(base_url).netloc
|
16
|
+
if rate_limiter is not None:
|
17
|
+
rate_limiter.try_acquire(label)
|
18
|
+
return nullcontext()
|
19
|
+
|
20
|
+
|
12
21
|
def parse_units(rate: str) -> tuple[int, int]:
|
13
22
|
from pyrate_limiter import Duration
|
14
23
|
|
@@ -21,17 +30,10 @@ def parse_units(rate: str) -> tuple[int, int]:
|
|
21
30
|
"d": Duration.DAY,
|
22
31
|
}.get(interval_text)
|
23
32
|
if interval is None:
|
24
|
-
raise
|
33
|
+
raise InvalidRateLimit(rate)
|
25
34
|
return int(limit), interval
|
26
35
|
except ValueError as exc:
|
27
|
-
raise
|
28
|
-
|
29
|
-
|
30
|
-
def invalid_rate(value: str) -> UsageError:
|
31
|
-
return UsageError(
|
32
|
-
f"Invalid rate limit value: `{value}`. Should be in form `limit/interval`. "
|
33
|
-
"Example: `10/m` for 10 requests per minute."
|
34
|
-
)
|
36
|
+
raise InvalidRateLimit(rate) from exc
|
35
37
|
|
36
38
|
|
37
39
|
def _get_max_delay(value: int, unit: Duration) -> int:
|
@@ -51,11 +53,8 @@ def _get_max_delay(value: int, unit: Duration) -> int:
|
|
51
53
|
|
52
54
|
|
53
55
|
def build_limiter(rate: str) -> Limiter:
|
54
|
-
from
|
56
|
+
from pyrate_limiter import Limiter, Rate
|
55
57
|
|
56
58
|
limit, interval = parse_units(rate)
|
57
59
|
rate = Rate(limit, interval)
|
58
|
-
|
59
|
-
if IS_PYRATE_LIMITER_ABOVE_3:
|
60
|
-
kwargs["max_delay"] = _get_max_delay(limit, interval)
|
61
|
-
return Limiter(rate, **kwargs)
|
60
|
+
return Limiter(rate, max_delay=_get_max_delay(limit, interval))
|
@@ -0,0 +1,31 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import Callable, Generic, Sequence, TypeVar, Union
|
4
|
+
|
5
|
+
T = TypeVar("T", bound=Union[Callable, type])
|
6
|
+
|
7
|
+
|
8
|
+
class Registry(Generic[T]):
|
9
|
+
"""Container for Schemathesis extensions."""
|
10
|
+
|
11
|
+
__slots__ = ("_items",)
|
12
|
+
|
13
|
+
def __init__(self) -> None:
|
14
|
+
self._items: dict[str, T] = {}
|
15
|
+
|
16
|
+
def register(self, item: T) -> T:
|
17
|
+
self._items[item.__name__] = item
|
18
|
+
return item
|
19
|
+
|
20
|
+
def unregister(self, name: str) -> None:
|
21
|
+
del self._items[name]
|
22
|
+
|
23
|
+
def get_all_names(self) -> list[str]:
|
24
|
+
return list(self._items)
|
25
|
+
|
26
|
+
def get_all(self) -> list[T]:
|
27
|
+
return list(self._items.values())
|
28
|
+
|
29
|
+
def get_by_names(self, names: Sequence[str]) -> list[T]:
|
30
|
+
"""Get items by their names."""
|
31
|
+
return [self._items[name] for name in names]
|
@@ -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
|