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.
Files changed (255) hide show
  1. schemathesis/__init__.py +41 -79
  2. schemathesis/auths.py +111 -122
  3. schemathesis/checks.py +169 -60
  4. schemathesis/cli/__init__.py +15 -2117
  5. schemathesis/cli/commands/__init__.py +85 -0
  6. schemathesis/cli/commands/data.py +10 -0
  7. schemathesis/cli/commands/run/__init__.py +590 -0
  8. schemathesis/cli/commands/run/context.py +204 -0
  9. schemathesis/cli/commands/run/events.py +60 -0
  10. schemathesis/cli/commands/run/executor.py +157 -0
  11. schemathesis/cli/commands/run/filters.py +53 -0
  12. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  13. schemathesis/cli/commands/run/handlers/base.py +18 -0
  14. schemathesis/cli/commands/run/handlers/cassettes.py +474 -0
  15. schemathesis/cli/commands/run/handlers/junitxml.py +55 -0
  16. schemathesis/cli/commands/run/handlers/output.py +1628 -0
  17. schemathesis/cli/commands/run/loaders.py +114 -0
  18. schemathesis/cli/commands/run/validation.py +246 -0
  19. schemathesis/cli/constants.py +5 -58
  20. schemathesis/cli/core.py +19 -0
  21. schemathesis/cli/ext/fs.py +16 -0
  22. schemathesis/cli/ext/groups.py +84 -0
  23. schemathesis/cli/{options.py → ext/options.py} +36 -34
  24. schemathesis/config/__init__.py +189 -0
  25. schemathesis/config/_auth.py +51 -0
  26. schemathesis/config/_checks.py +268 -0
  27. schemathesis/config/_diff_base.py +99 -0
  28. schemathesis/config/_env.py +21 -0
  29. schemathesis/config/_error.py +156 -0
  30. schemathesis/config/_generation.py +149 -0
  31. schemathesis/config/_health_check.py +24 -0
  32. schemathesis/config/_operations.py +327 -0
  33. schemathesis/config/_output.py +171 -0
  34. schemathesis/config/_parameters.py +19 -0
  35. schemathesis/config/_phases.py +187 -0
  36. schemathesis/config/_projects.py +527 -0
  37. schemathesis/config/_rate_limit.py +17 -0
  38. schemathesis/config/_report.py +120 -0
  39. schemathesis/config/_validator.py +9 -0
  40. schemathesis/config/_warnings.py +25 -0
  41. schemathesis/config/schema.json +885 -0
  42. schemathesis/core/__init__.py +67 -0
  43. schemathesis/core/compat.py +32 -0
  44. schemathesis/core/control.py +2 -0
  45. schemathesis/core/curl.py +58 -0
  46. schemathesis/core/deserialization.py +65 -0
  47. schemathesis/core/errors.py +459 -0
  48. schemathesis/core/failures.py +315 -0
  49. schemathesis/core/fs.py +19 -0
  50. schemathesis/core/hooks.py +20 -0
  51. schemathesis/core/loaders.py +104 -0
  52. schemathesis/core/marks.py +66 -0
  53. schemathesis/{transports/content_types.py → core/media_types.py} +14 -12
  54. schemathesis/core/output/__init__.py +46 -0
  55. schemathesis/core/output/sanitization.py +54 -0
  56. schemathesis/{throttling.py → core/rate_limit.py} +16 -17
  57. schemathesis/core/registries.py +31 -0
  58. schemathesis/core/transforms.py +113 -0
  59. schemathesis/core/transport.py +223 -0
  60. schemathesis/core/validation.py +54 -0
  61. schemathesis/core/version.py +7 -0
  62. schemathesis/engine/__init__.py +28 -0
  63. schemathesis/engine/context.py +118 -0
  64. schemathesis/engine/control.py +36 -0
  65. schemathesis/engine/core.py +169 -0
  66. schemathesis/engine/errors.py +464 -0
  67. schemathesis/engine/events.py +258 -0
  68. schemathesis/engine/phases/__init__.py +88 -0
  69. schemathesis/{runner → engine/phases}/probes.py +52 -68
  70. schemathesis/engine/phases/stateful/__init__.py +68 -0
  71. schemathesis/engine/phases/stateful/_executor.py +356 -0
  72. schemathesis/engine/phases/stateful/context.py +85 -0
  73. schemathesis/engine/phases/unit/__init__.py +212 -0
  74. schemathesis/engine/phases/unit/_executor.py +416 -0
  75. schemathesis/engine/phases/unit/_pool.py +82 -0
  76. schemathesis/engine/recorder.py +247 -0
  77. schemathesis/errors.py +43 -0
  78. schemathesis/filters.py +17 -98
  79. schemathesis/generation/__init__.py +5 -33
  80. schemathesis/generation/case.py +317 -0
  81. schemathesis/generation/coverage.py +282 -175
  82. schemathesis/generation/hypothesis/__init__.py +36 -0
  83. schemathesis/generation/hypothesis/builder.py +800 -0
  84. schemathesis/generation/{_hypothesis.py → hypothesis/examples.py} +2 -11
  85. schemathesis/generation/hypothesis/given.py +66 -0
  86. schemathesis/generation/hypothesis/reporting.py +14 -0
  87. schemathesis/generation/hypothesis/strategies.py +16 -0
  88. schemathesis/generation/meta.py +115 -0
  89. schemathesis/generation/metrics.py +93 -0
  90. schemathesis/generation/modes.py +20 -0
  91. schemathesis/generation/overrides.py +116 -0
  92. schemathesis/generation/stateful/__init__.py +37 -0
  93. schemathesis/generation/stateful/state_machine.py +278 -0
  94. schemathesis/graphql/__init__.py +15 -0
  95. schemathesis/graphql/checks.py +109 -0
  96. schemathesis/graphql/loaders.py +284 -0
  97. schemathesis/hooks.py +80 -101
  98. schemathesis/openapi/__init__.py +13 -0
  99. schemathesis/openapi/checks.py +455 -0
  100. schemathesis/openapi/generation/__init__.py +0 -0
  101. schemathesis/openapi/generation/filters.py +72 -0
  102. schemathesis/openapi/loaders.py +313 -0
  103. schemathesis/pytest/__init__.py +5 -0
  104. schemathesis/pytest/control_flow.py +7 -0
  105. schemathesis/pytest/lazy.py +281 -0
  106. schemathesis/pytest/loaders.py +36 -0
  107. schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +128 -108
  108. schemathesis/python/__init__.py +0 -0
  109. schemathesis/python/asgi.py +12 -0
  110. schemathesis/python/wsgi.py +12 -0
  111. schemathesis/schemas.py +537 -273
  112. schemathesis/specs/graphql/__init__.py +0 -1
  113. schemathesis/specs/graphql/_cache.py +1 -2
  114. schemathesis/specs/graphql/scalars.py +42 -6
  115. schemathesis/specs/graphql/schemas.py +141 -137
  116. schemathesis/specs/graphql/validation.py +11 -17
  117. schemathesis/specs/openapi/__init__.py +6 -1
  118. schemathesis/specs/openapi/_cache.py +1 -2
  119. schemathesis/specs/openapi/_hypothesis.py +142 -156
  120. schemathesis/specs/openapi/checks.py +368 -257
  121. schemathesis/specs/openapi/converter.py +4 -4
  122. schemathesis/specs/openapi/definitions.py +1 -1
  123. schemathesis/specs/openapi/examples.py +23 -21
  124. schemathesis/specs/openapi/expressions/__init__.py +31 -19
  125. schemathesis/specs/openapi/expressions/extractors.py +1 -4
  126. schemathesis/specs/openapi/expressions/lexer.py +1 -1
  127. schemathesis/specs/openapi/expressions/nodes.py +36 -41
  128. schemathesis/specs/openapi/expressions/parser.py +1 -1
  129. schemathesis/specs/openapi/formats.py +35 -7
  130. schemathesis/specs/openapi/media_types.py +53 -12
  131. schemathesis/specs/openapi/negative/__init__.py +7 -4
  132. schemathesis/specs/openapi/negative/mutations.py +6 -5
  133. schemathesis/specs/openapi/parameters.py +7 -10
  134. schemathesis/specs/openapi/patterns.py +94 -31
  135. schemathesis/specs/openapi/references.py +12 -53
  136. schemathesis/specs/openapi/schemas.py +238 -308
  137. schemathesis/specs/openapi/security.py +1 -1
  138. schemathesis/specs/openapi/serialization.py +12 -6
  139. schemathesis/specs/openapi/stateful/__init__.py +268 -133
  140. schemathesis/specs/openapi/stateful/control.py +87 -0
  141. schemathesis/specs/openapi/stateful/links.py +209 -0
  142. schemathesis/transport/__init__.py +142 -0
  143. schemathesis/transport/asgi.py +26 -0
  144. schemathesis/transport/prepare.py +124 -0
  145. schemathesis/transport/requests.py +244 -0
  146. schemathesis/{_xml.py → transport/serialization.py} +69 -11
  147. schemathesis/transport/wsgi.py +171 -0
  148. schemathesis-4.0.0.dist-info/METADATA +204 -0
  149. schemathesis-4.0.0.dist-info/RECORD +164 -0
  150. {schemathesis-3.39.15.dist-info → schemathesis-4.0.0.dist-info}/entry_points.txt +1 -1
  151. {schemathesis-3.39.15.dist-info → schemathesis-4.0.0.dist-info}/licenses/LICENSE +1 -1
  152. schemathesis/_compat.py +0 -74
  153. schemathesis/_dependency_versions.py +0 -19
  154. schemathesis/_hypothesis.py +0 -712
  155. schemathesis/_override.py +0 -50
  156. schemathesis/_patches.py +0 -21
  157. schemathesis/_rate_limiter.py +0 -7
  158. schemathesis/cli/callbacks.py +0 -466
  159. schemathesis/cli/cassettes.py +0 -561
  160. schemathesis/cli/context.py +0 -75
  161. schemathesis/cli/debug.py +0 -27
  162. schemathesis/cli/handlers.py +0 -19
  163. schemathesis/cli/junitxml.py +0 -124
  164. schemathesis/cli/output/__init__.py +0 -1
  165. schemathesis/cli/output/default.py +0 -920
  166. schemathesis/cli/output/short.py +0 -59
  167. schemathesis/cli/reporting.py +0 -79
  168. schemathesis/cli/sanitization.py +0 -26
  169. schemathesis/code_samples.py +0 -151
  170. schemathesis/constants.py +0 -54
  171. schemathesis/contrib/__init__.py +0 -11
  172. schemathesis/contrib/openapi/__init__.py +0 -11
  173. schemathesis/contrib/openapi/fill_missing_examples.py +0 -24
  174. schemathesis/contrib/openapi/formats/__init__.py +0 -9
  175. schemathesis/contrib/openapi/formats/uuid.py +0 -16
  176. schemathesis/contrib/unique_data.py +0 -41
  177. schemathesis/exceptions.py +0 -571
  178. schemathesis/experimental/__init__.py +0 -109
  179. schemathesis/extra/_aiohttp.py +0 -28
  180. schemathesis/extra/_flask.py +0 -13
  181. schemathesis/extra/_server.py +0 -18
  182. schemathesis/failures.py +0 -284
  183. schemathesis/fixups/__init__.py +0 -37
  184. schemathesis/fixups/fast_api.py +0 -41
  185. schemathesis/fixups/utf8_bom.py +0 -28
  186. schemathesis/generation/_methods.py +0 -44
  187. schemathesis/graphql.py +0 -3
  188. schemathesis/internal/__init__.py +0 -7
  189. schemathesis/internal/checks.py +0 -86
  190. schemathesis/internal/copy.py +0 -32
  191. schemathesis/internal/datetime.py +0 -5
  192. schemathesis/internal/deprecation.py +0 -37
  193. schemathesis/internal/diff.py +0 -15
  194. schemathesis/internal/extensions.py +0 -27
  195. schemathesis/internal/jsonschema.py +0 -36
  196. schemathesis/internal/output.py +0 -68
  197. schemathesis/internal/transformation.py +0 -26
  198. schemathesis/internal/validation.py +0 -34
  199. schemathesis/lazy.py +0 -474
  200. schemathesis/loaders.py +0 -122
  201. schemathesis/models.py +0 -1341
  202. schemathesis/parameters.py +0 -90
  203. schemathesis/runner/__init__.py +0 -605
  204. schemathesis/runner/events.py +0 -389
  205. schemathesis/runner/impl/__init__.py +0 -3
  206. schemathesis/runner/impl/context.py +0 -88
  207. schemathesis/runner/impl/core.py +0 -1280
  208. schemathesis/runner/impl/solo.py +0 -80
  209. schemathesis/runner/impl/threadpool.py +0 -391
  210. schemathesis/runner/serialization.py +0 -544
  211. schemathesis/sanitization.py +0 -252
  212. schemathesis/serializers.py +0 -328
  213. schemathesis/service/__init__.py +0 -18
  214. schemathesis/service/auth.py +0 -11
  215. schemathesis/service/ci.py +0 -202
  216. schemathesis/service/client.py +0 -133
  217. schemathesis/service/constants.py +0 -38
  218. schemathesis/service/events.py +0 -61
  219. schemathesis/service/extensions.py +0 -224
  220. schemathesis/service/hosts.py +0 -111
  221. schemathesis/service/metadata.py +0 -71
  222. schemathesis/service/models.py +0 -258
  223. schemathesis/service/report.py +0 -255
  224. schemathesis/service/serialization.py +0 -173
  225. schemathesis/service/usage.py +0 -66
  226. schemathesis/specs/graphql/loaders.py +0 -364
  227. schemathesis/specs/openapi/expressions/context.py +0 -16
  228. schemathesis/specs/openapi/links.py +0 -389
  229. schemathesis/specs/openapi/loaders.py +0 -707
  230. schemathesis/specs/openapi/stateful/statistic.py +0 -198
  231. schemathesis/specs/openapi/stateful/types.py +0 -14
  232. schemathesis/specs/openapi/validation.py +0 -26
  233. schemathesis/stateful/__init__.py +0 -147
  234. schemathesis/stateful/config.py +0 -97
  235. schemathesis/stateful/context.py +0 -135
  236. schemathesis/stateful/events.py +0 -274
  237. schemathesis/stateful/runner.py +0 -309
  238. schemathesis/stateful/sink.py +0 -68
  239. schemathesis/stateful/state_machine.py +0 -328
  240. schemathesis/stateful/statistic.py +0 -22
  241. schemathesis/stateful/validation.py +0 -100
  242. schemathesis/targets.py +0 -77
  243. schemathesis/transports/__init__.py +0 -369
  244. schemathesis/transports/asgi.py +0 -7
  245. schemathesis/transports/auth.py +0 -38
  246. schemathesis/transports/headers.py +0 -36
  247. schemathesis/transports/responses.py +0 -57
  248. schemathesis/types.py +0 -44
  249. schemathesis/utils.py +0 -164
  250. schemathesis-3.39.15.dist-info/METADATA +0 -293
  251. schemathesis-3.39.15.dist-info/RECORD +0 -160
  252. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
  253. /schemathesis/{_lazy_import.py → core/lazy_import.py} +0 -0
  254. /schemathesis/{internal → core}/result.py +0 -0
  255. {schemathesis-3.39.15.dist-info → schemathesis-4.0.0.dist-info}/WHEEL +0 -0
