schemathesis 3.25.5__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.
- schemathesis/__init__.py +6 -6
- schemathesis/_compat.py +2 -2
- schemathesis/_dependency_versions.py +4 -2
- schemathesis/_hypothesis.py +369 -56
- schemathesis/_lazy_import.py +1 -0
- schemathesis/_override.py +5 -4
- schemathesis/_patches.py +21 -0
- schemathesis/_rate_limiter.py +7 -0
- schemathesis/_xml.py +75 -22
- schemathesis/auths.py +78 -16
- schemathesis/checks.py +21 -9
- schemathesis/cli/__init__.py +793 -448
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/callbacks.py +58 -13
- schemathesis/cli/cassettes.py +233 -47
- schemathesis/cli/constants.py +8 -2
- schemathesis/cli/context.py +24 -4
- schemathesis/cli/debug.py +2 -1
- schemathesis/cli/handlers.py +4 -1
- schemathesis/cli/junitxml.py +103 -22
- schemathesis/cli/options.py +15 -4
- schemathesis/cli/output/default.py +286 -115
- schemathesis/cli/output/short.py +25 -6
- schemathesis/cli/reporting.py +79 -0
- schemathesis/cli/sanitization.py +6 -0
- schemathesis/code_samples.py +5 -3
- schemathesis/constants.py +1 -0
- schemathesis/contrib/openapi/__init__.py +1 -1
- schemathesis/contrib/openapi/fill_missing_examples.py +3 -1
- schemathesis/contrib/openapi/formats/uuid.py +2 -1
- schemathesis/contrib/unique_data.py +3 -3
- schemathesis/exceptions.py +76 -65
- schemathesis/experimental/__init__.py +35 -0
- schemathesis/extra/_aiohttp.py +1 -0
- schemathesis/extra/_flask.py +4 -1
- schemathesis/extra/_server.py +1 -0
- schemathesis/extra/pytest_plugin.py +17 -25
- schemathesis/failures.py +77 -9
- schemathesis/filters.py +185 -8
- schemathesis/fixups/__init__.py +1 -0
- schemathesis/fixups/fast_api.py +2 -2
- schemathesis/fixups/utf8_bom.py +1 -2
- schemathesis/generation/__init__.py +20 -36
- schemathesis/generation/_hypothesis.py +59 -0
- schemathesis/generation/_methods.py +44 -0
- schemathesis/generation/coverage.py +931 -0
- schemathesis/graphql.py +0 -1
- schemathesis/hooks.py +89 -12
- schemathesis/internal/checks.py +84 -0
- schemathesis/internal/copy.py +22 -3
- schemathesis/internal/deprecation.py +6 -2
- schemathesis/internal/diff.py +15 -0
- schemathesis/internal/extensions.py +27 -0
- schemathesis/internal/jsonschema.py +2 -1
- schemathesis/internal/output.py +68 -0
- schemathesis/internal/result.py +1 -1
- schemathesis/internal/transformation.py +11 -0
- schemathesis/lazy.py +138 -25
- schemathesis/loaders.py +7 -5
- schemathesis/models.py +323 -213
- schemathesis/parameters.py +4 -0
- schemathesis/runner/__init__.py +72 -22
- schemathesis/runner/events.py +86 -6
- schemathesis/runner/impl/context.py +104 -0
- schemathesis/runner/impl/core.py +447 -187
- schemathesis/runner/impl/solo.py +19 -29
- schemathesis/runner/impl/threadpool.py +70 -79
- schemathesis/{cli → runner}/probes.py +37 -25
- schemathesis/runner/serialization.py +150 -17
- schemathesis/sanitization.py +5 -1
- schemathesis/schemas.py +170 -102
- schemathesis/serializers.py +17 -4
- schemathesis/service/ci.py +1 -0
- schemathesis/service/client.py +39 -6
- schemathesis/service/events.py +5 -1
- schemathesis/service/extensions.py +224 -0
- schemathesis/service/hosts.py +6 -2
- schemathesis/service/metadata.py +25 -0
- schemathesis/service/models.py +211 -2
- schemathesis/service/report.py +6 -6
- schemathesis/service/serialization.py +60 -71
- schemathesis/service/usage.py +1 -0
- schemathesis/specs/graphql/_cache.py +26 -0
- schemathesis/specs/graphql/loaders.py +25 -5
- schemathesis/specs/graphql/nodes.py +1 -0
- schemathesis/specs/graphql/scalars.py +2 -2
- schemathesis/specs/graphql/schemas.py +130 -100
- schemathesis/specs/graphql/validation.py +1 -2
- schemathesis/specs/openapi/__init__.py +1 -0
- schemathesis/specs/openapi/_cache.py +123 -0
- schemathesis/specs/openapi/_hypothesis.py +79 -61
- schemathesis/specs/openapi/checks.py +504 -25
- schemathesis/specs/openapi/converter.py +31 -4
- schemathesis/specs/openapi/definitions.py +10 -17
- schemathesis/specs/openapi/examples.py +143 -31
- schemathesis/specs/openapi/expressions/__init__.py +37 -2
- schemathesis/specs/openapi/expressions/context.py +1 -1
- schemathesis/specs/openapi/expressions/extractors.py +26 -0
- schemathesis/specs/openapi/expressions/lexer.py +20 -18
- schemathesis/specs/openapi/expressions/nodes.py +29 -6
- schemathesis/specs/openapi/expressions/parser.py +26 -5
- schemathesis/specs/openapi/formats.py +44 -0
- schemathesis/specs/openapi/links.py +125 -42
- schemathesis/specs/openapi/loaders.py +77 -36
- schemathesis/specs/openapi/media_types.py +34 -0
- schemathesis/specs/openapi/negative/__init__.py +6 -3
- schemathesis/specs/openapi/negative/mutations.py +21 -6
- schemathesis/specs/openapi/parameters.py +39 -25
- schemathesis/specs/openapi/patterns.py +137 -0
- schemathesis/specs/openapi/references.py +37 -7
- schemathesis/specs/openapi/schemas.py +368 -242
- schemathesis/specs/openapi/security.py +25 -7
- schemathesis/specs/openapi/serialization.py +1 -0
- schemathesis/specs/openapi/stateful/__init__.py +198 -70
- schemathesis/specs/openapi/stateful/statistic.py +198 -0
- schemathesis/specs/openapi/stateful/types.py +14 -0
- schemathesis/specs/openapi/utils.py +6 -1
- schemathesis/specs/openapi/validation.py +1 -0
- schemathesis/stateful/__init__.py +35 -21
- schemathesis/stateful/config.py +97 -0
- schemathesis/stateful/context.py +135 -0
- schemathesis/stateful/events.py +274 -0
- schemathesis/stateful/runner.py +309 -0
- schemathesis/stateful/sink.py +68 -0
- schemathesis/stateful/state_machine.py +67 -38
- schemathesis/stateful/statistic.py +22 -0
- schemathesis/stateful/validation.py +100 -0
- schemathesis/targets.py +33 -1
- schemathesis/throttling.py +25 -5
- schemathesis/transports/__init__.py +354 -0
- schemathesis/transports/asgi.py +7 -0
- schemathesis/transports/auth.py +25 -2
- schemathesis/transports/content_types.py +3 -1
- schemathesis/transports/headers.py +2 -1
- schemathesis/transports/responses.py +9 -4
- schemathesis/types.py +9 -0
- schemathesis/utils.py +11 -16
- schemathesis-3.39.7.dist-info/METADATA +293 -0
- schemathesis-3.39.7.dist-info/RECORD +160 -0
- {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/WHEEL +1 -1
- schemathesis/specs/openapi/filters.py +0 -49
- schemathesis/specs/openapi/stateful/links.py +0 -92
- schemathesis-3.25.5.dist-info/METADATA +0 -356
- schemathesis-3.25.5.dist-info/RECORD +0 -134
- {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.25.5.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
|
|
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 ..
|
|
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
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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}={
|
|
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
|