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,66 @@
|
|
1
|
+
"""Integrating `hypothesis.given` into Schemathesis."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
from inspect import getfullargspec
|
6
|
+
from typing import TYPE_CHECKING, Any, Callable, NoReturn, Union
|
7
|
+
|
8
|
+
from schemathesis.core.errors import IncorrectUsage
|
9
|
+
from schemathesis.core.marks import Mark
|
10
|
+
|
11
|
+
if TYPE_CHECKING:
|
12
|
+
from hypothesis.strategies import SearchStrategy
|
13
|
+
|
14
|
+
|
15
|
+
__all__ = ["is_given_applied", "given_proxy", "merge_given_args", "GivenInput", "GivenArgsMark", "GivenKwargsMark"]
|
16
|
+
|
17
|
+
EllipsisType = type(...)
|
18
|
+
GivenInput = Union["SearchStrategy", EllipsisType] # type: ignore[valid-type]
|
19
|
+
|
20
|
+
GivenArgsMark = Mark[tuple](attr_name="given_args", default=())
|
21
|
+
GivenKwargsMark = Mark[dict[str, Any]](attr_name="given_kwargs", default=dict)
|
22
|
+
|
23
|
+
|
24
|
+
def is_given_applied(func: Callable) -> bool:
|
25
|
+
return GivenArgsMark.is_set(func) or GivenKwargsMark.is_set(func)
|
26
|
+
|
27
|
+
|
28
|
+
def given_proxy(*args: GivenInput, **kwargs: GivenInput) -> Callable[[Callable], Callable]:
|
29
|
+
"""Proxy Hypothesis strategies to ``hypothesis.given``."""
|
30
|
+
|
31
|
+
def wrapper(func: Callable) -> Callable:
|
32
|
+
if is_given_applied(func):
|
33
|
+
|
34
|
+
def wrapped_test(*_: Any, **__: Any) -> NoReturn:
|
35
|
+
raise IncorrectUsage(
|
36
|
+
f"You have applied `given` to the `{func.__name__}` test more than once, which "
|
37
|
+
"overrides the previous decorator. You need to pass all arguments to the same `given` call."
|
38
|
+
)
|
39
|
+
|
40
|
+
return wrapped_test
|
41
|
+
|
42
|
+
GivenArgsMark.set(func, args)
|
43
|
+
GivenKwargsMark.set(func, kwargs)
|
44
|
+
return func
|
45
|
+
|
46
|
+
return wrapper
|
47
|
+
|
48
|
+
|
49
|
+
def merge_given_args(func: Callable, args: tuple, kwargs: dict[str, Any]) -> dict[str, Any]:
|
50
|
+
"""Merge positional arguments to ``@schema.given`` into a dictionary with keyword arguments.
|
51
|
+
|
52
|
+
Kwargs are modified inplace.
|
53
|
+
"""
|
54
|
+
if args:
|
55
|
+
argspec = getfullargspec(func)
|
56
|
+
for name, strategy in zip(reversed([arg for arg in argspec.args if arg != "case"]), reversed(args)):
|
57
|
+
kwargs[name] = strategy
|
58
|
+
return kwargs
|
59
|
+
|
60
|
+
|
61
|
+
def validate_given_args(func: Callable, args: tuple, kwargs: dict[str, Any]) -> Callable | None:
|
62
|
+
from hypothesis.core import is_invalid_test
|
63
|
+
from hypothesis.internal.reflection import get_signature
|
64
|
+
|
65
|
+
signature = get_signature(func)
|
66
|
+
return is_invalid_test(func, signature, args, kwargs) # type: ignore
|
@@ -0,0 +1,14 @@
|
|
1
|
+
from contextlib import contextmanager
|
2
|
+
from typing import Generator
|
3
|
+
|
4
|
+
from hypothesis.reporting import with_reporter
|
5
|
+
|
6
|
+
|
7
|
+
def ignore(_: str) -> None:
|
8
|
+
pass
|
9
|
+
|
10
|
+
|
11
|
+
@contextmanager
|
12
|
+
def ignore_hypothesis_output() -> Generator:
|
13
|
+
with with_reporter(ignore): # type: ignore
|
14
|
+
yield
|
@@ -0,0 +1,16 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from functools import reduce
|
4
|
+
from operator import or_
|
5
|
+
from typing import TYPE_CHECKING
|
6
|
+
|
7
|
+
if TYPE_CHECKING:
|
8
|
+
from hypothesis import strategies as st
|
9
|
+
|
10
|
+
|
11
|
+
def combine(strategies: list[st.SearchStrategy] | tuple[st.SearchStrategy]) -> st.SearchStrategy:
|
12
|
+
"""Combine a list of strategies into a single one.
|
13
|
+
|
14
|
+
If the input is `[a, b, c]`, then the result is equivalent to `a | b | c`.
|
15
|
+
"""
|
16
|
+
return reduce(or_, strategies[1:], strategies[0])
|
@@ -0,0 +1,115 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from dataclasses import dataclass
|
4
|
+
from enum import Enum
|
5
|
+
|
6
|
+
from schemathesis.generation import GenerationMode
|
7
|
+
|
8
|
+
|
9
|
+
class TestPhase(str, Enum):
|
10
|
+
__test__ = False
|
11
|
+
|
12
|
+
EXPLICIT = "explicit"
|
13
|
+
COVERAGE = "coverage"
|
14
|
+
GENERATE = "generate"
|
15
|
+
|
16
|
+
|
17
|
+
class ComponentKind(str, Enum):
|
18
|
+
"""Components that can be generated."""
|
19
|
+
|
20
|
+
QUERY = "query"
|
21
|
+
PATH_PARAMETERS = "path_parameters"
|
22
|
+
HEADERS = "headers"
|
23
|
+
COOKIES = "cookies"
|
24
|
+
BODY = "body"
|
25
|
+
|
26
|
+
|
27
|
+
@dataclass
|
28
|
+
class ComponentInfo:
|
29
|
+
"""Information about how a specific component was generated."""
|
30
|
+
|
31
|
+
mode: GenerationMode
|
32
|
+
|
33
|
+
__slots__ = ("mode",)
|
34
|
+
|
35
|
+
|
36
|
+
@dataclass
|
37
|
+
class GeneratePhaseData:
|
38
|
+
"""Metadata specific to generate phase."""
|
39
|
+
|
40
|
+
|
41
|
+
@dataclass
|
42
|
+
class ExplicitPhaseData:
|
43
|
+
"""Metadata specific to explicit phase."""
|
44
|
+
|
45
|
+
|
46
|
+
@dataclass
|
47
|
+
class CoveragePhaseData:
|
48
|
+
"""Metadata specific to coverage phase."""
|
49
|
+
|
50
|
+
description: str
|
51
|
+
location: str | None
|
52
|
+
parameter: str | None
|
53
|
+
parameter_location: str | None
|
54
|
+
|
55
|
+
__slots__ = ("description", "location", "parameter", "parameter_location")
|
56
|
+
|
57
|
+
|
58
|
+
@dataclass
|
59
|
+
class PhaseInfo:
|
60
|
+
"""Phase-specific information."""
|
61
|
+
|
62
|
+
name: TestPhase
|
63
|
+
data: CoveragePhaseData | ExplicitPhaseData | GeneratePhaseData
|
64
|
+
|
65
|
+
__slots__ = ("name", "data")
|
66
|
+
|
67
|
+
@classmethod
|
68
|
+
def coverage(
|
69
|
+
cls,
|
70
|
+
description: str,
|
71
|
+
location: str | None = None,
|
72
|
+
parameter: str | None = None,
|
73
|
+
parameter_location: str | None = None,
|
74
|
+
) -> PhaseInfo:
|
75
|
+
return cls(
|
76
|
+
name=TestPhase.COVERAGE,
|
77
|
+
data=CoveragePhaseData(
|
78
|
+
description=description, location=location, parameter=parameter, parameter_location=parameter_location
|
79
|
+
),
|
80
|
+
)
|
81
|
+
|
82
|
+
@classmethod
|
83
|
+
def generate(cls) -> PhaseInfo:
|
84
|
+
return cls(name=TestPhase.GENERATE, data=GeneratePhaseData())
|
85
|
+
|
86
|
+
|
87
|
+
@dataclass
|
88
|
+
class GenerationInfo:
|
89
|
+
"""Information about test case generation."""
|
90
|
+
|
91
|
+
time: float
|
92
|
+
mode: GenerationMode
|
93
|
+
|
94
|
+
__slots__ = ("time", "mode")
|
95
|
+
|
96
|
+
|
97
|
+
@dataclass
|
98
|
+
class CaseMetadata:
|
99
|
+
"""Complete metadata for generated cases."""
|
100
|
+
|
101
|
+
generation: GenerationInfo
|
102
|
+
components: dict[ComponentKind, ComponentInfo]
|
103
|
+
phase: PhaseInfo
|
104
|
+
|
105
|
+
__slots__ = ("generation", "components", "phase")
|
106
|
+
|
107
|
+
def __init__(
|
108
|
+
self,
|
109
|
+
generation: GenerationInfo,
|
110
|
+
components: dict[ComponentKind, ComponentInfo],
|
111
|
+
phase: PhaseInfo,
|
112
|
+
) -> None:
|
113
|
+
self.generation = generation
|
114
|
+
self.components = components
|
115
|
+
self.phase = phase
|
@@ -0,0 +1,28 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from enum import Enum
|
4
|
+
|
5
|
+
|
6
|
+
class GenerationMode(str, Enum):
|
7
|
+
"""Defines what data Schemathesis generates for tests."""
|
8
|
+
|
9
|
+
# Generate data, that fits the API schema
|
10
|
+
POSITIVE = "positive"
|
11
|
+
# Doesn't fit the API schema
|
12
|
+
NEGATIVE = "negative"
|
13
|
+
|
14
|
+
@classmethod
|
15
|
+
def default(cls) -> GenerationMode:
|
16
|
+
return cls.POSITIVE
|
17
|
+
|
18
|
+
@classmethod
|
19
|
+
def all(cls) -> list[GenerationMode]:
|
20
|
+
return list(GenerationMode)
|
21
|
+
|
22
|
+
@property
|
23
|
+
def is_positive(self) -> bool:
|
24
|
+
return self == GenerationMode.POSITIVE
|
25
|
+
|
26
|
+
@property
|
27
|
+
def is_negative(self) -> bool:
|
28
|
+
return self == GenerationMode.NEGATIVE
|
@@ -0,0 +1,96 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from collections.abc import Mapping
|
4
|
+
from dataclasses import dataclass
|
5
|
+
from typing import TYPE_CHECKING, Any, Callable
|
6
|
+
|
7
|
+
from schemathesis.core.errors import IncorrectUsage
|
8
|
+
from schemathesis.core.marks import Mark
|
9
|
+
from schemathesis.core.transforms import diff
|
10
|
+
from schemathesis.generation.meta import ComponentKind
|
11
|
+
|
12
|
+
if TYPE_CHECKING:
|
13
|
+
from schemathesis.generation.case import Case
|
14
|
+
from schemathesis.schemas import APIOperation, ParameterSet
|
15
|
+
|
16
|
+
|
17
|
+
@dataclass
|
18
|
+
class Override:
|
19
|
+
"""Overrides for various parts of a test case."""
|
20
|
+
|
21
|
+
query: dict[str, str]
|
22
|
+
headers: dict[str, str]
|
23
|
+
cookies: dict[str, str]
|
24
|
+
path_parameters: dict[str, str]
|
25
|
+
|
26
|
+
def for_operation(self, operation: APIOperation) -> dict[str, dict[str, str]]:
|
27
|
+
return {
|
28
|
+
"query": (_for_parameters(self.query, operation.query)),
|
29
|
+
"headers": (_for_parameters(self.headers, operation.headers)),
|
30
|
+
"cookies": (_for_parameters(self.cookies, operation.cookies)),
|
31
|
+
"path_parameters": (_for_parameters(self.path_parameters, operation.path_parameters)),
|
32
|
+
}
|
33
|
+
|
34
|
+
@classmethod
|
35
|
+
def from_components(cls, components: dict[ComponentKind, StoredValue], case: Case) -> Override:
|
36
|
+
return Override(
|
37
|
+
**{
|
38
|
+
kind.value: get_component_diff(stored=stored, current=getattr(case, kind.value))
|
39
|
+
for kind, stored in components.items()
|
40
|
+
}
|
41
|
+
)
|
42
|
+
|
43
|
+
|
44
|
+
def _for_parameters(overridden: dict[str, str], defined: ParameterSet) -> dict[str, str]:
|
45
|
+
output = {}
|
46
|
+
for param in defined:
|
47
|
+
if param.name in overridden:
|
48
|
+
output[param.name] = overridden[param.name]
|
49
|
+
return output
|
50
|
+
|
51
|
+
|
52
|
+
@dataclass
|
53
|
+
class StoredValue:
|
54
|
+
value: dict[str, Any] | None
|
55
|
+
is_generated: bool
|
56
|
+
|
57
|
+
__slots__ = ("value", "is_generated")
|
58
|
+
|
59
|
+
|
60
|
+
def store_original_state(value: dict[str, Any] | None) -> dict[str, Any] | None:
|
61
|
+
if isinstance(value, Mapping):
|
62
|
+
return value.copy()
|
63
|
+
return value
|
64
|
+
|
65
|
+
|
66
|
+
def get_component_diff(stored: StoredValue, current: dict[str, Any] | None) -> dict[str, Any]:
|
67
|
+
"""Calculate difference between stored and current components."""
|
68
|
+
if not (current and stored.value):
|
69
|
+
return {}
|
70
|
+
if stored.is_generated:
|
71
|
+
return diff(stored.value, current)
|
72
|
+
return current
|
73
|
+
|
74
|
+
|
75
|
+
def store_components(case: Case) -> dict[ComponentKind, StoredValue]:
|
76
|
+
"""Store original component states for a test case."""
|
77
|
+
return {
|
78
|
+
kind: StoredValue(
|
79
|
+
value=store_original_state(getattr(case, kind.value)),
|
80
|
+
is_generated=bool(case.meta and kind in case.meta.components),
|
81
|
+
)
|
82
|
+
for kind in [
|
83
|
+
ComponentKind.QUERY,
|
84
|
+
ComponentKind.HEADERS,
|
85
|
+
ComponentKind.COOKIES,
|
86
|
+
ComponentKind.PATH_PARAMETERS,
|
87
|
+
]
|
88
|
+
}
|
89
|
+
|
90
|
+
|
91
|
+
OverrideMark = Mark[Override](attr_name="override")
|
92
|
+
|
93
|
+
|
94
|
+
def check_no_override_mark(test: Callable) -> None:
|
95
|
+
if OverrideMark.is_set(test):
|
96
|
+
raise IncorrectUsage(f"`{test.__name__}` has already been decorated with `override`.")
|
@@ -0,0 +1,20 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import TYPE_CHECKING
|
4
|
+
|
5
|
+
if TYPE_CHECKING:
|
6
|
+
import hypothesis
|
7
|
+
|
8
|
+
from schemathesis.generation.stateful.state_machine import APIStateMachine
|
9
|
+
|
10
|
+
|
11
|
+
def run_state_machine_as_test(
|
12
|
+
state_machine_factory: type[APIStateMachine], *, settings: hypothesis.settings | None = None
|
13
|
+
) -> None:
|
14
|
+
"""Run a state machine as a test.
|
15
|
+
|
16
|
+
It automatically adds the `_min_steps` argument if ``Hypothesis`` is recent enough.
|
17
|
+
"""
|
18
|
+
from hypothesis.stateful import run_state_machine_as_test as _run_state_machine_as_test
|
19
|
+
|
20
|
+
return _run_state_machine_as_test(state_machine_factory, settings=settings, _min_steps=2)
|
@@ -1,36 +1,49 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
import time
|
4
3
|
import re
|
5
4
|
from dataclasses import dataclass
|
6
|
-
from
|
5
|
+
from functools import lru_cache
|
6
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
7
7
|
|
8
|
+
import hypothesis
|
8
9
|
from hypothesis.errors import InvalidDefinition
|
9
10
|
from hypothesis.stateful import RuleBasedStateMachine
|
10
11
|
|
11
|
-
from
|
12
|
-
from
|
13
|
-
from
|
14
|
-
from
|
12
|
+
from schemathesis.checks import CheckFunction
|
13
|
+
from schemathesis.core.errors import IncorrectUsage
|
14
|
+
from schemathesis.core.transport import Response
|
15
|
+
from schemathesis.generation.case import Case
|
15
16
|
|
16
17
|
if TYPE_CHECKING:
|
17
18
|
import hypothesis
|
18
19
|
from requests.structures import CaseInsensitiveDict
|
19
20
|
|
20
|
-
from
|
21
|
-
|
21
|
+
from schemathesis.schemas import APIOperation, BaseSchema
|
22
|
+
|
23
|
+
|
24
|
+
NO_LINKS_ERROR_MESSAGE = (
|
25
|
+
"Stateful testing requires at least one OpenAPI link in the schema, but no links detected. "
|
26
|
+
"Please add OpenAPI links to enable stateful testing or use stateless tests instead. \n"
|
27
|
+
"See https://schemathesis.readthedocs.io/en/stable/stateful.html#how-to-specify-connections for more information."
|
28
|
+
)
|
29
|
+
|
30
|
+
DEFAULT_STATE_MACHINE_SETTINGS = hypothesis.settings(
|
31
|
+
phases=[hypothesis.Phase.generate],
|
32
|
+
deadline=None,
|
33
|
+
stateful_step_count=6,
|
34
|
+
suppress_health_check=list(hypothesis.HealthCheck),
|
35
|
+
)
|
22
36
|
|
23
37
|
|
24
38
|
@dataclass
|
25
39
|
class StepResult:
|
26
40
|
"""Output from a single transition of a state machine."""
|
27
41
|
|
28
|
-
response:
|
42
|
+
response: Response
|
29
43
|
case: Case
|
30
|
-
elapsed: float
|
31
44
|
|
32
45
|
|
33
|
-
def
|
46
|
+
def _normalize_name(name: str) -> str:
|
34
47
|
return re.sub(r"\W|^(?=\d)", "_", name).replace("__", "_")
|
35
48
|
|
36
49
|
|
@@ -51,26 +64,40 @@ class APIStateMachine(RuleBasedStateMachine):
|
|
51
64
|
super().__init__() # type: ignore
|
52
65
|
except InvalidDefinition as exc:
|
53
66
|
if "defines no rules" in str(exc):
|
54
|
-
raise
|
67
|
+
raise IncorrectUsage(NO_LINKS_ERROR_MESSAGE) from None
|
55
68
|
raise
|
56
69
|
self.setup()
|
57
70
|
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
71
|
+
@classmethod
|
72
|
+
@lru_cache
|
73
|
+
def _to_test_case(cls) -> type:
|
74
|
+
from schemathesis.generation.stateful import run_state_machine_as_test
|
75
|
+
|
76
|
+
class StateMachineTestCase(RuleBasedStateMachine.TestCase):
|
77
|
+
settings = DEFAULT_STATE_MACHINE_SETTINGS
|
78
|
+
|
79
|
+
def runTest(self) -> None:
|
80
|
+
run_state_machine_as_test(cls, settings=self.settings)
|
68
81
|
|
69
|
-
|
82
|
+
runTest.is_hypothesis_test = True # type: ignore[attr-defined]
|
70
83
|
|
71
|
-
|
72
|
-
|
73
|
-
|
84
|
+
StateMachineTestCase.__name__ = cls.__name__ + ".TestCase"
|
85
|
+
StateMachineTestCase.__qualname__ = cls.__qualname__ + ".TestCase"
|
86
|
+
return StateMachineTestCase
|
87
|
+
|
88
|
+
def _new_name(self, target: str) -> str:
|
89
|
+
target = _normalize_name(target)
|
90
|
+
return super()._new_name(target) # type: ignore
|
91
|
+
|
92
|
+
def _get_target_for_result(self, result: StepResult) -> str | None:
|
93
|
+
raise NotImplementedError
|
94
|
+
|
95
|
+
def _add_result_to_targets(self, targets: tuple[str, ...], result: StepResult | None) -> None:
|
96
|
+
if result is None:
|
97
|
+
return
|
98
|
+
target = self._get_target_for_result(result)
|
99
|
+
if target is not None:
|
100
|
+
super()._add_result_to_targets((target,), result)
|
74
101
|
|
75
102
|
@classmethod
|
76
103
|
def run(cls, *, settings: hypothesis.settings | None = None) -> None:
|
@@ -80,10 +107,7 @@ class APIStateMachine(RuleBasedStateMachine):
|
|
80
107
|
return run_state_machine_as_test(cls, settings=settings)
|
81
108
|
|
82
109
|
def setup(self) -> None:
|
83
|
-
"""Hook method that runs unconditionally in the beginning of each test scenario.
|
84
|
-
|
85
|
-
Does nothing by default.
|
86
|
-
"""
|
110
|
+
"""Hook method that runs unconditionally in the beginning of each test scenario."""
|
87
111
|
|
88
112
|
def teardown(self) -> None:
|
89
113
|
pass
|
@@ -94,12 +118,14 @@ class APIStateMachine(RuleBasedStateMachine):
|
|
94
118
|
def transform(self, result: StepResult, direction: Direction, case: Case) -> Case:
|
95
119
|
raise NotImplementedError
|
96
120
|
|
97
|
-
def _step(self, case: Case, previous:
|
121
|
+
def _step(self, case: Case, previous: StepResult | None = None, link: Direction | None = None) -> StepResult | None:
|
98
122
|
# This method is a proxy that is used under the hood during the state machine initialization.
|
99
123
|
# The whole point of having it is to make it possible to override `step`; otherwise, custom "step" is ignored.
|
100
124
|
# It happens because, at the point of initialization, the final class is not yet created.
|
101
125
|
__tracebackhide__ = True
|
102
|
-
|
126
|
+
if previous is not None and link is not None:
|
127
|
+
return self.step(case, (previous, link))
|
128
|
+
return self.step(case, None)
|
103
129
|
|
104
130
|
def step(self, case: Case, previous: tuple[StepResult, Direction] | None = None) -> StepResult:
|
105
131
|
"""A single state machine step.
|
@@ -116,12 +142,10 @@ class APIStateMachine(RuleBasedStateMachine):
|
|
116
142
|
case = self.transform(result, direction, case)
|
117
143
|
self.before_call(case)
|
118
144
|
kwargs = self.get_call_kwargs(case)
|
119
|
-
start = time.monotonic()
|
120
145
|
response = self.call(case, **kwargs)
|
121
|
-
elapsed = time.monotonic() - start
|
122
146
|
self.after_call(response, case)
|
123
147
|
self.validate_response(response, case)
|
124
|
-
return self.store_result(response, case
|
148
|
+
return self.store_result(response, case)
|
125
149
|
|
126
150
|
def before_call(self, case: Case) -> None:
|
127
151
|
"""Hook method for modifying the case data before making a request.
|
@@ -148,7 +172,7 @@ class APIStateMachine(RuleBasedStateMachine):
|
|
148
172
|
case.body["is_fake"] = True
|
149
173
|
"""
|
150
174
|
|
151
|
-
def after_call(self, response:
|
175
|
+
def after_call(self, response: Response, case: Case) -> None:
|
152
176
|
"""Hook method for additional actions with case or response instances.
|
153
177
|
|
154
178
|
:param response: Response from the application under test.
|
@@ -181,7 +205,7 @@ class APIStateMachine(RuleBasedStateMachine):
|
|
181
205
|
# PATCH /users/{user_id} -> 500
|
182
206
|
"""
|
183
207
|
|
184
|
-
def call(self, case: Case, **kwargs: Any) ->
|
208
|
+
def call(self, case: Case, **kwargs: Any) -> Response:
|
185
209
|
"""Make a request to the API.
|
186
210
|
|
187
211
|
:param Case case: Generated test case data that should be sent in an API call to the tested API operation.
|
@@ -189,13 +213,12 @@ class APIStateMachine(RuleBasedStateMachine):
|
|
189
213
|
:return: Response from the application under test.
|
190
214
|
|
191
215
|
Note that WSGI/ASGI applications are detected automatically in this method. Depending on the result of this
|
192
|
-
detection the state machine will call ``call
|
216
|
+
detection the state machine will call the ``call`` method.
|
193
217
|
|
194
218
|
Usually, you don't need to override this method unless you are building a different state machine on top of this
|
195
219
|
one and want to customize the transport layer itself.
|
196
220
|
"""
|
197
|
-
|
198
|
-
return method(**kwargs)
|
221
|
+
return case.call(**kwargs)
|
199
222
|
|
200
223
|
def get_call_kwargs(self, case: Case) -> dict[str, Any]:
|
201
224
|
"""Create custom keyword arguments that will be passed to the :meth:`Case.call` method.
|
@@ -214,24 +237,15 @@ class APIStateMachine(RuleBasedStateMachine):
|
|
214
237
|
"""
|
215
238
|
return {}
|
216
239
|
|
217
|
-
def _get_call_method(self, case: Case) -> Callable:
|
218
|
-
if case.app is not None:
|
219
|
-
from starlette.applications import Starlette
|
220
|
-
|
221
|
-
if isinstance(case.app, Starlette):
|
222
|
-
return case.call_asgi
|
223
|
-
return case.call_wsgi
|
224
|
-
return case.call
|
225
|
-
|
226
240
|
def validate_response(
|
227
|
-
self, response:
|
241
|
+
self, response: Response, case: Case, additional_checks: list[CheckFunction] | None = None
|
228
242
|
) -> None:
|
229
243
|
"""Validate an API response.
|
230
244
|
|
231
245
|
:param response: Response from the application under test.
|
232
246
|
:param Case case: Generated test case data that should be sent in an API call to the tested API operation.
|
233
247
|
:param additional_checks: A list of checks that will be run together with the default ones.
|
234
|
-
:raises
|
248
|
+
:raises FailureGroup: If any of the supplied checks failed.
|
235
249
|
|
236
250
|
If you need to change the default checks or provide custom validation rules, you can do it here.
|
237
251
|
|
@@ -258,23 +272,8 @@ class APIStateMachine(RuleBasedStateMachine):
|
|
258
272
|
__tracebackhide__ = True
|
259
273
|
case.validate_response(response, additional_checks=additional_checks)
|
260
274
|
|
261
|
-
def store_result(self, response:
|
262
|
-
return StepResult(response, case
|
263
|
-
|
264
|
-
|
265
|
-
def _print_case(case: Case, kwargs: dict[str, Any]) -> str:
|
266
|
-
from requests.structures import CaseInsensitiveDict
|
267
|
-
|
268
|
-
operation = f"state.schema['{case.operation.path}']['{case.operation.method.upper()}']"
|
269
|
-
headers = case.headers or CaseInsensitiveDict()
|
270
|
-
headers.update(kwargs.get("headers", {}))
|
271
|
-
case.headers = headers
|
272
|
-
data = [
|
273
|
-
f"{name}={repr(getattr(case, name))}"
|
274
|
-
for name in ("path_parameters", "headers", "cookies", "query", "body", "media_type")
|
275
|
-
if getattr(case, name) not in (None, NOT_SET)
|
276
|
-
]
|
277
|
-
return f"{operation}.make_case({', '.join(data)})"
|
275
|
+
def store_result(self, response: Response, case: Case) -> StepResult:
|
276
|
+
return StepResult(response, case)
|
278
277
|
|
279
278
|
|
280
279
|
class Direction:
|
@@ -282,17 +281,5 @@ class Direction:
|
|
282
281
|
status_code: str
|
283
282
|
operation: APIOperation
|
284
283
|
|
285
|
-
def set_data(self, case: Case,
|
284
|
+
def set_data(self, case: Case, **kwargs: Any) -> None:
|
286
285
|
raise NotImplementedError
|
287
|
-
|
288
|
-
|
289
|
-
@dataclass(repr=False)
|
290
|
-
class _DirectionWrapper:
|
291
|
-
"""Purely to avoid modification of `Direction.__repr__`."""
|
292
|
-
|
293
|
-
direction: Direction
|
294
|
-
|
295
|
-
def __repr__(self) -> str:
|
296
|
-
path = self.direction.operation.path
|
297
|
-
method = self.direction.operation.method.upper()
|
298
|
-
return f"state.schema['{path}']['{method}'].links['{self.direction.status_code}']['{self.direction.name}']"
|