@@ -10,7 +10,7 @@ from .parameters import OpenAPI20Parameter, OpenAPI30Parameter, OpenAPIParameter
10
10
  if TYPE_CHECKING:
11
11
  from jsonschema import RefResolver
12
12
 
13
- from ...models import APIOperation
13
+ from schemathesis.schemas import APIOperation
14
14
 
15
15
 
16
16
  @dataclass
@@ -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
- conversions = list(func(definitions))
33
- if conversions:
34
- return compose(*[conv for conv in conversions if conv is not None])
35
- return None
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 collections import defaultdict
3
+ from dataclasses import dataclass
4
4
  from functools import lru_cache
5
- from typing import TYPE_CHECKING, Any, Callable, ClassVar, Iterator
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 ....constants import NOT_SET
11
- from ....generation import DataGenerationMethod, combine_strategies
12
- from ....internal.result import Ok
13
- from ....stateful.state_machine import APIStateMachine, Direction, StepResult
14
- from ....types import NotSet
15
- from .. import expressions
16
- from ..links import get_all_links
17
- from ..utils import expand_status_code
18
- from .statistic import OpenAPILinkStats
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 ....models import Case
22
- from ..schemas import BaseOpenAPISchema
23
- from .types import FilterFunction, LinkName, StatusCode, TargetName
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
- _transition_stats_template: ClassVar[OpenAPILinkStats]
28
- _response_matchers: dict[str, Callable[[StepResult], str | None]]
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: StepResult) -> str | None:
31
- matcher = self._response_matchers.get(result.case.operation.verbose_name)
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
- @classmethod
42
- def format_rules(cls) -> str:
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
- # The proportion of negative tests generated for "root" transitions
47
- NEGATIVE_TEST_CASES_THRESHOLD = 20
51
+ @dataclass
52
+ class OperationTransitions:
53
+ """Transitions for a single operation."""
48
54
 
