schemathesis 3.15.4__py3-none-any.whl → 4.4.2__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 +53 -25
- schemathesis/auths.py +507 -0
- schemathesis/checks.py +190 -25
- schemathesis/cli/__init__.py +27 -1219
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/commands/__init__.py +133 -0
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +602 -0
- schemathesis/cli/commands/run/context.py +228 -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 +45 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +464 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +60 -0
- schemathesis/cli/commands/run/handlers/output.py +1750 -0
- schemathesis/cli/commands/run/loaders.py +118 -0
- schemathesis/cli/commands/run/validation.py +256 -0
- schemathesis/cli/constants.py +5 -0
- schemathesis/cli/core.py +19 -0
- schemathesis/cli/ext/fs.py +16 -0
- schemathesis/cli/ext/groups.py +203 -0
- schemathesis/cli/ext/options.py +81 -0
- schemathesis/config/__init__.py +202 -0
- schemathesis/config/_auth.py +51 -0
- schemathesis/config/_checks.py +268 -0
- schemathesis/config/_diff_base.py +101 -0
- schemathesis/config/_env.py +21 -0
- schemathesis/config/_error.py +163 -0
- schemathesis/config/_generation.py +157 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +335 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +253 -0
- schemathesis/config/_projects.py +543 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +120 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/_warnings.py +89 -0
- schemathesis/config/schema.json +975 -0
- schemathesis/core/__init__.py +72 -0
- schemathesis/core/adapter.py +34 -0
- schemathesis/core/compat.py +32 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +100 -0
- schemathesis/core/deserialization.py +210 -0
- schemathesis/core/errors.py +588 -0
- schemathesis/core/failures.py +316 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/hooks.py +20 -0
- schemathesis/core/jsonschema/__init__.py +13 -0
- schemathesis/core/jsonschema/bundler.py +183 -0
- schemathesis/core/jsonschema/keywords.py +40 -0
- schemathesis/core/jsonschema/references.py +222 -0
- schemathesis/core/jsonschema/types.py +41 -0
- schemathesis/core/lazy_import.py +15 -0
- schemathesis/core/loaders.py +107 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/core/media_types.py +79 -0
- schemathesis/core/output/__init__.py +46 -0
- schemathesis/core/output/sanitization.py +54 -0
- schemathesis/core/parameters.py +45 -0
- schemathesis/core/rate_limit.py +60 -0
- schemathesis/core/registries.py +34 -0
- schemathesis/core/result.py +27 -0
- schemathesis/core/schema_analysis.py +17 -0
- schemathesis/core/shell.py +203 -0
- schemathesis/core/transforms.py +144 -0
- schemathesis/core/transport.py +223 -0
- schemathesis/core/validation.py +73 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +28 -0
- schemathesis/engine/context.py +152 -0
- schemathesis/engine/control.py +44 -0
- schemathesis/engine/core.py +201 -0
- schemathesis/engine/errors.py +446 -0
- schemathesis/engine/events.py +284 -0
- schemathesis/engine/observations.py +42 -0
- schemathesis/engine/phases/__init__.py +108 -0
- schemathesis/engine/phases/analysis.py +28 -0
- schemathesis/engine/phases/probes.py +172 -0
- schemathesis/engine/phases/stateful/__init__.py +68 -0
- schemathesis/engine/phases/stateful/_executor.py +364 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +220 -0
- schemathesis/engine/phases/unit/_executor.py +459 -0
- schemathesis/engine/phases/unit/_pool.py +82 -0
- schemathesis/engine/recorder.py +254 -0
- schemathesis/errors.py +47 -0
- schemathesis/filters.py +395 -0
- schemathesis/generation/__init__.py +25 -0
- schemathesis/generation/case.py +478 -0
- schemathesis/generation/coverage.py +1528 -0
- schemathesis/generation/hypothesis/__init__.py +121 -0
- schemathesis/generation/hypothesis/builder.py +992 -0
- schemathesis/generation/hypothesis/examples.py +56 -0
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +285 -0
- schemathesis/generation/meta.py +227 -0
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +20 -0
- schemathesis/generation/overrides.py +127 -0
- schemathesis/generation/stateful/__init__.py +37 -0
- schemathesis/generation/stateful/state_machine.py +294 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +285 -0
- schemathesis/hooks.py +270 -91
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +467 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +72 -0
- schemathesis/openapi/loaders.py +315 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +341 -0
- schemathesis/pytest/loaders.py +36 -0
- schemathesis/pytest/plugin.py +357 -0
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +682 -257
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/nodes.py +26 -2
- schemathesis/specs/graphql/scalars.py +77 -12
- schemathesis/specs/graphql/schemas.py +367 -148
- schemathesis/specs/graphql/validation.py +33 -0
- schemathesis/specs/openapi/__init__.py +9 -1
- schemathesis/specs/openapi/_hypothesis.py +555 -318
- schemathesis/specs/openapi/adapter/__init__.py +10 -0
- schemathesis/specs/openapi/adapter/parameters.py +729 -0
- schemathesis/specs/openapi/adapter/protocol.py +59 -0
- schemathesis/specs/openapi/adapter/references.py +19 -0
- schemathesis/specs/openapi/adapter/responses.py +368 -0
- schemathesis/specs/openapi/adapter/security.py +144 -0
- schemathesis/specs/openapi/adapter/v2.py +30 -0
- schemathesis/specs/openapi/adapter/v3_0.py +30 -0
- schemathesis/specs/openapi/adapter/v3_1.py +30 -0
- schemathesis/specs/openapi/analysis.py +96 -0
- schemathesis/specs/openapi/checks.py +748 -82
- schemathesis/specs/openapi/converter.py +176 -37
- schemathesis/specs/openapi/definitions.py +599 -4
- schemathesis/specs/openapi/examples.py +581 -165
- schemathesis/specs/openapi/expressions/__init__.py +52 -5
- schemathesis/specs/openapi/expressions/extractors.py +25 -0
- schemathesis/specs/openapi/expressions/lexer.py +34 -31
- schemathesis/specs/openapi/expressions/nodes.py +97 -46
- schemathesis/specs/openapi/expressions/parser.py +35 -13
- schemathesis/specs/openapi/formats.py +122 -0
- schemathesis/specs/openapi/media_types.py +75 -0
- schemathesis/specs/openapi/negative/__init__.py +93 -73
- schemathesis/specs/openapi/negative/mutations.py +294 -103
- schemathesis/specs/openapi/negative/utils.py +0 -9
- schemathesis/specs/openapi/patterns.py +458 -0
- schemathesis/specs/openapi/references.py +60 -81
- schemathesis/specs/openapi/schemas.py +647 -666
- schemathesis/specs/openapi/serialization.py +53 -30
- schemathesis/specs/openapi/stateful/__init__.py +403 -68
- schemathesis/specs/openapi/stateful/control.py +87 -0
- schemathesis/specs/openapi/stateful/dependencies/__init__.py +232 -0
- schemathesis/specs/openapi/stateful/dependencies/inputs.py +428 -0
- schemathesis/specs/openapi/stateful/dependencies/models.py +341 -0
- schemathesis/specs/openapi/stateful/dependencies/naming.py +491 -0
- schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
- schemathesis/specs/openapi/stateful/dependencies/resources.py +339 -0
- schemathesis/specs/openapi/stateful/dependencies/schemas.py +447 -0
- schemathesis/specs/openapi/stateful/inference.py +254 -0
- schemathesis/specs/openapi/stateful/links.py +219 -78
- schemathesis/specs/openapi/types/__init__.py +3 -0
- schemathesis/specs/openapi/types/common.py +23 -0
- schemathesis/specs/openapi/types/v2.py +129 -0
- schemathesis/specs/openapi/types/v3.py +134 -0
- schemathesis/specs/openapi/utils.py +7 -6
- schemathesis/specs/openapi/warnings.py +75 -0
- schemathesis/transport/__init__.py +224 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +126 -0
- schemathesis/transport/requests.py +278 -0
- schemathesis/transport/serialization.py +329 -0
- schemathesis/transport/wsgi.py +175 -0
- schemathesis-4.4.2.dist-info/METADATA +213 -0
- schemathesis-4.4.2.dist-info/RECORD +192 -0
- {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info}/WHEEL +1 -1
- schemathesis-4.4.2.dist-info/entry_points.txt +6 -0
- {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info/licenses}/LICENSE +1 -1
- schemathesis/_compat.py +0 -57
- schemathesis/_hypothesis.py +0 -123
- schemathesis/auth.py +0 -214
- schemathesis/cli/callbacks.py +0 -240
- schemathesis/cli/cassettes.py +0 -351
- schemathesis/cli/context.py +0 -38
- schemathesis/cli/debug.py +0 -21
- schemathesis/cli/handlers.py +0 -11
- schemathesis/cli/junitxml.py +0 -41
- schemathesis/cli/options.py +0 -70
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -521
- schemathesis/cli/output/short.py +0 -40
- schemathesis/constants.py +0 -88
- schemathesis/exceptions.py +0 -257
- schemathesis/extra/_aiohttp.py +0 -27
- schemathesis/extra/_flask.py +0 -10
- schemathesis/extra/_server.py +0 -16
- schemathesis/extra/pytest_plugin.py +0 -251
- schemathesis/failures.py +0 -145
- schemathesis/fixups/__init__.py +0 -29
- schemathesis/fixups/fast_api.py +0 -30
- schemathesis/graphql.py +0 -5
- schemathesis/internal.py +0 -6
- schemathesis/lazy.py +0 -301
- schemathesis/models.py +0 -1113
- schemathesis/parameters.py +0 -91
- schemathesis/runner/__init__.py +0 -470
- schemathesis/runner/events.py +0 -242
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/core.py +0 -791
- schemathesis/runner/impl/solo.py +0 -85
- schemathesis/runner/impl/threadpool.py +0 -367
- schemathesis/runner/serialization.py +0 -206
- schemathesis/serializers.py +0 -253
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -10
- schemathesis/service/client.py +0 -62
- schemathesis/service/constants.py +0 -25
- schemathesis/service/events.py +0 -39
- schemathesis/service/handler.py +0 -46
- schemathesis/service/hosts.py +0 -74
- schemathesis/service/metadata.py +0 -42
- schemathesis/service/models.py +0 -21
- schemathesis/service/serialization.py +0 -184
- schemathesis/service/worker.py +0 -39
- schemathesis/specs/graphql/loaders.py +0 -215
- schemathesis/specs/openapi/constants.py +0 -7
- schemathesis/specs/openapi/expressions/context.py +0 -12
- schemathesis/specs/openapi/expressions/pointers.py +0 -29
- schemathesis/specs/openapi/filters.py +0 -44
- schemathesis/specs/openapi/links.py +0 -303
- schemathesis/specs/openapi/loaders.py +0 -453
- schemathesis/specs/openapi/parameters.py +0 -430
- schemathesis/specs/openapi/security.py +0 -129
- schemathesis/specs/openapi/validation.py +0 -24
- schemathesis/stateful.py +0 -358
- schemathesis/targets.py +0 -32
- schemathesis/types.py +0 -38
- schemathesis/utils.py +0 -475
- schemathesis-3.15.4.dist-info/METADATA +0 -202
- schemathesis-3.15.4.dist-info/RECORD +0 -99
- schemathesis-3.15.4.dist-info/entry_points.txt +0 -7
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
|
@@ -1,94 +1,429 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
from
|
|
4
|
-
from
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Callable, Iterator
|
|
5
6
|
|
|
6
7
|
from hypothesis import strategies as st
|
|
7
|
-
from hypothesis.stateful import Bundle, Rule, rule
|
|
8
|
-
from requests.structures import CaseInsensitiveDict
|
|
8
|
+
from hypothesis.stateful import Bundle, Rule, precondition, rule
|
|
9
9
|
|
|
10
|
-
from
|
|
11
|
-
from
|
|
12
|
-
from
|
|
13
|
-
from
|
|
14
|
-
from . import
|
|
10
|
+
from schemathesis.core import NOT_SET
|
|
11
|
+
from schemathesis.core.errors import InvalidStateMachine, InvalidTransition
|
|
12
|
+
from schemathesis.core.result import Ok
|
|
13
|
+
from schemathesis.core.transforms import UNRESOLVABLE
|
|
14
|
+
from schemathesis.engine.recorder import ScenarioRecorder
|
|
15
|
+
from schemathesis.generation import GenerationMode
|
|
16
|
+
from schemathesis.generation.case import Case
|
|
17
|
+
from schemathesis.generation.meta import TestPhase
|
|
18
|
+
from schemathesis.generation.stateful import STATEFUL_TESTS_LABEL
|
|
19
|
+
from schemathesis.generation.stateful.state_machine import APIStateMachine, StepInput, StepOutput, _normalize_name
|
|
20
|
+
from schemathesis.schemas import APIOperation
|
|
21
|
+
from schemathesis.specs.openapi.stateful.control import TransitionController
|
|
22
|
+
from schemathesis.specs.openapi.stateful.links import OpenApiLink
|
|
23
|
+
from schemathesis.specs.openapi.utils import expand_status_code
|
|
15
24
|
|
|
16
25
|
if TYPE_CHECKING:
|
|
17
|
-
from
|
|
18
|
-
from
|
|
19
|
-
|
|
26
|
+
from schemathesis.generation.stateful.state_machine import StepOutput
|
|
27
|
+
from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
|
|
20
28
|
|
|
21
|
-
|
|
29
|
+
FilterFunction = Callable[["StepOutput"], bool]
|
|
22
30
|
|
|
23
31
|
|
|
24
32
|
class OpenAPIStateMachine(APIStateMachine):
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
direction.set_data(case, elapsed=result.elapsed, context=context)
|
|
28
|
-
return case
|
|
33
|
+
_response_matchers: dict[str, Callable[[StepOutput], str | None]]
|
|
34
|
+
_transitions: ApiTransitions
|
|
29
35
|
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self.recorder = ScenarioRecorder(label=STATEFUL_TESTS_LABEL)
|
|
38
|
+
self.control = TransitionController(self._transitions)
|
|
39
|
+
super().__init__()
|
|
30
40
|
|
|
31
|
-
def
|
|
32
|
-
|
|
41
|
+
def _get_target_for_result(self, result: StepOutput) -> str | None:
|
|
42
|
+
matcher = self._response_matchers.get(result.case.operation.label)
|
|
43
|
+
if matcher is None:
|
|
44
|
+
return None
|
|
45
|
+
return matcher(result)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# The proportion of negative tests generated for "root" transitions
|
|
49
|
+
NEGATIVE_TEST_CASES_THRESHOLD = 10
|
|
50
|
+
# How often some transition is skipped
|
|
51
|
+
BASE_EXPLORATION_RATE = 0.15
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class OperationTransitions:
|
|
56
|
+
"""Transitions for a single operation."""
|
|
57
|
+
|
|
58
|
+
__slots__ = ("incoming", "outgoing")
|
|
59
|
+
|
|
60
|
+
def __init__(self) -> None:
|
|
61
|
+
self.incoming: list[OpenApiLink] = []
|
|
62
|
+
self.outgoing: list[OpenApiLink] = []
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class ApiTransitions:
|
|
67
|
+
"""Stores all transitions grouped by operation."""
|
|
68
|
+
|
|
69
|
+
__slots__ = ("operations",)
|
|
70
|
+
|
|
71
|
+
def __init__(self) -> None:
|
|
72
|
+
# operation label -> its transitions
|
|
73
|
+
self.operations: dict[str, OperationTransitions] = {}
|
|
74
|
+
|
|
75
|
+
def add_outgoing(self, source: str, link: OpenApiLink) -> None:
|
|
76
|
+
"""Record an outgoing transition from source operation."""
|
|
77
|
+
self.operations.setdefault(source, OperationTransitions()).outgoing.append(link)
|
|
78
|
+
self.operations.setdefault(link.target.label, OperationTransitions()).incoming.append(link)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class RootTransitions:
|
|
83
|
+
"""Classification of API operations that can serve as entry points."""
|
|
84
|
+
|
|
85
|
+
__slots__ = ("reliable", "fallback")
|
|
86
|
+
|
|
87
|
+
def __init__(self) -> None:
|
|
88
|
+
# Operations likely to succeed and provide data for other transitions
|
|
89
|
+
self.reliable: set[str] = set()
|
|
90
|
+
# Operations that might work but are less reliable
|
|
91
|
+
self.fallback: set[str] = set()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def collect_transitions(operations: list[APIOperation]) -> ApiTransitions:
|
|
95
|
+
"""Collect all transitions between operations."""
|
|
96
|
+
transitions = ApiTransitions()
|
|
97
|
+
|
|
98
|
+
selected_labels = {operation.label for operation in operations}
|
|
99
|
+
errors = []
|
|
100
|
+
for operation in operations:
|
|
101
|
+
for status_code, response in operation.responses.items():
|
|
102
|
+
for name, link in response.iter_links():
|
|
103
|
+
try:
|
|
104
|
+
link = OpenApiLink(name, status_code, link, operation)
|
|
105
|
+
if link.target.label in selected_labels:
|
|
106
|
+
transitions.add_outgoing(operation.label, link)
|
|
107
|
+
except InvalidTransition as exc:
|
|
108
|
+
errors.append(exc)
|
|
109
|
+
|
|
110
|
+
if errors:
|
|
111
|
+
raise InvalidStateMachine(errors)
|
|
112
|
+
|
|
113
|
+
return transitions
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
|
|
117
|
+
operations = [result.ok() for result in schema.get_all_operations() if isinstance(result, Ok)]
|
|
118
|
+
bundles = {}
|
|
119
|
+
transitions = collect_transitions(operations)
|
|
120
|
+
_response_matchers: dict[str, Callable[[StepOutput], str | None]] = {}
|
|
121
|
+
|
|
122
|
+
# Detect warnings once for all operations tested in stateful phase
|
|
123
|
+
# Store them as a class attribute to avoid re-detection for each scenario
|
|
124
|
+
# Create bundles and matchers
|
|
125
|
+
for operation in operations:
|
|
126
|
+
all_status_codes = operation.responses.status_codes
|
|
127
|
+
bundle_matchers = []
|
|
128
|
+
|
|
129
|
+
if operation.label in transitions.operations:
|
|
130
|
+
# Use outgoing transitions
|
|
131
|
+
for link in transitions.operations[operation.label].outgoing:
|
|
132
|
+
bundle_name = f"{operation.label} -> {link.status_code}"
|
|
133
|
+
bundles[bundle_name] = Bundle(bundle_name)
|
|
134
|
+
bundle_matchers.append((bundle_name, make_response_filter(link.status_code, all_status_codes)))
|
|
135
|
+
|
|
136
|
+
if bundle_matchers:
|
|
137
|
+
_response_matchers[operation.label] = make_response_matcher(bundle_matchers)
|
|
138
|
+
|
|
139
|
+
rules = {}
|
|
140
|
+
catch_all = Bundle("catch_all")
|
|
141
|
+
|
|
142
|
+
# We want stateful testing to be effective and focus on meaningful transitions.
|
|
143
|
+
# An operation is considered as a "root" transition (entry point) if it satisfies certain criteria
|
|
144
|
+
# that indicate it's likely to succeed and provide data for other transitions.
|
|
145
|
+
# For example:
|
|
146
|
+
# - POST operations that create resources
|
|
147
|
+
# - GET operations without path parameters (e.g., GET /users/ to list all users)
|
|
148
|
+
#
|
|
149
|
+
# We avoid adding operations as roots if they:
|
|
150
|
+
# 1. Have incoming transitions that will provide proper data
|
|
151
|
+
# Example: If POST /users/ -> GET /users/{id} exists, we don't need
|
|
152
|
+
# to generate random user IDs for GET /users/{id}
|
|
153
|
+
# 2. Are unlikely to succeed with random data
|
|
154
|
+
# Example: GET /users/{id} with random ID is likely to return 404
|
|
155
|
+
#
|
|
156
|
+
# This way we:
|
|
157
|
+
# 1. Maximize the chance of successful transitions
|
|
158
|
+
# 2. Don't waste the test budget (limited number of steps) on likely-to-fail operations
|
|
159
|
+
# 3. Focus on transitions that are designed to work together via links
|
|
160
|
+
|
|
161
|
+
roots = classify_root_transitions(operations, transitions)
|
|
162
|
+
|
|
163
|
+
for target in operations:
|
|
164
|
+
if target.label in transitions.operations:
|
|
165
|
+
incoming = transitions.operations[target.label].incoming
|
|
166
|
+
config = schema.config.generation_for(operation=target, phase="stateful")
|
|
167
|
+
if incoming:
|
|
168
|
+
for link in incoming:
|
|
169
|
+
bundle_name = f"{link.source.label} -> {link.status_code}"
|
|
170
|
+
name = _normalize_name(link.full_name)
|
|
171
|
+
assert name not in rules, name
|
|
172
|
+
rules[name] = precondition(is_transition_allowed(bundle_name, link.source.label, target.label))(
|
|
173
|
+
transition(
|
|
174
|
+
name=name,
|
|
175
|
+
target=catch_all,
|
|
176
|
+
input=bundles[bundle_name].flatmap(
|
|
177
|
+
into_step_input(target=target, link=link, modes=config.modes)
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
if target.label in roots.reliable or (not roots.reliable and target.label in roots.fallback):
|
|
182
|
+
name = _normalize_name(f"RANDOM -> {target.label}")
|
|
183
|
+
if len(config.modes) == 1:
|
|
184
|
+
case_strategy = target.as_strategy(generation_mode=config.modes[0], phase=TestPhase.STATEFUL)
|
|
185
|
+
else:
|
|
186
|
+
_strategies = {
|
|
187
|
+
method: target.as_strategy(generation_mode=method, phase=TestPhase.STATEFUL)
|
|
188
|
+
for method in config.modes
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
@st.composite # type: ignore[misc]
|
|
192
|
+
def case_strategy_factory(
|
|
193
|
+
draw: st.DrawFn, strategies: dict[GenerationMode, st.SearchStrategy] = _strategies
|
|
194
|
+
) -> Case:
|
|
195
|
+
if draw(st.integers(min_value=0, max_value=99)) < NEGATIVE_TEST_CASES_THRESHOLD:
|
|
196
|
+
return draw(strategies[GenerationMode.NEGATIVE])
|
|
197
|
+
return draw(strategies[GenerationMode.POSITIVE])
|
|
198
|
+
|
|
199
|
+
case_strategy = case_strategy_factory()
|
|
200
|
+
|
|
201
|
+
rules[name] = precondition(is_root_allowed(target.label))(
|
|
202
|
+
transition(name=name, target=catch_all, input=case_strategy.map(StepInput.initial))
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
return type(
|
|
206
|
+
"APIWorkflow",
|
|
207
|
+
(OpenAPIStateMachine,),
|
|
208
|
+
{
|
|
209
|
+
"schema": schema,
|
|
210
|
+
"bundles": bundles,
|
|
211
|
+
"_response_matchers": _response_matchers,
|
|
212
|
+
"_transitions": transitions,
|
|
213
|
+
**rules,
|
|
214
|
+
},
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def classify_root_transitions(operations: list[APIOperation], transitions: ApiTransitions) -> RootTransitions:
|
|
219
|
+
"""Find operations that can serve as root transitions."""
|
|
220
|
+
roots = RootTransitions()
|
|
221
|
+
|
|
222
|
+
for operation in operations:
|
|
223
|
+
# Skip if operation has no outgoing transitions
|
|
224
|
+
operation_transitions = transitions.operations.get(operation.label)
|
|
225
|
+
if not operation_transitions or not operation_transitions.outgoing:
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
if is_likely_root_transition(operation):
|
|
229
|
+
roots.reliable.add(operation.label)
|
|
230
|
+
else:
|
|
231
|
+
roots.fallback.add(operation.label)
|
|
232
|
+
|
|
233
|
+
return roots
|
|
33
234
|
|
|
34
|
-
This state machine will contain transitions that connect some operations' outputs with other operations' inputs.
|
|
35
|
-
"""
|
|
36
|
-
bundles = init_bundles(schema)
|
|
37
|
-
connections: APIOperationConnections = defaultdict(list)
|
|
38
|
-
for result in schema.get_all_operations():
|
|
39
|
-
if isinstance(result, Ok):
|
|
40
|
-
links.apply(result.ok(), bundles, connections)
|
|
41
235
|
|
|
42
|
-
|
|
236
|
+
def is_likely_root_transition(operation: APIOperation) -> bool:
|
|
237
|
+
"""Check if operation is likely to succeed as a root transition."""
|
|
238
|
+
# POST operations are likely to create resources
|
|
239
|
+
if operation.method == "post":
|
|
240
|
+
return True
|
|
43
241
|
|
|
44
|
-
|
|
45
|
-
|
|
242
|
+
# GET operations without path parameters are likely to return lists
|
|
243
|
+
if operation.method == "get" and not operation.path_parameters:
|
|
244
|
+
return True
|
|
46
245
|
|
|
246
|
+
return False
|
|
47
247
|
|
|
48
|
-
def init_bundles(schema: "BaseOpenAPISchema") -> Dict[str, CaseInsensitiveDict]:
|
|
49
|
-
"""Create bundles for all operations in the given schema.
|
|
50
248
|
|
|
51
|
-
|
|
52
|
-
|
|
249
|
+
def into_step_input(
|
|
250
|
+
*, target: APIOperation, link: OpenApiLink, modes: list[GenerationMode]
|
|
251
|
+
) -> Callable[[StepOutput], st.SearchStrategy[StepInput]]:
|
|
252
|
+
"""A single transition between API operations."""
|
|
253
|
+
|
|
254
|
+
def builder(_output: StepOutput) -> st.SearchStrategy[StepInput]:
|
|
255
|
+
@st.composite # type: ignore[misc]
|
|
256
|
+
def inner(draw: st.DrawFn, output: StepOutput) -> StepInput:
|
|
257
|
+
random = draw(st.randoms(use_true_random=True))
|
|
258
|
+
|
|
259
|
+
def biased_coin(p: float) -> bool:
|
|
260
|
+
return random.random() < p
|
|
261
|
+
|
|
262
|
+
# Extract transition data from previous operation's output
|
|
263
|
+
transition = link.extract(output)
|
|
264
|
+
|
|
265
|
+
overrides: dict[str, Any] = {}
|
|
266
|
+
applied_parameters = []
|
|
267
|
+
for container, data in transition.parameters.items():
|
|
268
|
+
overrides[container] = {}
|
|
269
|
+
|
|
270
|
+
for name, extracted in data.items():
|
|
271
|
+
# Skip if extraction failed or returned unusable value
|
|
272
|
+
if not isinstance(extracted.value, Ok) or extracted.value.ok() in (None, UNRESOLVABLE):
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
param_key = f"{container}.{name}"
|
|
276
|
+
|
|
277
|
+
# Calculate exploration rate based on parameter characteristics
|
|
278
|
+
exploration_rate = BASE_EXPLORATION_RATE
|
|
279
|
+
|
|
280
|
+
# Path parameters are critical for routing - use link values more often
|
|
281
|
+
if container == "path_parameters":
|
|
282
|
+
exploration_rate *= 0.5
|
|
283
|
+
|
|
284
|
+
# Required parameters should follow links more often, optional ones explored more
|
|
285
|
+
# Path params are always required, so they get both multipliers
|
|
286
|
+
if extracted.is_required:
|
|
287
|
+
exploration_rate *= 0.5
|
|
288
|
+
else:
|
|
289
|
+
# Explore optional parameters more to avoid only testing link-provided values
|
|
290
|
+
exploration_rate *= 3.0
|
|
291
|
+
|
|
292
|
+
if biased_coin(1 - exploration_rate):
|
|
293
|
+
overrides[container][name] = extracted.value.ok()
|
|
294
|
+
applied_parameters.append(param_key)
|
|
295
|
+
|
|
296
|
+
# Get the extracted body value
|
|
297
|
+
if (
|
|
298
|
+
transition.request_body is not None
|
|
299
|
+
and isinstance(transition.request_body.value, Ok)
|
|
300
|
+
and transition.request_body.value.ok() is not UNRESOLVABLE
|
|
301
|
+
):
|
|
302
|
+
request_body = transition.request_body.value.ok()
|
|
303
|
+
else:
|
|
304
|
+
request_body = NOT_SET
|
|
305
|
+
|
|
306
|
+
# Link suppose to replace the entire extracted body
|
|
307
|
+
if request_body is not NOT_SET and not link.merge_body and biased_coin(1 - BASE_EXPLORATION_RATE):
|
|
308
|
+
overrides["body"] = request_body
|
|
309
|
+
if isinstance(overrides["body"], dict):
|
|
310
|
+
applied_parameters.extend(f"body.{field}" for field in overrides["body"])
|
|
311
|
+
else:
|
|
312
|
+
applied_parameters.append("body")
|
|
313
|
+
|
|
314
|
+
cases = st.one_of(
|
|
315
|
+
[target.as_strategy(generation_mode=mode, phase=TestPhase.STATEFUL, **overrides) for mode in modes]
|
|
316
|
+
)
|
|
317
|
+
case = draw(cases)
|
|
318
|
+
if request_body is not NOT_SET and link.merge_body:
|
|
319
|
+
if isinstance(request_body, dict):
|
|
320
|
+
selected_fields = {}
|
|
321
|
+
|
|
322
|
+
for field_name, field_value in request_body.items():
|
|
323
|
+
if field_value is UNRESOLVABLE:
|
|
324
|
+
continue
|
|
325
|
+
|
|
326
|
+
if biased_coin(1 - BASE_EXPLORATION_RATE):
|
|
327
|
+
selected_fields[field_name] = field_value
|
|
328
|
+
applied_parameters.append(f"body.{field_name}")
|
|
329
|
+
|
|
330
|
+
if selected_fields:
|
|
331
|
+
if isinstance(case.body, dict):
|
|
332
|
+
case.body = {**case.body, **selected_fields}
|
|
333
|
+
else:
|
|
334
|
+
# Can't merge into non-dict, replace entirely
|
|
335
|
+
case.body = selected_fields
|
|
336
|
+
elif biased_coin(1 - BASE_EXPLORATION_RATE):
|
|
337
|
+
case.body = request_body
|
|
338
|
+
applied_parameters.append("body")
|
|
339
|
+
return StepInput(case=case, transition=transition, applied_parameters=applied_parameters)
|
|
340
|
+
|
|
341
|
+
return inner(output=_output)
|
|
342
|
+
|
|
343
|
+
return builder
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def is_transition_allowed(bundle_name: str, source: str, target: str) -> Callable[[OpenAPIStateMachine], bool]:
|
|
347
|
+
def inner(machine: OpenAPIStateMachine) -> bool:
|
|
348
|
+
return bool(machine.bundles.get(bundle_name)) and machine.control.allow_transition(source, target)
|
|
349
|
+
|
|
350
|
+
return inner
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def is_root_allowed(label: str) -> Callable[[OpenAPIStateMachine], bool]:
|
|
354
|
+
def inner(machine: OpenAPIStateMachine) -> bool:
|
|
355
|
+
return machine.control.allow_root_transition(label, machine.bundles)
|
|
356
|
+
|
|
357
|
+
return inner
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def transition(*, name: str, target: Bundle, input: st.SearchStrategy[StepInput]) -> Callable[[Callable], Rule]:
|
|
361
|
+
def step_function(self: OpenAPIStateMachine, input: StepInput) -> StepOutput | None:
|
|
362
|
+
if input.transition is not None:
|
|
363
|
+
self.recorder.record_case(
|
|
364
|
+
parent_id=input.transition.parent_id,
|
|
365
|
+
transition=input.transition,
|
|
366
|
+
case=input.case,
|
|
367
|
+
is_transition_applied=input.is_applied,
|
|
368
|
+
)
|
|
369
|
+
else:
|
|
370
|
+
self.recorder.record_case(parent_id=None, case=input.case, transition=None, is_transition_applied=False)
|
|
371
|
+
self.control.record_step(input, self.recorder)
|
|
372
|
+
return APIStateMachine._step(self, input=input)
|
|
373
|
+
|
|
374
|
+
step_function.__name__ = name
|
|
375
|
+
|
|
376
|
+
return rule(target=target, input=input)(step_function)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def make_response_matcher(matchers: list[tuple[str, FilterFunction]]) -> Callable[[StepOutput], str | None]:
|
|
380
|
+
def compare(result: StepOutput) -> str | None:
|
|
381
|
+
for bundle_name, response_filter in matchers:
|
|
382
|
+
if response_filter(result):
|
|
383
|
+
return bundle_name
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
return compare
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@lru_cache
|
|
390
|
+
def make_response_filter(status_code: str, all_status_codes: Iterator[str]) -> FilterFunction:
|
|
391
|
+
"""Create a filter for stored responses.
|
|
392
|
+
|
|
393
|
+
This filter will decide whether some response is suitable to use as a source for requesting some API operation.
|
|
53
394
|
"""
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
output.setdefault(operation.path, CaseInsensitiveDict())
|
|
59
|
-
output[operation.path][operation.method.upper()] = Bundle(operation.verbose_name) # type: ignore
|
|
60
|
-
return output
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def make_all_rules(
|
|
64
|
-
schema: "BaseOpenAPISchema", bundles: Dict[str, CaseInsensitiveDict], connections: APIOperationConnections
|
|
65
|
-
) -> Dict[str, Rule]:
|
|
66
|
-
"""Create rules for all API operations, based on the provided connections."""
|
|
67
|
-
return {
|
|
68
|
-
f"rule {operation.verbose_name} {idx}": new
|
|
69
|
-
for operation in (result.ok() for result in schema.get_all_operations() if isinstance(result, Ok))
|
|
70
|
-
for idx, new in enumerate(make_rules(operation, bundles[operation.path][operation.method.upper()], connections))
|
|
71
|
-
}
|
|
395
|
+
if status_code == "default":
|
|
396
|
+
return default_status_code(all_status_codes)
|
|
397
|
+
return match_status_code(status_code)
|
|
398
|
+
|
|
72
399
|
|
|
400
|
+
def match_status_code(status_code: str) -> FilterFunction:
|
|
401
|
+
"""Create a filter function that matches all responses with the given status code.
|
|
73
402
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
403
|
+
Note that the status code can contain "X", which means any digit.
|
|
404
|
+
For example, 50X will match all status codes from 500 to 509.
|
|
405
|
+
"""
|
|
406
|
+
status_codes = set(expand_status_code(status_code))
|
|
407
|
+
|
|
408
|
+
def compare(result: StepOutput) -> bool:
|
|
409
|
+
return result.response.status_code in status_codes
|
|
78
410
|
|
|
79
|
-
|
|
80
|
-
decorator = rule(target=bundle, previous=previous, case=operation.as_strategy()) # type: ignore
|
|
81
|
-
return decorator(APIStateMachine._step)
|
|
411
|
+
compare.__name__ = f"match_{status_code}_response"
|
|
82
412
|
|
|
83
|
-
|
|
84
|
-
if previous_strategies is not None:
|
|
85
|
-
yield _make_rule(_combine_strategies(previous_strategies))
|
|
86
|
-
yield _make_rule(st.none())
|
|
413
|
+
return compare
|
|
87
414
|
|
|
88
415
|
|
|
89
|
-
def
|
|
90
|
-
"""
|
|
416
|
+
def default_status_code(status_codes: Iterator[str]) -> FilterFunction:
|
|
417
|
+
"""Create a filter that matches all "default" responses.
|
|
91
418
|
|
|
92
|
-
|
|
419
|
+
In Open API, the "default" response is the one that is used if no other options were matched.
|
|
420
|
+
Therefore, we need to match only responses that were not matched by other listed status codes.
|
|
93
421
|
"""
|
|
94
|
-
|
|
422
|
+
expanded_status_codes = {
|
|
423
|
+
status_code for value in status_codes if value != "default" for status_code in expand_status_code(value)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
def match_default_response(result: StepOutput) -> bool:
|
|
427
|
+
return result.response.status_code not in expanded_status_codes
|
|
428
|
+
|
|
429
|
+
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
|