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.
Files changed (251) hide show
  1. schemathesis/__init__.py +53 -25
  2. schemathesis/auths.py +507 -0
  3. schemathesis/checks.py +190 -25
  4. schemathesis/cli/__init__.py +27 -1219
  5. schemathesis/cli/__main__.py +4 -0
  6. schemathesis/cli/commands/__init__.py +133 -0
  7. schemathesis/cli/commands/data.py +10 -0
  8. schemathesis/cli/commands/run/__init__.py +602 -0
  9. schemathesis/cli/commands/run/context.py +228 -0
  10. schemathesis/cli/commands/run/events.py +60 -0
  11. schemathesis/cli/commands/run/executor.py +157 -0
  12. schemathesis/cli/commands/run/filters.py +53 -0
  13. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  14. schemathesis/cli/commands/run/handlers/base.py +45 -0
  15. schemathesis/cli/commands/run/handlers/cassettes.py +464 -0
  16. schemathesis/cli/commands/run/handlers/junitxml.py +60 -0
  17. schemathesis/cli/commands/run/handlers/output.py +1750 -0
  18. schemathesis/cli/commands/run/loaders.py +118 -0
  19. schemathesis/cli/commands/run/validation.py +256 -0
  20. schemathesis/cli/constants.py +5 -0
  21. schemathesis/cli/core.py +19 -0
  22. schemathesis/cli/ext/fs.py +16 -0
  23. schemathesis/cli/ext/groups.py +203 -0
  24. schemathesis/cli/ext/options.py +81 -0
  25. schemathesis/config/__init__.py +202 -0
  26. schemathesis/config/_auth.py +51 -0
  27. schemathesis/config/_checks.py +268 -0
  28. schemathesis/config/_diff_base.py +101 -0
  29. schemathesis/config/_env.py +21 -0
  30. schemathesis/config/_error.py +163 -0
  31. schemathesis/config/_generation.py +157 -0
  32. schemathesis/config/_health_check.py +24 -0
  33. schemathesis/config/_operations.py +335 -0
  34. schemathesis/config/_output.py +171 -0
  35. schemathesis/config/_parameters.py +19 -0
  36. schemathesis/config/_phases.py +253 -0
  37. schemathesis/config/_projects.py +543 -0
  38. schemathesis/config/_rate_limit.py +17 -0
  39. schemathesis/config/_report.py +120 -0
  40. schemathesis/config/_validator.py +9 -0
  41. schemathesis/config/_warnings.py +89 -0
  42. schemathesis/config/schema.json +975 -0
  43. schemathesis/core/__init__.py +72 -0
  44. schemathesis/core/adapter.py +34 -0
  45. schemathesis/core/compat.py +32 -0
  46. schemathesis/core/control.py +2 -0
  47. schemathesis/core/curl.py +100 -0
  48. schemathesis/core/deserialization.py +210 -0
  49. schemathesis/core/errors.py +588 -0
  50. schemathesis/core/failures.py +316 -0
  51. schemathesis/core/fs.py +19 -0
  52. schemathesis/core/hooks.py +20 -0
  53. schemathesis/core/jsonschema/__init__.py +13 -0
  54. schemathesis/core/jsonschema/bundler.py +183 -0
  55. schemathesis/core/jsonschema/keywords.py +40 -0
  56. schemathesis/core/jsonschema/references.py +222 -0
  57. schemathesis/core/jsonschema/types.py +41 -0
  58. schemathesis/core/lazy_import.py +15 -0
  59. schemathesis/core/loaders.py +107 -0
  60. schemathesis/core/marks.py +66 -0
  61. schemathesis/core/media_types.py +79 -0
  62. schemathesis/core/output/__init__.py +46 -0
  63. schemathesis/core/output/sanitization.py +54 -0
  64. schemathesis/core/parameters.py +45 -0
  65. schemathesis/core/rate_limit.py +60 -0
  66. schemathesis/core/registries.py +34 -0
  67. schemathesis/core/result.py +27 -0
  68. schemathesis/core/schema_analysis.py +17 -0
  69. schemathesis/core/shell.py +203 -0
  70. schemathesis/core/transforms.py +144 -0
  71. schemathesis/core/transport.py +223 -0
  72. schemathesis/core/validation.py +73 -0
  73. schemathesis/core/version.py +7 -0
  74. schemathesis/engine/__init__.py +28 -0
  75. schemathesis/engine/context.py +152 -0
  76. schemathesis/engine/control.py +44 -0
  77. schemathesis/engine/core.py +201 -0
  78. schemathesis/engine/errors.py +446 -0
  79. schemathesis/engine/events.py +284 -0
  80. schemathesis/engine/observations.py +42 -0
  81. schemathesis/engine/phases/__init__.py +108 -0
  82. schemathesis/engine/phases/analysis.py +28 -0
  83. schemathesis/engine/phases/probes.py +172 -0
  84. schemathesis/engine/phases/stateful/__init__.py +68 -0
  85. schemathesis/engine/phases/stateful/_executor.py +364 -0
  86. schemathesis/engine/phases/stateful/context.py +85 -0
  87. schemathesis/engine/phases/unit/__init__.py +220 -0
  88. schemathesis/engine/phases/unit/_executor.py +459 -0
  89. schemathesis/engine/phases/unit/_pool.py +82 -0
  90. schemathesis/engine/recorder.py +254 -0
  91. schemathesis/errors.py +47 -0
  92. schemathesis/filters.py +395 -0
  93. schemathesis/generation/__init__.py +25 -0
  94. schemathesis/generation/case.py +478 -0
  95. schemathesis/generation/coverage.py +1528 -0
  96. schemathesis/generation/hypothesis/__init__.py +121 -0
  97. schemathesis/generation/hypothesis/builder.py +992 -0
  98. schemathesis/generation/hypothesis/examples.py +56 -0
  99. schemathesis/generation/hypothesis/given.py +66 -0
  100. schemathesis/generation/hypothesis/reporting.py +285 -0
  101. schemathesis/generation/meta.py +227 -0
  102. schemathesis/generation/metrics.py +93 -0
  103. schemathesis/generation/modes.py +20 -0
  104. schemathesis/generation/overrides.py +127 -0
  105. schemathesis/generation/stateful/__init__.py +37 -0
  106. schemathesis/generation/stateful/state_machine.py +294 -0
  107. schemathesis/graphql/__init__.py +15 -0
  108. schemathesis/graphql/checks.py +109 -0
  109. schemathesis/graphql/loaders.py +285 -0
  110. schemathesis/hooks.py +270 -91
  111. schemathesis/openapi/__init__.py +13 -0
  112. schemathesis/openapi/checks.py +467 -0
  113. schemathesis/openapi/generation/__init__.py +0 -0
  114. schemathesis/openapi/generation/filters.py +72 -0
  115. schemathesis/openapi/loaders.py +315 -0
  116. schemathesis/pytest/__init__.py +5 -0
  117. schemathesis/pytest/control_flow.py +7 -0
  118. schemathesis/pytest/lazy.py +341 -0
  119. schemathesis/pytest/loaders.py +36 -0
  120. schemathesis/pytest/plugin.py +357 -0
  121. schemathesis/python/__init__.py +0 -0
  122. schemathesis/python/asgi.py +12 -0
  123. schemathesis/python/wsgi.py +12 -0
  124. schemathesis/schemas.py +682 -257
  125. schemathesis/specs/graphql/__init__.py +0 -1
  126. schemathesis/specs/graphql/nodes.py +26 -2
  127. schemathesis/specs/graphql/scalars.py +77 -12
  128. schemathesis/specs/graphql/schemas.py +367 -148
  129. schemathesis/specs/graphql/validation.py +33 -0
  130. schemathesis/specs/openapi/__init__.py +9 -1
  131. schemathesis/specs/openapi/_hypothesis.py +555 -318
  132. schemathesis/specs/openapi/adapter/__init__.py +10 -0
  133. schemathesis/specs/openapi/adapter/parameters.py +729 -0
  134. schemathesis/specs/openapi/adapter/protocol.py +59 -0
  135. schemathesis/specs/openapi/adapter/references.py +19 -0
  136. schemathesis/specs/openapi/adapter/responses.py +368 -0
  137. schemathesis/specs/openapi/adapter/security.py +144 -0
  138. schemathesis/specs/openapi/adapter/v2.py +30 -0
  139. schemathesis/specs/openapi/adapter/v3_0.py +30 -0
  140. schemathesis/specs/openapi/adapter/v3_1.py +30 -0
  141. schemathesis/specs/openapi/analysis.py +96 -0
  142. schemathesis/specs/openapi/checks.py +748 -82
  143. schemathesis/specs/openapi/converter.py +176 -37
  144. schemathesis/specs/openapi/definitions.py +599 -4
  145. schemathesis/specs/openapi/examples.py +581 -165
  146. schemathesis/specs/openapi/expressions/__init__.py +52 -5
  147. schemathesis/specs/openapi/expressions/extractors.py +25 -0
  148. schemathesis/specs/openapi/expressions/lexer.py +34 -31
  149. schemathesis/specs/openapi/expressions/nodes.py +97 -46
  150. schemathesis/specs/openapi/expressions/parser.py +35 -13
  151. schemathesis/specs/openapi/formats.py +122 -0
  152. schemathesis/specs/openapi/media_types.py +75 -0
  153. schemathesis/specs/openapi/negative/__init__.py +93 -73
  154. schemathesis/specs/openapi/negative/mutations.py +294 -103
  155. schemathesis/specs/openapi/negative/utils.py +0 -9
  156. schemathesis/specs/openapi/patterns.py +458 -0
  157. schemathesis/specs/openapi/references.py +60 -81
  158. schemathesis/specs/openapi/schemas.py +647 -666
  159. schemathesis/specs/openapi/serialization.py +53 -30
  160. schemathesis/specs/openapi/stateful/__init__.py +403 -68
  161. schemathesis/specs/openapi/stateful/control.py +87 -0
  162. schemathesis/specs/openapi/stateful/dependencies/__init__.py +232 -0
  163. schemathesis/specs/openapi/stateful/dependencies/inputs.py +428 -0
  164. schemathesis/specs/openapi/stateful/dependencies/models.py +341 -0
  165. schemathesis/specs/openapi/stateful/dependencies/naming.py +491 -0
  166. schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
  167. schemathesis/specs/openapi/stateful/dependencies/resources.py +339 -0
  168. schemathesis/specs/openapi/stateful/dependencies/schemas.py +447 -0
  169. schemathesis/specs/openapi/stateful/inference.py +254 -0
  170. schemathesis/specs/openapi/stateful/links.py +219 -78
  171. schemathesis/specs/openapi/types/__init__.py +3 -0
  172. schemathesis/specs/openapi/types/common.py +23 -0
  173. schemathesis/specs/openapi/types/v2.py +129 -0
  174. schemathesis/specs/openapi/types/v3.py +134 -0
  175. schemathesis/specs/openapi/utils.py +7 -6
  176. schemathesis/specs/openapi/warnings.py +75 -0
  177. schemathesis/transport/__init__.py +224 -0
  178. schemathesis/transport/asgi.py +26 -0
  179. schemathesis/transport/prepare.py +126 -0
  180. schemathesis/transport/requests.py +278 -0
  181. schemathesis/transport/serialization.py +329 -0
  182. schemathesis/transport/wsgi.py +175 -0
  183. schemathesis-4.4.2.dist-info/METADATA +213 -0
  184. schemathesis-4.4.2.dist-info/RECORD +192 -0
  185. {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info}/WHEEL +1 -1
  186. schemathesis-4.4.2.dist-info/entry_points.txt +6 -0
  187. {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info/licenses}/LICENSE +1 -1
  188. schemathesis/_compat.py +0 -57
  189. schemathesis/_hypothesis.py +0 -123
  190. schemathesis/auth.py +0 -214
  191. schemathesis/cli/callbacks.py +0 -240
  192. schemathesis/cli/cassettes.py +0 -351
  193. schemathesis/cli/context.py +0 -38
  194. schemathesis/cli/debug.py +0 -21
  195. schemathesis/cli/handlers.py +0 -11
  196. schemathesis/cli/junitxml.py +0 -41
  197. schemathesis/cli/options.py +0 -70
  198. schemathesis/cli/output/__init__.py +0 -1
  199. schemathesis/cli/output/default.py +0 -521
  200. schemathesis/cli/output/short.py +0 -40
  201. schemathesis/constants.py +0 -88
  202. schemathesis/exceptions.py +0 -257
  203. schemathesis/extra/_aiohttp.py +0 -27
  204. schemathesis/extra/_flask.py +0 -10
  205. schemathesis/extra/_server.py +0 -16
  206. schemathesis/extra/pytest_plugin.py +0 -251
  207. schemathesis/failures.py +0 -145
  208. schemathesis/fixups/__init__.py +0 -29
  209. schemathesis/fixups/fast_api.py +0 -30
  210. schemathesis/graphql.py +0 -5
  211. schemathesis/internal.py +0 -6
  212. schemathesis/lazy.py +0 -301
  213. schemathesis/models.py +0 -1113
  214. schemathesis/parameters.py +0 -91
  215. schemathesis/runner/__init__.py +0 -470
  216. schemathesis/runner/events.py +0 -242
  217. schemathesis/runner/impl/__init__.py +0 -3
  218. schemathesis/runner/impl/core.py +0 -791
  219. schemathesis/runner/impl/solo.py +0 -85
  220. schemathesis/runner/impl/threadpool.py +0 -367
  221. schemathesis/runner/serialization.py +0 -206
  222. schemathesis/serializers.py +0 -253
  223. schemathesis/service/__init__.py +0 -18
  224. schemathesis/service/auth.py +0 -10
  225. schemathesis/service/client.py +0 -62
  226. schemathesis/service/constants.py +0 -25
  227. schemathesis/service/events.py +0 -39
  228. schemathesis/service/handler.py +0 -46
  229. schemathesis/service/hosts.py +0 -74
  230. schemathesis/service/metadata.py +0 -42
  231. schemathesis/service/models.py +0 -21
  232. schemathesis/service/serialization.py +0 -184
  233. schemathesis/service/worker.py +0 -39
  234. schemathesis/specs/graphql/loaders.py +0 -215
  235. schemathesis/specs/openapi/constants.py +0 -7
  236. schemathesis/specs/openapi/expressions/context.py +0 -12
  237. schemathesis/specs/openapi/expressions/pointers.py +0 -29
  238. schemathesis/specs/openapi/filters.py +0 -44
  239. schemathesis/specs/openapi/links.py +0 -303
  240. schemathesis/specs/openapi/loaders.py +0 -453
  241. schemathesis/specs/openapi/parameters.py +0 -430
  242. schemathesis/specs/openapi/security.py +0 -129
  243. schemathesis/specs/openapi/validation.py +0 -24
  244. schemathesis/stateful.py +0 -358
  245. schemathesis/targets.py +0 -32
  246. schemathesis/types.py +0 -38
  247. schemathesis/utils.py +0 -475
  248. schemathesis-3.15.4.dist-info/METADATA +0 -202
  249. schemathesis-3.15.4.dist-info/RECORD +0 -99
  250. schemathesis-3.15.4.dist-info/entry_points.txt +0 -7
  251. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