55
+ __slots__ = ("incoming", "outgoing")
49
56
 
50
- def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
51
- """Create a state machine class.
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
- This state machine won't make calls to (2) without having a proper response from (1) first.
58
- """
59
- from ....stateful.state_machine import _normalize_name
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
- incoming_transitions = defaultdict(list)
64
- _response_matchers: dict[str, Callable[[StepResult], str | None]] = {}
65
- # Statistic structure follows the links and count for each response status code
66
- transitions = {}
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
- for _, link in get_all_links(operation):
72
- bundle_name = f"{operation.verbose_name} -> {link.status_code}"
73
- bundles[bundle_name] = Bundle(bundle_name)
74
- target_operation = link.get_target_operation()
75
- incoming_transitions[target_operation.verbose_name].append(link)
76
- response_targets = operation_links.setdefault(link.status_code, {})
77
- target_links = response_targets.setdefault(target_operation.verbose_name, {})
78
- target_links[link.name] = {}
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.verbose_name] = make_response_matcher(bundle_matchers)
130
+ _response_matchers[operation.label] = make_response_matcher(bundle_matchers)
131
+
84
132
  rules = {}
85
133
  catch_all = Bundle("catch_all")
86
134
 
87
- for target in operations:
88
- incoming = incoming_transitions.get(target.verbose_name)
89
- if incoming is not None:
90
- for link in incoming:
91
- source = link.operation
92
- bundle_name = f"{source.verbose_name} -> {link.status_code}"
93
- name = _normalize_name(f"{target.verbose_name} -> {link.status_code}")
94
- case_strategy = combine_strategies(
95
- [
96
- target.as_strategy(data_generation_method=data_generation_method)
97
- for data_generation_method in schema.data_generation_methods
98
- ]
99
- )
100
- bundle = bundles[bundle_name]
101
- rules[name] = transition(
102
- name=name,
103
- target=catch_all,
104
- previous=bundle,
105
- case=case_strategy,
106
- link=st.just(link),
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
- @st.composite # type: ignore[misc]
127
- def case_strategy_factory(
128
- draw: st.DrawFn, strategies: dict[DataGenerationMethod, st.SearchStrategy] = strategies
129
- ) -> Case:
130
- if draw(st.integers(min_value=0, max_value=99)) < NEGATIVE_TEST_CASES_THRESHOLD:
131
- return draw(strategies[DataGenerationMethod.negative])
132
- return draw(strategies[DataGenerationMethod.positive])
133
-
134
- case_strategy = case_strategy_factory()
135
-
136
- rules[name] = precondition(ensure_links_followed)(
137
- transition(
138
- name=name,
139
- target=catch_all,
140
- previous=st.none(),
141
- case=case_strategy,
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 ensure_links_followed(machine: APIStateMachine) -> bool:
159
- # If there are responses that have links to follow, reject any rule without incoming transitions
160
- for bundle in machine.bundles.values():
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
- def transition(
167
- *,
168
- name: str,
169
- target: Bundle,
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
- step_function.__name__ = name
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
- return rule(**kwargs)(step_function)
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[[StepResult], str | None]:
187
- def compare(result: StepResult) -> str | None:
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: StepResult) -> bool:
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: StepResult) -> bool:
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