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
@@ -6,8 +6,6 @@ from typing import Any, Callable, Dict, Generator, List
|
|
6
6
|
from schemathesis.schemas import APIOperation
|
7
7
|
from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
|
8
8
|
|
9
|
-
from ...utils import compose
|
10
|
-
|
11
9
|
Generated = Dict[str, Any]
|
12
10
|
Definition = Dict[str, Any]
|
13
11
|
DefinitionList = List[Definition]
|
@@ -29,10 +27,18 @@ def make_serializer(
|
|
29
27
|
"""A maker function to avoid code duplication."""
|
30
28
|
|
31
29
|
def _wrapper(definitions: DefinitionList) -> Callable | None:
|
32
|
-
|
33
|
-
if
|
34
|
-
return
|
35
|
-
|
30
|
+
functions = list(func(definitions))
|
31
|
+
if not functions:
|
32
|
+
return None
|
33
|
+
|
34
|
+
def composed(x: Any) -> Any:
|
35
|
+
result = x
|
36
|
+
for func in reversed(functions):
|
37
|
+
if func is not None:
|
38
|
+
result = func(result)
|
39
|
+
return result
|
40
|
+
|
41
|
+
return composed
|
36
42
|
|
37
43
|
return _wrapper
|
38
44
|
|
@@ -1,146 +1,202 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
from
|
3
|
+
from dataclasses import dataclass
|
4
4
|
from functools import lru_cache
|
5
|
-
from typing import TYPE_CHECKING, Any, Callable,
|
5
|
+
from typing import TYPE_CHECKING, Any, Callable, Iterator
|
6
6
|
|
7
7
|
from hypothesis import strategies as st
|
8
8
|
from hypothesis.stateful import Bundle, Rule, precondition, rule
|
9
9
|
|
10
|
-
from
|
11
|
-
from
|
12
|
-
from
|
13
|
-
from
|
14
|
-
from
|
15
|
-
from
|
16
|
-
from
|
17
|
-
from
|
18
|
-
from .
|
10
|
+
from schemathesis.core.errors import InvalidStateMachine
|
11
|
+
from schemathesis.core.result import Ok
|
12
|
+
from schemathesis.core.transforms import UNRESOLVABLE
|
13
|
+
from schemathesis.engine.recorder import ScenarioRecorder
|
14
|
+
from schemathesis.generation import GenerationMode
|
15
|
+
from schemathesis.generation.case import Case
|
16
|
+
from schemathesis.generation.hypothesis import strategies
|
17
|
+
from schemathesis.generation.stateful import STATEFUL_TESTS_LABEL
|
18
|
+
from schemathesis.generation.stateful.state_machine import APIStateMachine, StepInput, StepOutput, _normalize_name
|
19
|
+
from schemathesis.schemas import APIOperation
|
20
|
+
from schemathesis.specs.openapi.stateful.control import TransitionController
|
21
|
+
from schemathesis.specs.openapi.stateful.links import OpenApiLink, get_all_links
|
22
|
+
from schemathesis.specs.openapi.utils import expand_status_code
|
19
23
|
|
20
24
|
if TYPE_CHECKING:
|
21
|
-
from
|
22
|
-
from
|
23
|
-
|
25
|
+
from schemathesis.generation.stateful.state_machine import StepOutput
|
26
|
+
from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
|
27
|
+
|
28
|
+
FilterFunction = Callable[["StepOutput"], bool]
|
24
29
|
|
25
30
|
|
26
31
|
class OpenAPIStateMachine(APIStateMachine):
|
27
|
-
|
28
|
-
|
32
|
+
_response_matchers: dict[str, Callable[[StepOutput], str | None]]
|
33
|
+
_transitions: ApiTransitions
|
34
|
+
|
35
|
+
def __init__(self) -> None:
|
36
|
+
self.recorder = ScenarioRecorder(label=STATEFUL_TESTS_LABEL)
|
37
|
+
self.control = TransitionController(self._transitions)
|
38
|
+
super().__init__()
|
29
39
|
|
30
|
-
def _get_target_for_result(self, result:
|
31
|
-
matcher = self._response_matchers.get(result.case.operation.
|
40
|
+
def _get_target_for_result(self, result: StepOutput) -> str | None:
|
41
|
+
matcher = self._response_matchers.get(result.case.operation.label)
|
32
42
|
if matcher is None:
|
33
43
|
return None
|
34
44
|
return matcher(result)
|
35
45
|
|
36
|
-
def transform(self, result: StepResult, direction: Direction, case: Case) -> Case:
|
37
|
-
context = expressions.ExpressionContext(case=result.case, response=result.response)
|
38
|
-
direction.set_data(case, elapsed=result.elapsed, context=context)
|
39
|
-
return case
|
40
46
|
|
41
|
-
|
42
|
-
|
43
|
-
return "\n".join(item.line for item in cls._transition_stats_template.iter_with_format())
|
47
|
+
# The proportion of negative tests generated for "root" transitions
|
48
|
+
NEGATIVE_TEST_CASES_THRESHOLD = 10
|
44
49
|
|
45
50
|
|
46
|
-
|
47
|
-
|
51
|
+
@dataclass
|
52
|
+
class OperationTransitions:
|
53
|
+
"""Transitions for a single operation."""
|
48
54
|
|
55
|
+
__slots__ = ("incoming", "outgoing")
|
49
56
|
|
50
|
-
def
|
51
|
-
|
57
|
+
def __init__(self) -> None:
|
58
|
+
self.incoming: list[OpenApiLink] = []
|
59
|
+
self.outgoing: list[OpenApiLink] = []
|
52
60
|
|
53
|
-
It aims to avoid making calls that are not likely to lead to a stateful call later. For example:
|
54
|
-
1. POST /users/
|
55
|
-
2. GET /users/{id}/
|
56
61
|
|
57
|
-
|
58
|
-
|
59
|
-
|
62
|
+
@dataclass
|
63
|
+
class ApiTransitions:
|
64
|
+
"""Stores all transitions grouped by operation."""
|
65
|
+
|
66
|
+
__slots__ = ("operations",)
|
67
|
+
|
68
|
+
def __init__(self) -> None:
|
69
|
+
# operation label -> its transitions
|
70
|
+
self.operations: dict[str, OperationTransitions] = {}
|
71
|
+
|
72
|
+
def add_outgoing(self, source: str, link: OpenApiLink) -> None:
|
73
|
+
"""Record an outgoing transition from source operation."""
|
74
|
+
self.operations.setdefault(source, OperationTransitions()).outgoing.append(link)
|
75
|
+
self.operations.setdefault(link.target.label, OperationTransitions()).incoming.append(link)
|
76
|
+
|
77
|
+
|
78
|
+
@dataclass
|
79
|
+
class RootTransitions:
|
80
|
+
"""Classification of API operations that can serve as entry points."""
|
81
|
+
|
82
|
+
__slots__ = ("reliable", "fallback")
|
83
|
+
|
84
|
+
def __init__(self) -> None:
|
85
|
+
# Operations likely to succeed and provide data for other transitions
|
86
|
+
self.reliable: set[str] = set()
|
87
|
+
# Operations that might work but are less reliable
|
88
|
+
self.fallback: set[str] = set()
|
89
|
+
|
90
|
+
|
91
|
+
def collect_transitions(operations: list[APIOperation]) -> ApiTransitions:
|
92
|
+
"""Collect all transitions between operations."""
|
93
|
+
transitions = ApiTransitions()
|
60
94
|
|
95
|
+
selected_labels = {operation.label for operation in operations}
|
96
|
+
errors = []
|
97
|
+
for operation in operations:
|
98
|
+
for _, link in get_all_links(operation):
|
99
|
+
if isinstance(link, Ok):
|
100
|
+
if link.ok().target.label in selected_labels:
|
101
|
+
transitions.add_outgoing(operation.label, link.ok())
|
102
|
+
else:
|
103
|
+
errors.append(link.err())
|
104
|
+
|
105
|
+
if errors:
|
106
|
+
raise InvalidStateMachine(errors)
|
107
|
+
|
108
|
+
return transitions
|
109
|
+
|
110
|
+
|
111
|
+
def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
|
61
112
|
operations = [result.ok() for result in schema.get_all_operations() if isinstance(result, Ok)]
|
62
113
|
bundles = {}
|
63
|
-
|
64
|
-
_response_matchers: dict[str, Callable[[
|
65
|
-
|
66
|
-
|
114
|
+
transitions = collect_transitions(operations)
|
115
|
+
_response_matchers: dict[str, Callable[[StepOutput], str | None]] = {}
|
116
|
+
|
117
|
+
# Create bundles and matchers
|
67
118
|
for operation in operations:
|
68
|
-
operation_links: dict[StatusCode, dict[TargetName, dict[LinkName, dict[int | None, int]]]] = {}
|
69
119
|
all_status_codes = tuple(operation.definition.raw["responses"])
|
70
120
|
bundle_matchers = []
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
bundle_matchers.append((bundle_name, make_response_filter(link.status_code, all_status_codes)))
|
80
|
-
if operation_links:
|
81
|
-
transitions[operation.verbose_name] = operation_links
|
121
|
+
|
122
|
+
if operation.label in transitions.operations:
|
123
|
+
# Use outgoing transitions
|
124
|
+
for link in transitions.operations[operation.label].outgoing:
|
125
|
+
bundle_name = f"{operation.label} -> {link.status_code}"
|
126
|
+
bundles[bundle_name] = Bundle(bundle_name)
|
127
|
+
bundle_matchers.append((bundle_name, make_response_filter(link.status_code, all_status_codes)))
|
128
|
+
|
82
129
|
if bundle_matchers:
|
83
|
-
_response_matchers[operation.
|
130
|
+
_response_matchers[operation.label] = make_response_matcher(bundle_matchers)
|
131
|
+
|
84
132
|
rules = {}
|
85
133
|
catch_all = Bundle("catch_all")
|
86
134
|
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
)
|
108
|
-
elif any(
|
109
|
-
incoming.operation.verbose_name == target.verbose_name
|
110
|
-
for transitions in incoming_transitions.values()
|
111
|
-
for incoming in transitions
|
112
|
-
):
|
113
|
-
# No incoming transitions, but has at least one outgoing transition
|
114
|
-
# For example, POST /users/ -> GET /users/{id}/
|
115
|
-
# The source operation has no prerequisite, but we need to allow this rule to be executed
|
116
|
-
# in order to reach other transitions
|
117
|
-
name = _normalize_name(f"{target.verbose_name} -> X")
|
118
|
-
if len(schema.data_generation_methods) == 1:
|
119
|
-
case_strategy = target.as_strategy(data_generation_method=schema.data_generation_methods[0])
|
120
|
-
else:
|
121
|
-
strategies = {
|
122
|
-
method: target.as_strategy(data_generation_method=method)
|
123
|
-
for method in schema.data_generation_methods
|
124
|
-
}
|
135
|
+
# We want stateful testing to be effective and focus on meaningful transitions.
|
136
|
+
# An operation is considered as a "root" transition (entry point) if it satisfies certain criteria
|
137
|
+
# that indicate it's likely to succeed and provide data for other transitions.
|
138
|
+
# For example:
|
139
|
+
# - POST operations that create resources
|
140
|
+
# - GET operations without path parameters (e.g., GET /users/ to list all users)
|
141
|
+
#
|
142
|
+
# We avoid adding operations as roots if they:
|
143
|
+
# 1. Have incoming transitions that will provide proper data
|
144
|
+
# Example: If POST /users/ -> GET /users/{id} exists, we don't need
|
145
|
+
# to generate random user IDs for GET /users/{id}
|
146
|
+
# 2. Are unlikely to succeed with random data
|
147
|
+
# Example: GET /users/{id} with random ID is likely to return 404
|
148
|
+
#
|
149
|
+
# This way we:
|
150
|
+
# 1. Maximize the chance of successful transitions
|
151
|
+
# 2. Don't waste the test budget (limited number of steps) on likely-to-fail operations
|
152
|
+
# 3. Focus on transitions that are designed to work together via links
|
153
|
+
|
154
|
+
roots = classify_root_transitions(operations, transitions)
|
125
155
|
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
156
|
+
for target in operations:
|
157
|
+
if target.label in transitions.operations:
|
158
|
+
incoming = transitions.operations[target.label].incoming
|
159
|
+
if incoming:
|
160
|
+
for link in incoming:
|
161
|
+
bundle_name = f"{link.source.label} -> {link.status_code}"
|
162
|
+
name = _normalize_name(
|
163
|
+
f"{link.source.label} -> {link.status_code} -> {link.name} -> {target.label}"
|
164
|
+
)
|
165
|
+
assert name not in rules, name
|
166
|
+
config = schema.config.generation_for(operation=target, phase="stateful")
|
167
|
+
rules[name] = precondition(is_transition_allowed(bundle_name, link.source.label, target.label))(
|
168
|
+
transition(
|
169
|
+
name=name,
|
170
|
+
target=catch_all,
|
171
|
+
input=bundles[bundle_name].flatmap(
|
172
|
+
into_step_input(target=target, link=link, modes=config.modes)
|
173
|
+
),
|
174
|
+
)
|
175
|
+
)
|
176
|
+
if target.label in roots.reliable or (not roots.reliable and target.label in roots.fallback):
|
177
|
+
name = _normalize_name(f"RANDOM -> {target.label}")
|
178
|
+
config = schema.config.generation_for(operation=target, phase="stateful")
|
179
|
+
if len(config.modes) == 1:
|
180
|
+
case_strategy = target.as_strategy(generation_mode=config.modes[0], __is_stateful_phase=True)
|
181
|
+
else:
|
182
|
+
_strategies = {
|
183
|
+
method: target.as_strategy(generation_mode=method, __is_stateful_phase=True)
|
184
|
+
for method in config.modes
|
185
|
+
}
|
186
|
+
|
187
|
+
@st.composite # type: ignore[misc]
|
188
|
+
def case_strategy_factory(
|
189
|
+
draw: st.DrawFn, strategies: dict[GenerationMode, st.SearchStrategy] = _strategies
|
190
|
+
) -> Case:
|
191
|
+
if draw(st.integers(min_value=0, max_value=99)) < NEGATIVE_TEST_CASES_THRESHOLD:
|
192
|
+
return draw(strategies[GenerationMode.NEGATIVE])
|
193
|
+
return draw(strategies[GenerationMode.POSITIVE])
|
194
|
+
|
195
|
+
case_strategy = case_strategy_factory()
|
196
|
+
|
197
|
+
rules[name] = precondition(is_root_allowed(target.label))(
|
198
|
+
transition(name=name, target=catch_all, input=case_strategy.map(StepInput.initial))
|
142
199
|
)
|
143
|
-
)
|
144
200
|
|
145
201
|
return type(
|
146
202
|
"APIWorkflow",
|
@@ -148,43 +204,122 @@ def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
|
|
148
204
|
{
|
149
205
|
"schema": schema,
|
150
206
|
"bundles": bundles,
|
151
|
-
"_transition_stats_template": OpenAPILinkStats(transitions=transitions),
|
152
207
|
"_response_matchers": _response_matchers,
|
208
|
+
"_transitions": transitions,
|
153
209
|
**rules,
|
154
210
|
},
|
155
211
|
)
|
156
212
|
|
157
213
|
|
158
|
-
def
|
159
|
-
|
160
|
-
|
161
|
-
if bundle:
|
162
|
-
return False
|
163
|
-
return True
|
214
|
+
def classify_root_transitions(operations: list[APIOperation], transitions: ApiTransitions) -> RootTransitions:
|
215
|
+
"""Find operations that can serve as root transitions."""
|
216
|
+
roots = RootTransitions()
|
164
217
|
|
218
|
+
for operation in operations:
|
219
|
+
# Skip if operation has no outgoing transitions
|
220
|
+
operation_transitions = transitions.operations.get(operation.label)
|
221
|
+
if not operation_transitions or not operation_transitions.outgoing:
|
222
|
+
continue
|
165
223
|
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
previous: Bundle | st.SearchStrategy,
|
171
|
-
case: st.SearchStrategy,
|
172
|
-
link: st.SearchStrategy | NotSet = NOT_SET,
|
173
|
-
) -> Callable[[Callable], Rule]:
|
174
|
-
def step_function(*args_: Any, **kwargs_: Any) -> StepResult | None:
|
175
|
-
return APIStateMachine._step(*args_, **kwargs_)
|
224
|
+
if is_likely_root_transition(operation, operation_transitions):
|
225
|
+
roots.reliable.add(operation.label)
|
226
|
+
else:
|
227
|
+
roots.fallback.add(operation.label)
|
176
228
|
|
177
|
-
|
229
|
+
return roots
|
178
230
|
|
179
|
-
kwargs = {"target": target, "previous": previous, "case": case}
|
180
|
-
if not isinstance(link, NotSet):
|
181
|
-
kwargs["link"] = link
|
182
231
|
|
183
|
-
|
232
|
+
def is_likely_root_transition(operation: APIOperation, transitions: OperationTransitions) -> bool:
|
233
|
+
"""Check if operation is likely to succeed as a root transition."""
|
234
|
+
# POST operations with request bodies are likely to create resources
|
235
|
+
if operation.method == "post" and operation.body:
|
236
|
+
return True
|
237
|
+
|
238
|
+
# GET operations without path parameters are likely to return lists
|
239
|
+
if operation.method == "get" and not operation.path_parameters:
|
240
|
+
return True
|
241
|
+
|
242
|
+
return False
|
243
|
+
|
244
|
+
|
245
|
+
def into_step_input(
|
246
|
+
target: APIOperation, link: OpenApiLink, modes: list[GenerationMode]
|
247
|
+
) -> Callable[[StepOutput], st.SearchStrategy[StepInput]]:
|
248
|
+
def builder(_output: StepOutput) -> st.SearchStrategy[StepInput]:
|
249
|
+
@st.composite # type: ignore[misc]
|
250
|
+
def inner(draw: st.DrawFn, output: StepOutput) -> StepInput:
|
251
|
+
transition = link.extract(output)
|
252
|
+
|
253
|
+
kwargs: dict[str, Any] = {
|
254
|
+
container: {
|
255
|
+
name: extracted.value.ok()
|
256
|
+
for name, extracted in data.items()
|
257
|
+
if isinstance(extracted.value, Ok) and extracted.value.ok() not in (None, UNRESOLVABLE)
|
258
|
+
}
|
259
|
+
for container, data in transition.parameters.items()
|
260
|
+
}
|
261
|
+
|
262
|
+
if (
|
263
|
+
transition.request_body is not None
|
264
|
+
and isinstance(transition.request_body.value, Ok)
|
265
|
+
and transition.request_body.value.ok() is not UNRESOLVABLE
|
266
|
+
and not link.merge_body
|
267
|
+
):
|
268
|
+
kwargs["body"] = transition.request_body.value.ok()
|
269
|
+
cases = strategies.combine(
|
270
|
+
[target.as_strategy(generation_mode=mode, __is_stateful_phase=True, **kwargs) for mode in modes]
|
271
|
+
)
|
272
|
+
case = draw(cases)
|
273
|
+
if (
|
274
|
+
transition.request_body is not None
|
275
|
+
and isinstance(transition.request_body.value, Ok)
|
276
|
+
and transition.request_body.value.ok() is not UNRESOLVABLE
|
277
|
+
and link.merge_body
|
278
|
+
):
|
279
|
+
new = transition.request_body.value.ok()
|
280
|
+
if isinstance(case.body, dict) and isinstance(new, dict):
|
281
|
+
case.body = {**case.body, **new}
|
282
|
+
else:
|
283
|
+
case.body = new
|
284
|
+
return StepInput(case=case, transition=transition)
|
285
|
+
|
286
|
+
return inner(output=_output)
|
287
|
+
|
288
|
+
return builder
|
289
|
+
|
290
|
+
|
291
|
+
def is_transition_allowed(bundle_name: str, source: str, target: str) -> Callable[[OpenAPIStateMachine], bool]:
|
292
|
+
def inner(machine: OpenAPIStateMachine) -> bool:
|
293
|
+
return bool(machine.bundles.get(bundle_name)) and machine.control.allow_transition(source, target)
|
294
|
+
|
295
|
+
return inner
|
296
|
+
|
297
|
+
|
298
|
+
def is_root_allowed(label: str) -> Callable[[OpenAPIStateMachine], bool]:
|
299
|
+
def inner(machine: OpenAPIStateMachine) -> bool:
|
300
|
+
return machine.control.allow_root_transition(label, machine.bundles)
|
301
|
+
|
302
|
+
return inner
|
303
|
+
|
304
|
+
|
305
|
+
def transition(*, name: str, target: Bundle, input: st.SearchStrategy[StepInput]) -> Callable[[Callable], Rule]:
|
306
|
+
def step_function(self: OpenAPIStateMachine, input: StepInput) -> StepOutput | None:
|
307
|
+
if input.transition is not None:
|
308
|
+
self.recorder.record_case(
|
309
|
+
parent_id=input.transition.parent_id, transition=input.transition, case=input.case
|
310
|
+
)
|
311
|
+
else:
|
312
|
+
self.recorder.record_case(parent_id=None, transition=None, case=input.case)
|
313
|
+
self.control.record_step(input, self.recorder)
|
314
|
+
return APIStateMachine._step(self, input=input)
|
315
|
+
|
316
|
+
step_function.__name__ = name
|
317
|
+
|
318
|
+
return rule(target=target, input=input)(step_function)
|
184
319
|
|
185
320
|
|
186
|
-
def make_response_matcher(matchers: list[tuple[str, FilterFunction]]) -> Callable[[
|
187
|
-
def compare(result:
|
321
|
+
def make_response_matcher(matchers: list[tuple[str, FilterFunction]]) -> Callable[[StepOutput], str | None]:
|
322
|
+
def compare(result: StepOutput) -> str | None:
|
188
323
|
for bundle_name, response_filter in matchers:
|
189
324
|
if response_filter(result):
|
190
325
|
return bundle_name
|
@@ -212,7 +347,7 @@ def match_status_code(status_code: str) -> FilterFunction:
|
|
212
347
|
"""
|
213
348
|
status_codes = set(expand_status_code(status_code))
|
214
349
|
|
215
|
-
def compare(result:
|
350
|
+
def compare(result: StepOutput) -> bool:
|
216
351
|
return result.response.status_code in status_codes
|
217
352
|
|
218
353
|
compare.__name__ = f"match_{status_code}_response"
|
@@ -230,7 +365,7 @@ def default_status_code(status_codes: Iterator[str]) -> FilterFunction:
|
|
230
365
|
status_code for value in status_codes if value != "default" for status_code in expand_status_code(value)
|
231
366
|
}
|
232
367
|
|
233
|
-
def match_default_response(result:
|
368
|
+
def match_default_response(result: StepOutput) -> bool:
|
234
369
|
return result.response.status_code not in expanded_status_codes
|
235
370
|
|
236
371
|
return match_default_response
|
@@ -0,0 +1,87 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from collections import Counter
|
4
|
+
from dataclasses import dataclass
|
5
|
+
from typing import TYPE_CHECKING
|
6
|
+
|
7
|
+
from schemathesis.engine.recorder import ScenarioRecorder
|
8
|
+
from schemathesis.generation.stateful.state_machine import DEFAULT_STATEFUL_STEP_COUNT
|
9
|
+
|
10
|
+
if TYPE_CHECKING:
|
11
|
+
from requests.structures import CaseInsensitiveDict
|
12
|
+
|
13
|
+
from schemathesis.generation.stateful.state_machine import StepInput
|
14
|
+
from schemathesis.specs.openapi.stateful import ApiTransitions
|
15
|
+
|
16
|
+
|
17
|
+
# It is enough to be able to catch double-click type of issues
|
18
|
+
MAX_OPERATIONS_PER_SOURCE_CAP = 2
|
19
|
+
# Maximum number of concurrent root sources (e.g., active users in the system)
|
20
|
+
MAX_ROOT_SOURCES = 2
|
21
|
+
|
22
|
+
|
23
|
+
def _get_max_operations_per_source(transitions: ApiTransitions) -> int:
|
24
|
+
"""Calculate global limit based on number of sources to maximize diversity of used API calls."""
|
25
|
+
sources = len(transitions.operations)
|
26
|
+
|
27
|
+
if sources == 0:
|
28
|
+
return MAX_OPERATIONS_PER_SOURCE_CAP
|
29
|
+
|
30
|
+
# Total steps divided by number of sources, but never below the cap
|
31
|
+
return max(MAX_OPERATIONS_PER_SOURCE_CAP, DEFAULT_STATEFUL_STEP_COUNT // sources)
|
32
|
+
|
33
|
+
|
34
|
+
@dataclass
|
35
|
+
class TransitionController:
|
36
|
+
"""Controls which transitions can be executed in a state machine."""
|
37
|
+
|
38
|
+
__slots__ = ("transitions", "max_operations_per_source", "statistic")
|
39
|
+
|
40
|
+
def __init__(self, transitions: ApiTransitions) -> None:
|
41
|
+
# Incoming & outgoing transitions available in the state machine
|
42
|
+
self.transitions = transitions
|
43
|
+
self.max_operations_per_source = _get_max_operations_per_source(transitions)
|
44
|
+
# source -> derived API calls
|
45
|
+
self.statistic: dict[str, dict[str, Counter[str]]] = {}
|
46
|
+
|
47
|
+
def record_step(self, input: StepInput, recorder: ScenarioRecorder) -> None:
|
48
|
+
"""Record API call input."""
|
49
|
+
case = input.case
|
50
|
+
|
51
|
+
if (
|
52
|
+
case.operation.label in self.transitions.operations
|
53
|
+
and self.transitions.operations[case.operation.label].outgoing
|
54
|
+
):
|
55
|
+
# This API operation has outgoing transitions, hence record it as a source
|
56
|
+
entry = self.statistic.setdefault(input.case.operation.label, {})
|
57
|
+
entry[input.case.id] = Counter()
|
58
|
+
|
59
|
+
if input.transition is not None:
|
60
|
+
# Find immediate parent and record as derived operation
|
61
|
+
parent = recorder.cases[input.transition.parent_id]
|
62
|
+
source = parent.value.operation.label
|
63
|
+
case_id = parent.value.id
|
64
|
+
|
65
|
+
if source in self.statistic and case_id in self.statistic[source]:
|
66
|
+
self.statistic[source][case_id][case.operation.label] += 1
|
67
|
+
|
68
|
+
def allow_root_transition(self, source: str, bundles: dict[str, CaseInsensitiveDict]) -> bool:
|
69
|
+
"""Decide if this root transition should be allowed now."""
|
70
|
+
if len(self.statistic.get(source, {})) < MAX_ROOT_SOURCES:
|
71
|
+
return True
|
72
|
+
|
73
|
+
# If all non-root operations are blocked, then allow root ones to make progress
|
74
|
+
history = {name.split("->")[0].strip() for name, values in bundles.items() if values}
|
75
|
+
return all(
|
76
|
+
incoming.source.label not in history
|
77
|
+
or not self.allow_transition(incoming.source.label, incoming.target.label)
|
78
|
+
for transitions in self.transitions.operations.values()
|
79
|
+
for incoming in transitions.incoming
|
80
|
+
if transitions.incoming
|
81
|
+
)
|
82
|
+
|
83
|
+
def allow_transition(self, source: str, target: str) -> bool:
|
84
|
+
"""Decide if this transition should be allowed now."""
|
85
|
+
existing = self.statistic.get(source, {})
|
86
|
+
total = sum(metric.get(target, 0) for metric in existing.values())
|
87
|
+
return total < self.max_operations_per_source
|