schemathesis 3.25.6__py3-none-any.whl → 3.39.7__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 (146) hide show
  1. schemathesis/__init__.py +6 -6
  2. schemathesis/_compat.py +2 -2
  3. schemathesis/_dependency_versions.py +4 -2
  4. schemathesis/_hypothesis.py +369 -56
  5. schemathesis/_lazy_import.py +1 -0
  6. schemathesis/_override.py +5 -4
  7. schemathesis/_patches.py +21 -0
  8. schemathesis/_rate_limiter.py +7 -0
  9. schemathesis/_xml.py +75 -22
  10. schemathesis/auths.py +78 -16
  11. schemathesis/checks.py +21 -9
  12. schemathesis/cli/__init__.py +783 -432
  13. schemathesis/cli/__main__.py +4 -0
  14. schemathesis/cli/callbacks.py +58 -13
  15. schemathesis/cli/cassettes.py +233 -47
  16. schemathesis/cli/constants.py +8 -2
  17. schemathesis/cli/context.py +22 -5
  18. schemathesis/cli/debug.py +2 -1
  19. schemathesis/cli/handlers.py +4 -1
  20. schemathesis/cli/junitxml.py +103 -22
  21. schemathesis/cli/options.py +15 -4
  22. schemathesis/cli/output/default.py +258 -112
  23. schemathesis/cli/output/short.py +23 -8
  24. schemathesis/cli/reporting.py +79 -0
  25. schemathesis/cli/sanitization.py +6 -0
  26. schemathesis/code_samples.py +5 -3
  27. schemathesis/constants.py +1 -0
  28. schemathesis/contrib/openapi/__init__.py +1 -1
  29. schemathesis/contrib/openapi/fill_missing_examples.py +3 -1
  30. schemathesis/contrib/openapi/formats/uuid.py +2 -1
  31. schemathesis/contrib/unique_data.py +3 -3
  32. schemathesis/exceptions.py +76 -65
  33. schemathesis/experimental/__init__.py +35 -0
  34. schemathesis/extra/_aiohttp.py +1 -0
  35. schemathesis/extra/_flask.py +4 -1
  36. schemathesis/extra/_server.py +1 -0
  37. schemathesis/extra/pytest_plugin.py +17 -25
  38. schemathesis/failures.py +77 -9
  39. schemathesis/filters.py +185 -8
  40. schemathesis/fixups/__init__.py +1 -0
  41. schemathesis/fixups/fast_api.py +2 -2
  42. schemathesis/fixups/utf8_bom.py +1 -2
  43. schemathesis/generation/__init__.py +20 -36
  44. schemathesis/generation/_hypothesis.py +59 -0
  45. schemathesis/generation/_methods.py +44 -0
  46. schemathesis/generation/coverage.py +931 -0
  47. schemathesis/graphql.py +0 -1
  48. schemathesis/hooks.py +89 -12
  49. schemathesis/internal/checks.py +84 -0
  50. schemathesis/internal/copy.py +22 -3
  51. schemathesis/internal/deprecation.py +6 -2
  52. schemathesis/internal/diff.py +15 -0
  53. schemathesis/internal/extensions.py +27 -0
  54. schemathesis/internal/jsonschema.py +2 -1
  55. schemathesis/internal/output.py +68 -0
  56. schemathesis/internal/result.py +1 -1
  57. schemathesis/internal/transformation.py +11 -0
  58. schemathesis/lazy.py +138 -25
  59. schemathesis/loaders.py +7 -5
  60. schemathesis/models.py +318 -211
  61. schemathesis/parameters.py +4 -0
  62. schemathesis/runner/__init__.py +50 -15
  63. schemathesis/runner/events.py +65 -5
  64. schemathesis/runner/impl/context.py +104 -0
  65. schemathesis/runner/impl/core.py +388 -177
  66. schemathesis/runner/impl/solo.py +19 -29
  67. schemathesis/runner/impl/threadpool.py +70 -79
  68. schemathesis/runner/probes.py +11 -9
  69. schemathesis/runner/serialization.py +150 -17
  70. schemathesis/sanitization.py +5 -1
  71. schemathesis/schemas.py +170 -102
  72. schemathesis/serializers.py +7 -2
  73. schemathesis/service/ci.py +1 -0
  74. schemathesis/service/client.py +39 -6
  75. schemathesis/service/events.py +5 -1
  76. schemathesis/service/extensions.py +224 -0
  77. schemathesis/service/hosts.py +6 -2
  78. schemathesis/service/metadata.py +25 -0
  79. schemathesis/service/models.py +211 -2
  80. schemathesis/service/report.py +6 -6
  81. schemathesis/service/serialization.py +45 -71
  82. schemathesis/service/usage.py +1 -0
  83. schemathesis/specs/graphql/_cache.py +26 -0
  84. schemathesis/specs/graphql/loaders.py +25 -5
  85. schemathesis/specs/graphql/nodes.py +1 -0
  86. schemathesis/specs/graphql/scalars.py +2 -2
  87. schemathesis/specs/graphql/schemas.py +130 -100
  88. schemathesis/specs/graphql/validation.py +1 -2
  89. schemathesis/specs/openapi/__init__.py +1 -0
  90. schemathesis/specs/openapi/_cache.py +123 -0
  91. schemathesis/specs/openapi/_hypothesis.py +78 -60
  92. schemathesis/specs/openapi/checks.py +504 -25
  93. schemathesis/specs/openapi/converter.py +31 -4
  94. schemathesis/specs/openapi/definitions.py +10 -17
  95. schemathesis/specs/openapi/examples.py +126 -12
  96. schemathesis/specs/openapi/expressions/__init__.py +37 -2
  97. schemathesis/specs/openapi/expressions/context.py +1 -1
  98. schemathesis/specs/openapi/expressions/extractors.py +26 -0
  99. schemathesis/specs/openapi/expressions/lexer.py +20 -18
  100. schemathesis/specs/openapi/expressions/nodes.py +29 -6
  101. schemathesis/specs/openapi/expressions/parser.py +26 -5
  102. schemathesis/specs/openapi/formats.py +44 -0
  103. schemathesis/specs/openapi/links.py +125 -42
  104. schemathesis/specs/openapi/loaders.py +77 -36
  105. schemathesis/specs/openapi/media_types.py +34 -0
  106. schemathesis/specs/openapi/negative/__init__.py +6 -3
  107. schemathesis/specs/openapi/negative/mutations.py +21 -6
  108. schemathesis/specs/openapi/parameters.py +39 -25
  109. schemathesis/specs/openapi/patterns.py +137 -0
  110. schemathesis/specs/openapi/references.py +37 -7
  111. schemathesis/specs/openapi/schemas.py +360 -241
  112. schemathesis/specs/openapi/security.py +25 -7
  113. schemathesis/specs/openapi/serialization.py +1 -0
  114. schemathesis/specs/openapi/stateful/__init__.py +198 -70
  115. schemathesis/specs/openapi/stateful/statistic.py +198 -0
  116. schemathesis/specs/openapi/stateful/types.py +14 -0
  117. schemathesis/specs/openapi/utils.py +6 -1
  118. schemathesis/specs/openapi/validation.py +1 -0
  119. schemathesis/stateful/__init__.py +35 -21
  120. schemathesis/stateful/config.py +97 -0
  121. schemathesis/stateful/context.py +135 -0
  122. schemathesis/stateful/events.py +274 -0
  123. schemathesis/stateful/runner.py +309 -0
  124. schemathesis/stateful/sink.py +68 -0
  125. schemathesis/stateful/state_machine.py +67 -38
  126. schemathesis/stateful/statistic.py +22 -0
  127. schemathesis/stateful/validation.py +100 -0
  128. schemathesis/targets.py +33 -1
  129. schemathesis/throttling.py +25 -5
  130. schemathesis/transports/__init__.py +354 -0
  131. schemathesis/transports/asgi.py +7 -0
  132. schemathesis/transports/auth.py +25 -2
  133. schemathesis/transports/content_types.py +3 -1
  134. schemathesis/transports/headers.py +2 -1
  135. schemathesis/transports/responses.py +9 -4
  136. schemathesis/types.py +9 -0
  137. schemathesis/utils.py +11 -16
  138. schemathesis-3.39.7.dist-info/METADATA +293 -0
  139. schemathesis-3.39.7.dist-info/RECORD +160 -0
  140. {schemathesis-3.25.6.dist-info → schemathesis-3.39.7.dist-info}/WHEEL +1 -1
  141. schemathesis/specs/openapi/filters.py +0 -49
  142. schemathesis/specs/openapi/stateful/links.py +0 -92
  143. schemathesis-3.25.6.dist-info/METADATA +0 -356
  144. schemathesis-3.25.6.dist-info/RECORD +0 -134
  145. {schemathesis-3.25.6.dist-info → schemathesis-3.39.7.dist-info}/entry_points.txt +0 -0
  146. {schemathesis-3.25.6.dist-info → schemathesis-3.39.7.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,309 @@
1
+ from __future__ import annotations
2
+
3
+ import queue
4
+ import threading
5
+ from contextlib import contextmanager
6
+ from dataclasses import dataclass, field
7
+ from typing import TYPE_CHECKING, Any, Generator, Iterator
8
+
9
+ import hypothesis
10
+ import requests
11
+ from hypothesis.control import current_build_context
12
+ from hypothesis.errors import Flaky, Unsatisfiable
13
+
14
+ from ..exceptions import CheckFailed
15
+ from ..internal.checks import CheckContext
16
+ from ..targets import TargetMetricCollector
17
+ from . import events
18
+ from .config import StatefulTestRunnerConfig
19
+ from .context import RunnerContext
20
+ from .validation import validate_response
21
+
22
+ if TYPE_CHECKING:
23
+ from hypothesis.stateful import Rule
24
+
25
+ from ..models import Case, CheckFunction
26
+ from ..transports.responses import GenericResponse
27
+ from .state_machine import APIStateMachine, Direction, StepResult
28
+
29
+ EVENT_QUEUE_TIMEOUT = 0.01
30
+
31
+
32
+ @dataclass
33
+ class StatefulTestRunner:
34
+ """Stateful test runner for the given state machine.
35
+
36
+ By default, the test runner executes the state machine in a loop until there are no new failures are found.
37
+ The loop is executed in a separate thread for better control over the execution and reporting.
38
+ """
39
+
40
+ # State machine class to use
41
+ state_machine: type[APIStateMachine]
42
+ # Test runner configuration that defines the runtime behavior
43
+ config: StatefulTestRunnerConfig = field(default_factory=StatefulTestRunnerConfig)
44
+ # Event to stop the execution
45
+ stop_event: threading.Event = field(default_factory=threading.Event)
46
+ # Queue to communicate with the state machine execution
47
+ event_queue: queue.Queue = field(default_factory=queue.Queue)
48
+
49
+ def execute(self) -> Iterator[events.StatefulEvent]:
50
+ """Execute a test run for a state machine."""
51
+ self.stop_event.clear()
52
+
53
+ yield events.RunStarted(state_machine=self.state_machine)
54
+
55
+ runner_thread = threading.Thread(
56
+ target=_execute_state_machine_loop,
57
+ kwargs={
58
+ "state_machine": self.state_machine,
59
+ "event_queue": self.event_queue,
60
+ "config": self.config,
61
+ "stop_event": self.stop_event,
62
+ },
63
+ )
64
+ run_status = events.RunStatus.SUCCESS
65
+
66
+ with thread_manager(runner_thread):
67
+ try:
68
+ while True:
69
+ try:
70
+ event = self.event_queue.get(timeout=EVENT_QUEUE_TIMEOUT)
71
+ # Set the run status based on the suite status
72
+ # ERROR & INTERRUPTED statuses are terminal, therefore they should not be overridden
73
+ if isinstance(event, events.SuiteFinished):
74
+ if event.status == events.SuiteStatus.FAILURE:
75
+ run_status = events.RunStatus.FAILURE
76
+ elif event.status == events.SuiteStatus.ERROR:
77
+ run_status = events.RunStatus.ERROR
78
+ elif event.status == events.SuiteStatus.INTERRUPTED:
79
+ run_status = events.RunStatus.INTERRUPTED
80
+ yield event
81
+ except queue.Empty:
82
+ if not runner_thread.is_alive():
83
+ break
84
+ except KeyboardInterrupt:
85
+ # Immediately notify the runner thread to stop, even though that the event will be set below in `finally`
86
+ self.stop()
87
+ run_status = events.RunStatus.INTERRUPTED
88
+ yield events.Interrupted()
89
+ finally:
90
+ self.stop()
91
+
92
+ yield events.RunFinished(status=run_status)
93
+
94
+ def stop(self) -> None:
95
+ """Stop the execution of the state machine."""
96
+ self.stop_event.set()
97
+
98
+
99
+ @contextmanager
100
+ def thread_manager(thread: threading.Thread) -> Generator[None, None, None]:
101
+ thread.start()
102
+ try:
103
+ yield
104
+ finally:
105
+ thread.join()
106
+
107
+
108
+ def _execute_state_machine_loop(
109
+ *,
110
+ state_machine: type[APIStateMachine],
111
+ event_queue: queue.Queue,
112
+ config: StatefulTestRunnerConfig,
113
+ stop_event: threading.Event,
114
+ ) -> None:
115
+ """Execute the state machine testing loop."""
116
+ from hypothesis import reporting
117
+ from requests.structures import CaseInsensitiveDict
118
+
119
+ from ..transports import RequestsTransport
120
+
121
+ ctx = RunnerContext(metric_collector=TargetMetricCollector(targets=config.targets))
122
+
123
+ call_kwargs: dict[str, Any] = {"headers": config.headers}
124
+ if isinstance(state_machine.schema.transport, RequestsTransport):
125
+ call_kwargs["timeout"] = config.request.prepared_timeout
126
+ call_kwargs["verify"] = config.request.tls_verify
127
+ call_kwargs["cert"] = config.request.cert
128
+ if config.request.proxy is not None:
129
+ call_kwargs["proxies"] = {"all": config.request.proxy}
130
+ session = requests.Session()
131
+ if config.auth is not None:
132
+ session.auth = config.auth
133
+ call_kwargs["session"] = session
134
+ check_ctx = CheckContext(
135
+ override=config.override,
136
+ auth=config.auth,
137
+ headers=CaseInsensitiveDict(config.headers) if config.headers else None,
138
+ )
139
+
140
+ class _InstrumentedStateMachine(state_machine): # type: ignore[valid-type,misc]
141
+ """State machine with additional hooks for emitting events."""
142
+
143
+ def setup(self) -> None:
144
+ build_ctx = current_build_context()
145
+ event_queue.put(events.ScenarioStarted(is_final=build_ctx.is_final))
146
+ super().setup()
147
+
148
+ def get_call_kwargs(self, case: Case) -> dict[str, Any]:
149
+ return call_kwargs
150
+
151
+ def _repr_step(self, rule: Rule, data: dict, result: StepResult) -> str:
152
+ return ""
153
+
154
+ if config.override is not None:
155
+
156
+ def before_call(self, case: Case) -> None:
157
+ for location, entry in config.override.for_operation(case.operation).items(): # type: ignore[union-attr]
158
+ if entry:
159
+ container = getattr(case, location) or {}
160
+ container.update(entry)
161
+ setattr(case, location, container)
162
+ return super().before_call(case)
163
+
164
+ def step(self, case: Case, previous: tuple[StepResult, Direction] | None = None) -> StepResult | None:
165
+ # Checking the stop event once inside `step` is sufficient as it is called frequently
166
+ # The idea is to stop the execution as soon as possible
167
+ if stop_event.is_set():
168
+ raise KeyboardInterrupt
169
+ event_queue.put(events.StepStarted())
170
+ try:
171
+ if config.dry_run:
172
+ return None
173
+ if config.unique_data:
174
+ cached = ctx.get_step_outcome(case)
175
+ if isinstance(cached, BaseException):
176
+ raise cached
177
+ elif cached is None:
178
+ return None
179
+ result = super().step(case, previous)
180
+ ctx.step_succeeded()
181
+ except CheckFailed as exc:
182
+ if config.unique_data:
183
+ ctx.store_step_outcome(case, exc)
184
+ ctx.step_failed()
185
+ raise
186
+ except Exception as exc:
187
+ if config.unique_data:
188
+ ctx.store_step_outcome(case, exc)
189
+ ctx.step_errored()
190
+ raise
191
+ except KeyboardInterrupt:
192
+ ctx.step_interrupted()
193
+ raise
194
+ except BaseException as exc:
195
+ if config.unique_data:
196
+ ctx.store_step_outcome(case, exc)
197
+ raise exc
198
+ else:
199
+ if config.unique_data:
200
+ ctx.store_step_outcome(case, None)
201
+ finally:
202
+ transition_id: events.TransitionId | None
203
+ if previous is not None:
204
+ transition = previous[1]
205
+ transition_id = events.TransitionId(
206
+ name=transition.name,
207
+ status_code=transition.status_code,
208
+ source=transition.operation.verbose_name,
209
+ )
210
+ else:
211
+ transition_id = None
212
+ event_queue.put(
213
+ events.StepFinished(
214
+ status=ctx.current_step_status,
215
+ transition_id=transition_id,
216
+ target=case.operation.verbose_name,
217
+ case=case,
218
+ response=ctx.current_response,
219
+ checks=ctx.checks_for_step,
220
+ )
221
+ )
222
+ ctx.reset_step()
223
+ return result
224
+
225
+ def validate_response(
226
+ self, response: GenericResponse, case: Case, additional_checks: tuple[CheckFunction, ...] = ()
227
+ ) -> None:
228
+ ctx.collect_metric(case, response)
229
+ ctx.current_response = response
230
+ validate_response(
231
+ response=response,
232
+ case=case,
233
+ runner_ctx=ctx,
234
+ check_ctx=check_ctx,
235
+ checks=config.checks,
236
+ additional_checks=additional_checks,
237
+ max_response_time=config.max_response_time,
238
+ )
239
+
240
+ def teardown(self) -> None:
241
+ build_ctx = current_build_context()
242
+ event_queue.put(
243
+ events.ScenarioFinished(
244
+ status=ctx.current_scenario_status,
245
+ is_final=build_ctx.is_final,
246
+ )
247
+ )
248
+ ctx.maximize_metrics()
249
+ ctx.reset_scenario()
250
+ super().teardown()
251
+
252
+ if config.seed is not None:
253
+ InstrumentedStateMachine = hypothesis.seed(config.seed)(_InstrumentedStateMachine)
254
+ else:
255
+ InstrumentedStateMachine = _InstrumentedStateMachine
256
+
257
+ def should_stop() -> bool:
258
+ return config.exit_first or (config.max_failures is not None and ctx.failures_count >= config.max_failures)
259
+
260
+ while True:
261
+ # This loop is running until no new failures are found in a single iteration
262
+ event_queue.put(events.SuiteStarted())
263
+ if stop_event.is_set():
264
+ event_queue.put(events.SuiteFinished(status=events.SuiteStatus.INTERRUPTED, failures=[]))
265
+ break
266
+ suite_status = events.SuiteStatus.SUCCESS
267
+ try:
268
+ with reporting.with_reporter(lambda _: None): # type: ignore
269
+ InstrumentedStateMachine.run(settings=config.hypothesis_settings)
270
+ except KeyboardInterrupt:
271
+ # Raised in the state machine when the stop event is set or it is raised by the user's code
272
+ # that is placed in the base class of the state machine.
273
+ # Therefore, set the stop event to cover the latter case
274
+ stop_event.set()
275
+ suite_status = events.SuiteStatus.INTERRUPTED
276
+ break
277
+ except CheckFailed as exc:
278
+ # When a check fails, the state machine is stopped
279
+ # The failure is already sent to the queue by the state machine
280
+ # Here we need to either exit or re-run the state machine with this failure marked as known
281
+ suite_status = events.SuiteStatus.FAILURE
282
+ if should_stop():
283
+ break
284
+ ctx.mark_as_seen_in_run(exc)
285
+ continue
286
+ except Flaky:
287
+ suite_status = events.SuiteStatus.FAILURE
288
+ if should_stop():
289
+ break
290
+ # Mark all failures in this suite as seen to prevent them being re-discovered
291
+ ctx.mark_current_suite_as_seen_in_run()
292
+ continue
293
+ except Exception as exc:
294
+ if isinstance(exc, Unsatisfiable) and ctx.completed_scenarios > 0:
295
+ # Sometimes Hypothesis randomly gives up on generating some complex cases. However, if we know that
296
+ # values are possible to generate based on the previous observations, we retry the generation
297
+ if ctx.completed_scenarios >= config.hypothesis_settings.max_examples:
298
+ # Avoid infinite restarts
299
+ break
300
+ continue
301
+ # Any other exception is an inner error and the test run should be stopped
302
+ suite_status = events.SuiteStatus.ERROR
303
+ event_queue.put(events.Errored(exception=exc))
304
+ break
305
+ finally:
306
+ event_queue.put(events.SuiteFinished(status=suite_status, failures=ctx.failures_for_suite))
307
+ ctx.reset()
308
+ # Exit on the first successful state machine execution
309
+ break
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import TYPE_CHECKING
5
+
6
+ from . import events
7
+
8
+ if TYPE_CHECKING:
9
+ from ..models import Check
10
+ from .statistic import TransitionStats
11
+
12
+
13
+ @dataclass
14
+ class AverageResponseTime:
15
+ """Average response time for a given status code.
16
+
17
+ Stored as a sum of all response times and a count of responses.
18
+ """
19
+
20
+ total: float
21
+ count: int
22
+
23
+ __slots__ = ("total", "count")
24
+
25
+ def __init__(self) -> None:
26
+ self.total = 0.0
27
+ self.count = 0
28
+
29
+
30
+ @dataclass
31
+ class StateMachineSink:
32
+ """Collects events and stores data about the state machine execution."""
33
+
34
+ transitions: TransitionStats
35
+ response_times: dict[str, dict[int, AverageResponseTime]] = field(default_factory=dict)
36
+ steps: dict[events.StepStatus, int] = field(default_factory=lambda: {status: 0 for status in events.StepStatus})
37
+ scenarios: dict[events.ScenarioStatus, int] = field(
38
+ default_factory=lambda: {status: 0 for status in events.ScenarioStatus}
39
+ )
40
+ suites: dict[events.SuiteStatus, int] = field(default_factory=lambda: {status: 0 for status in events.SuiteStatus})
41
+ failures: list[Check] = field(default_factory=list)
42
+ start_time: float | None = None
43
+ end_time: float | None = None
44
+
45
+ def consume(self, event: events.StatefulEvent) -> None:
46
+ self.transitions.consume(event)
47
+ if isinstance(event, events.RunStarted):
48
+ self.start_time = event.timestamp
49
+ elif isinstance(event, events.StepFinished) and event.status is not None:
50
+ self.steps[event.status] += 1
51
+ responses = self.response_times.setdefault(event.target, {})
52
+ if event.response is not None:
53
+ average = responses.setdefault(event.response.status_code, AverageResponseTime())
54
+ average.total += event.response.elapsed.total_seconds()
55
+ average.count += 1
56
+ elif isinstance(event, events.ScenarioFinished):
57
+ self.scenarios[event.status] += 1
58
+ elif isinstance(event, events.SuiteFinished):
59
+ self.suites[event.status] += 1
60
+ self.failures.extend(event.failures)
61
+ elif isinstance(event, events.RunFinished):
62
+ self.end_time = event.timestamp
63
+
64
+ @property
65
+ def duration(self) -> float | None:
66
+ if self.start_time is not None and self.end_time is not None:
67
+ return self.end_time - self.start_time
68
+ return None
@@ -1,9 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
- import time
4
3
  import re
4
+ import time
5
5
  from dataclasses import dataclass
6
- from typing import TYPE_CHECKING, Any, Callable, ClassVar
6
+ from functools import lru_cache
7
+ from typing import TYPE_CHECKING, Any, ClassVar
7
8
 
8
9
  from hypothesis.errors import InvalidDefinition
9
10
  from hypothesis.stateful import RuleBasedStateMachine
@@ -11,7 +12,11 @@ from hypothesis.stateful import RuleBasedStateMachine
11
12
  from .._dependency_versions import HYPOTHESIS_HAS_STATEFUL_NAMING_IMPROVEMENTS
12
13
  from ..constants import NO_LINKS_ERROR_MESSAGE, NOT_SET
13
14
  from ..exceptions import UsageError
14
- from ..models import APIOperation, Case, CheckFunction
15
+ from ..internal.checks import CheckFunction
16
+ from ..models import APIOperation, Case
17
+ from .config import _default_hypothesis_settings_factory
18
+ from .runner import StatefulTestRunner, StatefulTestRunnerConfig
19
+ from .sink import StateMachineSink
15
20
 
16
21
  if TYPE_CHECKING:
17
22
  import hypothesis
@@ -19,6 +24,7 @@ if TYPE_CHECKING:
19
24
 
20
25
  from ..schemas import BaseSchema
21
26
  from ..transports.responses import GenericResponse
27
+ from .statistic import TransitionStats
22
28
 
23
29
 
24
30
  @dataclass
@@ -30,7 +36,7 @@ class StepResult:
30
36
  elapsed: float
31
37
 
32
38
 
33
- def _operation_name_to_identifier(name: str) -> str:
39
+ def _normalize_name(name: str) -> str:
34
40
  return re.sub(r"\W|^(?=\d)", "_", name).replace("__", "_")
35
41
 
36
42
 
@@ -45,6 +51,8 @@ class APIStateMachine(RuleBasedStateMachine):
45
51
  # attribute will be renamed in the future
46
52
  bundles: ClassVar[dict[str, CaseInsensitiveDict]] # type: ignore
47
53
  schema: BaseSchema
54
+ # A template for transition statistics that can be filled with data from the state machine during its execution
55
+ _transition_stats_template: ClassVar[TransitionStats]
48
56
 
49
57
  def __init__(self) -> None:
50
58
  try:
@@ -55,23 +63,50 @@ class APIStateMachine(RuleBasedStateMachine):
55
63
  raise
56
64
  self.setup()
57
65
 
66
+ @classmethod
67
+ @lru_cache
68
+ def _to_test_case(cls) -> type:
69
+ from . import run_state_machine_as_test
70
+
71
+ class StateMachineTestCase(RuleBasedStateMachine.TestCase):
72
+ settings = _default_hypothesis_settings_factory()
73
+
74
+ def runTest(self) -> None:
75
+ run_state_machine_as_test(cls, settings=self.settings)
76
+
77
+ runTest.is_hypothesis_test = True # type: ignore[attr-defined]
78
+
79
+ StateMachineTestCase.__name__ = cls.__name__ + ".TestCase"
80
+ StateMachineTestCase.__qualname__ = cls.__qualname__ + ".TestCase"
81
+ return StateMachineTestCase
82
+
58
83
  def _pretty_print(self, value: Any) -> str:
59
84
  if isinstance(value, Case):
60
85
  # State machines suppose to be reproducible, hence it is OK to get kwargs here
61
86
  kwargs = self.get_call_kwargs(value)
62
87
  return _print_case(value, kwargs)
63
- if isinstance(value, tuple) and len(value) == 2:
64
- result, direction = value
65
- wrapper = _DirectionWrapper(direction)
66
- return super()._pretty_print((result, wrapper)) # type: ignore
67
88
  return super()._pretty_print(value) # type: ignore
68
89
 
69
90
  if HYPOTHESIS_HAS_STATEFUL_NAMING_IMPROVEMENTS:
70
91
 
71
92
  def _new_name(self, target: str) -> str:
72
- target = _operation_name_to_identifier(target)
93
+ target = _normalize_name(target)
73
94
  return super()._new_name(target) # type: ignore
74
95
 
96
+ def _get_target_for_result(self, result: StepResult) -> str | None:
97
+ raise NotImplementedError
98
+
99
+ def _add_result_to_targets(self, targets: tuple[str, ...], result: StepResult | None) -> None:
100
+ if result is None:
101
+ return
102
+ target = self._get_target_for_result(result)
103
+ if target is not None:
104
+ super()._add_result_to_targets((target,), result)
105
+
106
+ @classmethod
107
+ def format_rules(cls) -> str:
108
+ raise NotImplementedError
109
+
75
110
  @classmethod
76
111
  def run(cls, *, settings: hypothesis.settings | None = None) -> None:
77
112
  """Run state machine as a test."""
@@ -79,6 +114,18 @@ class APIStateMachine(RuleBasedStateMachine):
79
114
 
80
115
  return run_state_machine_as_test(cls, settings=settings)
81
116
 
117
+ @classmethod
118
+ def runner(cls, *, config: StatefulTestRunnerConfig | None = None) -> StatefulTestRunner:
119
+ """Create a runner for this state machine."""
120
+ from .runner import StatefulTestRunnerConfig
121
+
122
+ return StatefulTestRunner(cls, config=config or StatefulTestRunnerConfig())
123
+
124
+ @classmethod
125
+ def sink(cls) -> StateMachineSink:
126
+ """Create a sink to collect events into."""
127
+ return StateMachineSink(transitions=cls._transition_stats_template.copy())
128
+
82
129
  def setup(self) -> None:
83
130
  """Hook method that runs unconditionally in the beginning of each test scenario.
84
131
 
@@ -94,14 +141,16 @@ class APIStateMachine(RuleBasedStateMachine):
94
141
  def transform(self, result: StepResult, direction: Direction, case: Case) -> Case:
95
142
  raise NotImplementedError
96
143
 
97
- def _step(self, case: Case, previous: tuple[StepResult, Direction] | None = None) -> StepResult:
144
+ def _step(self, case: Case, previous: StepResult | None = None, link: Direction | None = None) -> StepResult | None:
98
145
  # This method is a proxy that is used under the hood during the state machine initialization.
99
146
  # The whole point of having it is to make it possible to override `step`; otherwise, custom "step" is ignored.
100
147
  # It happens because, at the point of initialization, the final class is not yet created.
101
148
  __tracebackhide__ = True
102
- return self.step(case, previous)
149
+ if previous is not None and link is not None:
150
+ return self.step(case, (previous, link))
151
+ return self.step(case, None)
103
152
 
104
- def step(self, case: Case, previous: tuple[StepResult, Direction] | None = None) -> StepResult:
153
+ def step(self, case: Case, previous: tuple[StepResult, Direction] | None = None) -> StepResult | None:
105
154
  """A single state machine step.
106
155
 
107
156
  :param Case case: Generated test case data that should be sent in an API call to the tested API operation.
@@ -110,6 +159,8 @@ class APIStateMachine(RuleBasedStateMachine):
110
159
  Schemathesis prepares data, makes a call and validates the received response.
111
160
  It is the most high-level point to extend the testing process. You probably don't need it in most cases.
112
161
  """
162
+ from ..specs.openapi.checks import use_after_free
163
+
113
164
  __tracebackhide__ = True
114
165
  if previous is not None:
115
166
  result, direction = previous
@@ -120,7 +171,7 @@ class APIStateMachine(RuleBasedStateMachine):
120
171
  response = self.call(case, **kwargs)
121
172
  elapsed = time.monotonic() - start
122
173
  self.after_call(response, case)
123
- self.validate_response(response, case)
174
+ self.validate_response(response, case, additional_checks=(use_after_free,))
124
175
  return self.store_result(response, case, elapsed)
125
176
 
126
177
  def before_call(self, case: Case) -> None:
@@ -189,13 +240,12 @@ class APIStateMachine(RuleBasedStateMachine):
189
240
  :return: Response from the application under test.
190
241
 
191
242
  Note that WSGI/ASGI applications are detected automatically in this method. Depending on the result of this
192
- detection the state machine will call ``call``, ``call_wsgi`` or ``call_asgi`` methods.
243
+ detection the state machine will call the ``call`` method.
193
244
 
194
245
  Usually, you don't need to override this method unless you are building a different state machine on top of this
195
246
  one and want to customize the transport layer itself.
196
247
  """
197
- method = self._get_call_method(case)
198
- return method(**kwargs)
248
+ return case.call(**kwargs)
199
249
 
200
250
  def get_call_kwargs(self, case: Case) -> dict[str, Any]:
201
251
  """Create custom keyword arguments that will be passed to the :meth:`Case.call` method.
@@ -214,15 +264,6 @@ class APIStateMachine(RuleBasedStateMachine):
214
264
  """
215
265
  return {}
216
266
 
217
- def _get_call_method(self, case: Case) -> Callable:
218
- if case.app is not None:
219
- from starlette.applications import Starlette
220
-
221
- if isinstance(case.app, Starlette):
222
- return case.call_asgi
223
- return case.call_wsgi
224
- return case.call
225
-
226
267
  def validate_response(
227
268
  self, response: GenericResponse, case: Case, additional_checks: tuple[CheckFunction, ...] = ()
228
269
  ) -> None:
@@ -270,7 +311,7 @@ def _print_case(case: Case, kwargs: dict[str, Any]) -> str:
270
311
  headers.update(kwargs.get("headers", {}))
271
312
  case.headers = headers
272
313
  data = [
273
- f"{name}={repr(getattr(case, name))}"
314
+ f"{name}={getattr(case, name)!r}"
274
315
  for name in ("path_parameters", "headers", "cookies", "query", "body", "media_type")
275
316
  if getattr(case, name) not in (None, NOT_SET)
276
317
  ]
@@ -284,15 +325,3 @@ class Direction:
284
325
 
285
326
  def set_data(self, case: Case, elapsed: float, **kwargs: Any) -> None:
286
327
  raise NotImplementedError
287
-
288
-
289
- @dataclass(repr=False)
290
- class _DirectionWrapper:
291
- """Purely to avoid modification of `Direction.__repr__`."""
292
-
293
- direction: Direction
294
-
295
- def __repr__(self) -> str:
296
- path = self.direction.operation.path
297
- method = self.direction.operation.method.upper()
298
- return f"state.schema['{path}']['{method}'].links['{self.direction.status_code}']['{self.direction.name}']"
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from . import events
8
+
9
+
10
+ @dataclass
11
+ class TransitionStats:
12
+ """Statistic for transitions in a state machine."""
13
+
14
+ def consume(self, event: events.StatefulEvent) -> None:
15
+ raise NotImplementedError
16
+
17
+ def copy(self) -> TransitionStats:
18
+ """Create a copy of the statistic."""
19
+ raise NotImplementedError
20
+
21
+ def to_formatted_table(self, width: int) -> str:
22
+ raise NotImplementedError