schemathesis 3.39.15__py3-none-any.whl → 4.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- schemathesis/__init__.py +41 -79
- schemathesis/auths.py +111 -122
- schemathesis/checks.py +169 -60
- schemathesis/cli/__init__.py +15 -2117
- schemathesis/cli/commands/__init__.py +85 -0
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +590 -0
- schemathesis/cli/commands/run/context.py +204 -0
- schemathesis/cli/commands/run/events.py +60 -0
- schemathesis/cli/commands/run/executor.py +157 -0
- schemathesis/cli/commands/run/filters.py +53 -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 +474 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +55 -0
- schemathesis/cli/commands/run/handlers/output.py +1628 -0
- schemathesis/cli/commands/run/loaders.py +114 -0
- schemathesis/cli/commands/run/validation.py +246 -0
- schemathesis/cli/constants.py +5 -58
- schemathesis/cli/core.py +19 -0
- schemathesis/cli/ext/fs.py +16 -0
- schemathesis/cli/ext/groups.py +84 -0
- schemathesis/cli/{options.py → ext/options.py} +36 -34
- schemathesis/config/__init__.py +189 -0
- schemathesis/config/_auth.py +51 -0
- schemathesis/config/_checks.py +268 -0
- schemathesis/config/_diff_base.py +99 -0
- schemathesis/config/_env.py +21 -0
- schemathesis/config/_error.py +156 -0
- schemathesis/config/_generation.py +149 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +327 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +187 -0
- schemathesis/config/_projects.py +527 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +120 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/_warnings.py +25 -0
- schemathesis/config/schema.json +885 -0
- schemathesis/core/__init__.py +67 -0
- schemathesis/core/compat.py +32 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +58 -0
- schemathesis/core/deserialization.py +65 -0
- schemathesis/core/errors.py +459 -0
- schemathesis/core/failures.py +315 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/hooks.py +20 -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/core/output/__init__.py +46 -0
- schemathesis/core/output/sanitization.py +54 -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 +223 -0
- schemathesis/core/validation.py +54 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +28 -0
- schemathesis/engine/context.py +118 -0
- schemathesis/engine/control.py +36 -0
- schemathesis/engine/core.py +169 -0
- schemathesis/engine/errors.py +464 -0
- schemathesis/engine/events.py +258 -0
- schemathesis/engine/phases/__init__.py +88 -0
- schemathesis/{runner → engine/phases}/probes.py +52 -68
- schemathesis/engine/phases/stateful/__init__.py +68 -0
- schemathesis/engine/phases/stateful/_executor.py +356 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +212 -0
- schemathesis/engine/phases/unit/_executor.py +416 -0
- schemathesis/engine/phases/unit/_pool.py +82 -0
- schemathesis/engine/recorder.py +247 -0
- schemathesis/errors.py +43 -0
- schemathesis/filters.py +17 -98
- schemathesis/generation/__init__.py +5 -33
- schemathesis/generation/case.py +317 -0
- schemathesis/generation/coverage.py +282 -175
- schemathesis/generation/hypothesis/__init__.py +36 -0
- schemathesis/generation/hypothesis/builder.py +800 -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/metrics.py +93 -0
- schemathesis/generation/modes.py +20 -0
- schemathesis/generation/overrides.py +116 -0
- schemathesis/generation/stateful/__init__.py +37 -0
- schemathesis/generation/stateful/state_machine.py +278 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +284 -0
- schemathesis/hooks.py +80 -101
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +455 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +72 -0
- schemathesis/openapi/loaders.py +313 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +281 -0
- schemathesis/pytest/loaders.py +36 -0
- schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +128 -108
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +537 -273
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/_cache.py +1 -2
- schemathesis/specs/graphql/scalars.py +42 -6
- schemathesis/specs/graphql/schemas.py +141 -137
- 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 +142 -156
- schemathesis/specs/openapi/checks.py +368 -257
- schemathesis/specs/openapi/converter.py +4 -4
- schemathesis/specs/openapi/definitions.py +1 -1
- schemathesis/specs/openapi/examples.py +23 -21
- schemathesis/specs/openapi/expressions/__init__.py +31 -19
- schemathesis/specs/openapi/expressions/extractors.py +1 -4
- schemathesis/specs/openapi/expressions/lexer.py +1 -1
- schemathesis/specs/openapi/expressions/nodes.py +36 -41
- schemathesis/specs/openapi/expressions/parser.py +1 -1
- schemathesis/specs/openapi/formats.py +35 -7
- schemathesis/specs/openapi/media_types.py +53 -12
- schemathesis/specs/openapi/negative/__init__.py +7 -4
- schemathesis/specs/openapi/negative/mutations.py +6 -5
- schemathesis/specs/openapi/parameters.py +7 -10
- schemathesis/specs/openapi/patterns.py +94 -31
- schemathesis/specs/openapi/references.py +12 -53
- schemathesis/specs/openapi/schemas.py +238 -308
- schemathesis/specs/openapi/security.py +1 -1
- schemathesis/specs/openapi/serialization.py +12 -6
- schemathesis/specs/openapi/stateful/__init__.py +268 -133
- schemathesis/specs/openapi/stateful/control.py +87 -0
- schemathesis/specs/openapi/stateful/links.py +209 -0
- schemathesis/transport/__init__.py +142 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +124 -0
- schemathesis/transport/requests.py +244 -0
- schemathesis/{_xml.py → transport/serialization.py} +69 -11
- schemathesis/transport/wsgi.py +171 -0
- schemathesis-4.0.0.dist-info/METADATA +204 -0
- schemathesis-4.0.0.dist-info/RECORD +164 -0
- {schemathesis-3.39.15.dist-info → schemathesis-4.0.0.dist-info}/entry_points.txt +1 -1
- {schemathesis-3.39.15.dist-info → schemathesis-4.0.0.dist-info}/licenses/LICENSE +1 -1
- schemathesis/_compat.py +0 -74
- schemathesis/_dependency_versions.py +0 -19
- schemathesis/_hypothesis.py +0 -712
- schemathesis/_override.py +0 -50
- schemathesis/_patches.py +0 -21
- schemathesis/_rate_limiter.py +0 -7
- schemathesis/cli/callbacks.py +0 -466
- schemathesis/cli/cassettes.py +0 -561
- 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 -920
- 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 -54
- schemathesis/contrib/__init__.py +0 -11
- schemathesis/contrib/openapi/__init__.py +0 -11
- schemathesis/contrib/openapi/fill_missing_examples.py +0 -24
- 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/experimental/__init__.py +0 -109
- schemathesis/extra/_aiohttp.py +0 -28
- schemathesis/extra/_flask.py +0 -13
- schemathesis/extra/_server.py +0 -18
- schemathesis/failures.py +0 -284
- 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 -86
- schemathesis/internal/copy.py +0 -32
- schemathesis/internal/datetime.py +0 -5
- schemathesis/internal/deprecation.py +0 -37
- schemathesis/internal/diff.py +0 -15
- schemathesis/internal/extensions.py +0 -27
- schemathesis/internal/jsonschema.py +0 -36
- schemathesis/internal/output.py +0 -68
- 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 -88
- schemathesis/runner/impl/core.py +0 -1280
- 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/links.py +0 -389
- schemathesis/specs/openapi/loaders.py +0 -707
- 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/state_machine.py +0 -328
- schemathesis/stateful/statistic.py +0 -22
- schemathesis/stateful/validation.py +0 -100
- schemathesis/targets.py +0 -77
- schemathesis/transports/__init__.py +0 -369
- 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.15.dist-info/METADATA +0 -293
- schemathesis-3.39.15.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.15.dist-info → schemathesis-4.0.0.dist-info}/WHEEL +0 -0
@@ -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, ValueError):
|
110
|
+
return UNRESOLVABLE
|
111
|
+
else:
|
112
|
+
return UNRESOLVABLE
|
113
|
+
return target
|
@@ -0,0 +1,223 @@
|
|
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 httpx
|
11
|
+
import requests
|
12
|
+
from werkzeug.test import TestResponse
|
13
|
+
|
14
|
+
from schemathesis.generation.overrides import Override
|
15
|
+
|
16
|
+
USER_AGENT = f"schemathesis/{SCHEMATHESIS_VERSION}"
|
17
|
+
DEFAULT_RESPONSE_TIMEOUT = 10
|
18
|
+
|
19
|
+
|
20
|
+
def prepare_urlencoded(data: Any) -> Any:
|
21
|
+
if isinstance(data, list):
|
22
|
+
output = []
|
23
|
+
for item in data:
|
24
|
+
if isinstance(item, dict):
|
25
|
+
for key, value in item.items():
|
26
|
+
output.append((key, value))
|
27
|
+
else:
|
28
|
+
output.append((item, "arbitrary-value"))
|
29
|
+
return output
|
30
|
+
return data
|
31
|
+
|
32
|
+
|
33
|
+
class Response:
|
34
|
+
"""HTTP response wrapper that normalizes different transport implementations.
|
35
|
+
|
36
|
+
Provides a consistent interface for accessing response data whether the request
|
37
|
+
was made via HTTP, ASGI, or WSGI transports.
|
38
|
+
"""
|
39
|
+
|
40
|
+
status_code: int
|
41
|
+
"""HTTP status code (e.g., 200, 404, 500)."""
|
42
|
+
headers: dict[str, list[str]]
|
43
|
+
"""Response headers with lowercase keys and list values."""
|
44
|
+
content: bytes
|
45
|
+
"""Raw response body as bytes."""
|
46
|
+
request: requests.PreparedRequest
|
47
|
+
"""The request that generated this response."""
|
48
|
+
elapsed: float
|
49
|
+
"""Response time in seconds."""
|
50
|
+
verify: bool
|
51
|
+
"""Whether TLS verification was enabled for the request."""
|
52
|
+
message: str
|
53
|
+
"""HTTP status message (e.g., "OK", "Not Found")."""
|
54
|
+
http_version: str
|
55
|
+
"""HTTP protocol version ("1.0" or "1.1")."""
|
56
|
+
encoding: str | None
|
57
|
+
"""Character encoding for text content, if detected."""
|
58
|
+
_override: Override | None
|
59
|
+
|
60
|
+
__slots__ = (
|
61
|
+
"status_code",
|
62
|
+
"headers",
|
63
|
+
"content",
|
64
|
+
"request",
|
65
|
+
"elapsed",
|
66
|
+
"verify",
|
67
|
+
"_json",
|
68
|
+
"message",
|
69
|
+
"http_version",
|
70
|
+
"encoding",
|
71
|
+
"_encoded_body",
|
72
|
+
"_override",
|
73
|
+
)
|
74
|
+
|
75
|
+
def __init__(
|
76
|
+
self,
|
77
|
+
status_code: int,
|
78
|
+
headers: Mapping[str, list[str]],
|
79
|
+
content: bytes,
|
80
|
+
request: requests.PreparedRequest,
|
81
|
+
elapsed: float,
|
82
|
+
verify: bool,
|
83
|
+
message: str = "",
|
84
|
+
http_version: str = "1.1",
|
85
|
+
encoding: str | None = None,
|
86
|
+
_override: Override | None = None,
|
87
|
+
):
|
88
|
+
self.status_code = status_code
|
89
|
+
self.headers = {key.lower(): value for key, value in headers.items()}
|
90
|
+
assert all(isinstance(v, list) for v in headers.values())
|
91
|
+
self.content = content
|
92
|
+
self.request = request
|
93
|
+
self.elapsed = elapsed
|
94
|
+
self.verify = verify
|
95
|
+
self._json = None
|
96
|
+
self._encoded_body: str | None = None
|
97
|
+
self.message = message
|
98
|
+
self.http_version = http_version
|
99
|
+
self.encoding = encoding
|
100
|
+
self._override = _override
|
101
|
+
|
102
|
+
@classmethod
|
103
|
+
def from_any(cls, response: Response | httpx.Response | requests.Response | TestResponse) -> Response:
|
104
|
+
import httpx
|
105
|
+
import requests
|
106
|
+
from werkzeug.test import TestResponse
|
107
|
+
|
108
|
+
if isinstance(response, requests.Response):
|
109
|
+
return Response.from_requests(response, verify=True)
|
110
|
+
elif isinstance(response, httpx.Response):
|
111
|
+
return Response.from_httpx(response, verify=True)
|
112
|
+
elif isinstance(response, TestResponse):
|
113
|
+
return Response.from_wsgi(response)
|
114
|
+
return response
|
115
|
+
|
116
|
+
@classmethod
|
117
|
+
def from_requests(cls, response: requests.Response, verify: bool, _override: Override | None = None) -> Response:
|
118
|
+
raw = response.raw
|
119
|
+
raw_headers = raw.headers if raw is not None else {}
|
120
|
+
headers = {name: response.raw.headers.getlist(name) for name in raw_headers.keys()}
|
121
|
+
# Similar to http.client:319 (HTTP version detection in stdlib's `http` package)
|
122
|
+
version = raw.version if raw is not None else 10
|
123
|
+
http_version = "1.0" if version == 10 else "1.1"
|
124
|
+
return Response(
|
125
|
+
status_code=response.status_code,
|
126
|
+
headers=headers,
|
127
|
+
content=response.content,
|
128
|
+
request=response.request,
|
129
|
+
elapsed=response.elapsed.total_seconds(),
|
130
|
+
message=response.reason,
|
131
|
+
encoding=response.encoding,
|
132
|
+
http_version=http_version,
|
133
|
+
verify=verify,
|
134
|
+
_override=_override,
|
135
|
+
)
|
136
|
+
|
137
|
+
@classmethod
|
138
|
+
def from_httpx(cls, response: httpx.Response, verify: bool) -> Response:
|
139
|
+
import requests
|
140
|
+
|
141
|
+
request = requests.Request(
|
142
|
+
method=response.request.method,
|
143
|
+
url=str(response.request.url),
|
144
|
+
headers=dict(response.request.headers),
|
145
|
+
params=dict(response.request.url.params),
|
146
|
+
data=response.request.content,
|
147
|
+
).prepare()
|
148
|
+
return Response(
|
149
|
+
status_code=response.status_code,
|
150
|
+
headers={key: [value] for key, value in response.headers.items()},
|
151
|
+
content=response.content,
|
152
|
+
request=request,
|
153
|
+
elapsed=response.elapsed.total_seconds(),
|
154
|
+
message=response.reason_phrase,
|
155
|
+
encoding=response.encoding,
|
156
|
+
http_version=response.http_version,
|
157
|
+
verify=verify,
|
158
|
+
)
|
159
|
+
|
160
|
+
@classmethod
|
161
|
+
def from_wsgi(cls, response: TestResponse) -> Response:
|
162
|
+
import http.client
|
163
|
+
|
164
|
+
import requests
|
165
|
+
|
166
|
+
reason = http.client.responses.get(response.status_code, "Unknown")
|
167
|
+
data = response.get_data()
|
168
|
+
if response.response == []:
|
169
|
+
# Werkzeug <3.0 had `charset` attr, newer versions always have UTF-8
|
170
|
+
encoding = response.mimetype_params.get("charset", getattr(response, "charset", "utf-8"))
|
171
|
+
else:
|
172
|
+
encoding = None
|
173
|
+
request = requests.Request(
|
174
|
+
method=response.request.method,
|
175
|
+
url=str(response.request.url),
|
176
|
+
headers=dict(response.request.headers),
|
177
|
+
params=dict(response.request.args),
|
178
|
+
# Request body is not available
|
179
|
+
data=b"",
|
180
|
+
).prepare()
|
181
|
+
return Response(
|
182
|
+
status_code=response.status_code,
|
183
|
+
headers={name: response.headers.getlist(name) for name in response.headers.keys()},
|
184
|
+
content=data,
|
185
|
+
request=request,
|
186
|
+
# Elapsed time is not available
|
187
|
+
elapsed=0.0,
|
188
|
+
message=reason,
|
189
|
+
encoding=encoding,
|
190
|
+
http_version="1.1",
|
191
|
+
verify=False,
|
192
|
+
)
|
193
|
+
|
194
|
+
@property
|
195
|
+
def text(self) -> str:
|
196
|
+
"""Decode response content as text using the detected or default encoding."""
|
197
|
+
return self.content.decode(self.encoding if self.encoding else "utf-8")
|
198
|
+
|
199
|
+
def json(self) -> Any:
|
200
|
+
"""Parse response content as JSON.
|
201
|
+
|
202
|
+
Returns:
|
203
|
+
Parsed JSON data (dict, list, or primitive types)
|
204
|
+
|
205
|
+
Raises:
|
206
|
+
json.JSONDecodeError: If content is not valid JSON
|
207
|
+
|
208
|
+
"""
|
209
|
+
if self._json is None:
|
210
|
+
self._json = json.loads(self.text)
|
211
|
+
return self._json
|
212
|
+
|
213
|
+
@property
|
214
|
+
def body_size(self) -> int | None:
|
215
|
+
"""Size of response body in bytes, or None if no content."""
|
216
|
+
return len(self.content) if self.content else None
|
217
|
+
|
218
|
+
@property
|
219
|
+
def encoded_body(self) -> str | None:
|
220
|
+
"""Base64-encoded response body for binary-safe serialization."""
|
221
|
+
if self._encoded_body is None and self.content:
|
222
|
+
self._encoded_body = base64.b64encode(self.content).decode()
|
223
|
+
return self._encoded_body
|
@@ -0,0 +1,54 @@
|
|
1
|
+
import re
|
2
|
+
from urllib.parse import urlparse
|
3
|
+
|
4
|
+
# Adapted from http.client._is_illegal_header_value
|
5
|
+
INVALID_HEADER_RE = re.compile(r"\n(?![ \t])|\r(?![ \t\n])")
|
6
|
+
|
7
|
+
|
8
|
+
def has_invalid_characters(name: str, value: object) -> bool:
|
9
|
+
from requests.exceptions import InvalidHeader
|
10
|
+
from requests.utils import check_header_validity
|
11
|
+
|
12
|
+
if not isinstance(value, str):
|
13
|
+
return False
|
14
|
+
try:
|
15
|
+
check_header_validity((name, value))
|
16
|
+
return bool(INVALID_HEADER_RE.search(value))
|
17
|
+
except InvalidHeader:
|
18
|
+
return True
|
19
|
+
|
20
|
+
|
21
|
+
def is_latin_1_encodable(value: object) -> bool:
|
22
|
+
"""Check if a value is a Latin-1 encodable string."""
|
23
|
+
if not isinstance(value, str):
|
24
|
+
return False
|
25
|
+
try:
|
26
|
+
value.encode("latin-1")
|
27
|
+
return True
|
28
|
+
except UnicodeEncodeError:
|
29
|
+
return False
|
30
|
+
|
31
|
+
|
32
|
+
SURROGATE_PAIR_RE = re.compile(r"[\ud800-\udfff]")
|
33
|
+
_contains_surrogate_pair = SURROGATE_PAIR_RE.search
|
34
|
+
|
35
|
+
|
36
|
+
def contains_unicode_surrogate_pair(item: object) -> bool:
|
37
|
+
if isinstance(item, list):
|
38
|
+
return any(isinstance(item_, str) and bool(_contains_surrogate_pair(item_)) for item_ in item)
|
39
|
+
return isinstance(item, str) and bool(_contains_surrogate_pair(item))
|
40
|
+
|
41
|
+
|
42
|
+
INVALID_BASE_URL_MESSAGE = (
|
43
|
+
"The provided base URL is invalid. This URL serves as a prefix for all API endpoints you want to test. "
|
44
|
+
"Make sure it is a properly formatted URL."
|
45
|
+
)
|
46
|
+
|
47
|
+
|
48
|
+
def validate_base_url(value: str) -> None:
|
49
|
+
try:
|
50
|
+
netloc = urlparse(value).netloc
|
51
|
+
except ValueError as exc:
|
52
|
+
raise ValueError(INVALID_BASE_URL_MESSAGE) from exc
|
53
|
+
if value and not netloc:
|
54
|
+
raise ValueError(INVALID_BASE_URL_MESSAGE)
|
@@ -0,0 +1,28 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from enum import Enum
|
4
|
+
from typing import TYPE_CHECKING
|
5
|
+
|
6
|
+
if TYPE_CHECKING:
|
7
|
+
from schemathesis.engine.core import Engine
|
8
|
+
from schemathesis.schemas import BaseSchema
|
9
|
+
|
10
|
+
|
11
|
+
class Status(str, Enum):
|
12
|
+
SUCCESS = "success"
|
13
|
+
FAILURE = "failure"
|
14
|
+
ERROR = "error"
|
15
|
+
INTERRUPTED = "interrupted"
|
16
|
+
SKIP = "skip"
|
17
|
+
|
18
|
+
def __lt__(self, other: Status) -> bool: # type: ignore[override]
|
19
|
+
return _STATUS_ORDER[self] < _STATUS_ORDER[other]
|
20
|
+
|
21
|
+
|
22
|
+
_STATUS_ORDER = {Status.SUCCESS: 0, Status.FAILURE: 1, Status.ERROR: 2, Status.INTERRUPTED: 3, Status.SKIP: 4}
|
23
|
+
|
24
|
+
|
25
|
+
def from_schema(schema: BaseSchema) -> Engine:
|
26
|
+
from .core import Engine
|
27
|
+
|
28
|
+
return Engine(schema=schema)
|
@@ -0,0 +1,118 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import time
|
4
|
+
from dataclasses import dataclass
|
5
|
+
from typing import TYPE_CHECKING, Any
|
6
|
+
|
7
|
+
from schemathesis.config import ProjectConfig
|
8
|
+
from schemathesis.core import NOT_SET, NotSet
|
9
|
+
from schemathesis.generation.case import Case
|
10
|
+
from schemathesis.schemas import APIOperation, BaseSchema
|
11
|
+
|
12
|
+
from .control import ExecutionControl
|
13
|
+
|
14
|
+
if TYPE_CHECKING:
|
15
|
+
import threading
|
16
|
+
|
17
|
+
import requests
|
18
|
+
|
19
|
+
|
20
|
+
@dataclass
|
21
|
+
class EngineContext:
|
22
|
+
"""Holds context shared for a test run."""
|
23
|
+
|
24
|
+
schema: BaseSchema
|
25
|
+
control: ExecutionControl
|
26
|
+
outcome_cache: dict[int, BaseException | None]
|
27
|
+
start_time: float
|
28
|
+
|
29
|
+
__slots__ = ("schema", "control", "outcome_cache", "start_time", "_session", "_transport_kwargs_cache")
|
30
|
+
|
31
|
+
def __init__(
|
32
|
+
self,
|
33
|
+
*,
|
34
|
+
schema: BaseSchema,
|
35
|
+
stop_event: threading.Event,
|
36
|
+
session: requests.Session | None = None,
|
37
|
+
) -> None:
|
38
|
+
self.schema = schema
|
39
|
+
self.control = ExecutionControl(stop_event=stop_event, max_failures=schema.config.max_failures)
|
40
|
+
self.outcome_cache = {}
|
41
|
+
self.start_time = time.monotonic()
|
42
|
+
self._session = session
|
43
|
+
self._transport_kwargs_cache: dict[str | None, dict[str, Any]] = {}
|
44
|
+
|
45
|
+
def _repr_pretty_(self, *args: Any, **kwargs: Any) -> None: ...
|
46
|
+
|
47
|
+
@property
|
48
|
+
def config(self) -> ProjectConfig:
|
49
|
+
return self.schema.config
|
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
|
+
def get_session(self, *, operation: APIOperation | None = None) -> requests.Session:
|
78
|
+
if self._session is not None:
|
79
|
+
return self._session
|
80
|
+
import requests
|
81
|
+
|
82
|
+
session = requests.Session()
|
83
|
+
session.headers = {}
|
84
|
+
config = self.config
|
85
|
+
|
86
|
+
session.verify = config.tls_verify_for(operation=operation)
|
87
|
+
auth = config.auth_for(operation=operation)
|
88
|
+
if auth is not None:
|
89
|
+
session.auth = auth
|
90
|
+
headers = config.headers_for(operation=operation)
|
91
|
+
if headers:
|
92
|
+
session.headers.update(headers)
|
93
|
+
request_cert = config.request_cert_for(operation=operation)
|
94
|
+
if request_cert is not None:
|
95
|
+
session.cert = request_cert
|
96
|
+
proxy = config.proxy_for(operation=operation)
|
97
|
+
if proxy is not None:
|
98
|
+
session.proxies["all"] = proxy
|
99
|
+
return session
|
100
|
+
|
101
|
+
def get_transport_kwargs(self, operation: APIOperation | None = None) -> dict[str, Any]:
|
102
|
+
key = operation.label if operation is not None else None
|
103
|
+
cached = self._transport_kwargs_cache.get(key)
|
104
|
+
if cached is not None:
|
105
|
+
return cached.copy()
|
106
|
+
config = self.config
|
107
|
+
kwargs: dict[str, Any] = {
|
108
|
+
"session": self.get_session(operation=operation),
|
109
|
+
"headers": config.headers_for(operation=operation),
|
110
|
+
"timeout": config.request_timeout_for(operation=operation),
|
111
|
+
"verify": config.tls_verify_for(operation=operation),
|
112
|
+
"cert": config.request_cert_for(operation=operation),
|
113
|
+
}
|
114
|
+
proxy = config.proxy_for(operation=operation)
|
115
|
+
if proxy is not None:
|
116
|
+
kwargs["proxies"] = {"all": proxy}
|
117
|
+
self._transport_kwargs_cache[key] = kwargs
|
118
|
+
return kwargs
|
@@ -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
|