@@ -1,94 +1,429 @@
1
- import functools
2
- import operator
3
- from collections import defaultdict
4
- from typing import TYPE_CHECKING, Any, Dict, Generator, List, Tuple, Type
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 ....stateful import APIStateMachine, Direction, StepResult
11
- from ....utils import Ok
12
- from .. import expressions
13
- from ..links import OpenAPILink
14
- from . import links
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 ....models import APIOperation, Case
18
- from ..schemas import BaseOpenAPISchema
19
-
26
+ from schemathesis.generation.stateful.state_machine import StepOutput
27
+ from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
20
28
 
21
- APIOperationConnections = Dict[str, List[st.SearchStrategy[Tuple[StepResult, OpenAPILink]]]]
29
+ FilterFunction = Callable[["StepOutput"], bool]
22
30
 
23
31
 
24
32
  class OpenAPIStateMachine(APIStateMachine):
25
- def transform(self, result: StepResult, direction: Direction, case: "Case") -> "Case":
26
- context = expressions.ExpressionContext(case=result.case, response=result.response)
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 create_state_machine(schema: "BaseOpenAPISchema") -> Type[APIStateMachine]:
32
- """Create a state machine class.
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
- rules = make_all_rules(schema, bundles, connections)
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
- kwargs: Dict[str, Any] = {"bundles": bundles, "schema": schema}
45
- return type("APIWorkflow", (OpenAPIStateMachine,), {**kwargs, **rules})
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
- Each API operation has a bundle that stores all responses from that operation.
52
- We need to create bundles first, so they can be referred when building connections between operations.
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
- output: Dict[str, CaseInsensitiveDict] = {}
55
- for result in schema.get_all_operations():
56
- if isinstance(result, Ok):
57
- operation = result.ok()
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
- def make_rules(
75
- operation: "APIOperation", bundle: Bundle, connections: APIOperationConnections
76
- ) -> Generator[Rule, None, None]:
77
- """Create a rule for an API operation."""
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
- def _make_rule(previous: st.SearchStrategy) -> Rule:
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
- previous_strategies = connections.get(operation.verbose_name)
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 _combine_strategies(strategies: List[st.SearchStrategy]) -> st.SearchStrategy:
90
- """Combine a list of strategies into a single one.
416
+ def default_status_code(status_codes: Iterator[str]) -> FilterFunction:
417
+ """Create a filter that matches all "default" responses.
91
418
 
92
- If the input is `[a, b, c]`, then the result is equivalent to `a | b | c`.
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
- return functools.reduce(operator.or_, strategies[1:], strategies[0])
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