schemathesis 3.39.16__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.
- schemathesis/__init__.py +41 -79
- schemathesis/auths.py +111 -122
- schemathesis/checks.py +169 -60
- schemathesis/cli/__init__.py +15 -2117
- schemathesis/cli/commands/__init__.py +85 -0
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +590 -0
- schemathesis/cli/commands/run/context.py +204 -0
- schemathesis/cli/commands/run/events.py +60 -0
- schemathesis/cli/commands/run/executor.py +157 -0
- schemathesis/cli/commands/run/filters.py +53 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +18 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +474 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +55 -0
- schemathesis/cli/commands/run/handlers/output.py +1628 -0
- schemathesis/cli/commands/run/loaders.py +114 -0
- schemathesis/cli/commands/run/validation.py +246 -0
- schemathesis/cli/constants.py +5 -58
- schemathesis/cli/core.py +19 -0
- schemathesis/cli/ext/fs.py +16 -0
- schemathesis/cli/ext/groups.py +84 -0
- schemathesis/cli/{options.py → ext/options.py} +36 -34
- schemathesis/config/__init__.py +189 -0
- schemathesis/config/_auth.py +51 -0
- schemathesis/config/_checks.py +268 -0
- schemathesis/config/_diff_base.py +99 -0
- schemathesis/config/_env.py +21 -0
- schemathesis/config/_error.py +156 -0
- schemathesis/config/_generation.py +149 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +327 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +187 -0
- schemathesis/config/_projects.py +527 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +120 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/_warnings.py +25 -0
- schemathesis/config/schema.json +885 -0
- schemathesis/core/__init__.py +67 -0
- schemathesis/core/compat.py +32 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +58 -0
- schemathesis/core/deserialization.py +65 -0
- schemathesis/core/errors.py +459 -0
- schemathesis/core/failures.py +315 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/hooks.py +20 -0
- schemathesis/core/loaders.py +104 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/{transports/content_types.py → core/media_types.py} +14 -12
- schemathesis/core/output/__init__.py +46 -0
- schemathesis/core/output/sanitization.py +54 -0
- schemathesis/{throttling.py → core/rate_limit.py} +16 -17
- schemathesis/core/registries.py +31 -0
- schemathesis/core/transforms.py +113 -0
- schemathesis/core/transport.py +223 -0
- schemathesis/core/validation.py +54 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +28 -0
- schemathesis/engine/context.py +118 -0
- schemathesis/engine/control.py +36 -0
- schemathesis/engine/core.py +169 -0
- schemathesis/engine/errors.py +464 -0
- schemathesis/engine/events.py +258 -0
- schemathesis/engine/phases/__init__.py +88 -0
- schemathesis/{runner → engine/phases}/probes.py +52 -68
- schemathesis/engine/phases/stateful/__init__.py +68 -0
- schemathesis/engine/phases/stateful/_executor.py +356 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +212 -0
- schemathesis/engine/phases/unit/_executor.py +416 -0
- schemathesis/engine/phases/unit/_pool.py +82 -0
- schemathesis/engine/recorder.py +247 -0
- schemathesis/errors.py +43 -0
- schemathesis/filters.py +17 -98
- schemathesis/generation/__init__.py +5 -33
- schemathesis/generation/case.py +317 -0
- schemathesis/generation/coverage.py +282 -175
- schemathesis/generation/hypothesis/__init__.py +36 -0
- schemathesis/generation/hypothesis/builder.py +800 -0
- schemathesis/generation/{_hypothesis.py → hypothesis/examples.py} +2 -11
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +14 -0
- schemathesis/generation/hypothesis/strategies.py +16 -0
- schemathesis/generation/meta.py +115 -0
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +20 -0
- schemathesis/generation/overrides.py +116 -0
- schemathesis/generation/stateful/__init__.py +37 -0
- schemathesis/generation/stateful/state_machine.py +278 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +284 -0
- schemathesis/hooks.py +80 -101
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +455 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +72 -0
- schemathesis/openapi/loaders.py +313 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +281 -0
- schemathesis/pytest/loaders.py +36 -0
- schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +128 -108
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +537 -273
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/_cache.py +1 -2
- schemathesis/specs/graphql/scalars.py +42 -6
- schemathesis/specs/graphql/schemas.py +141 -137
- schemathesis/specs/graphql/validation.py +11 -17
- schemathesis/specs/openapi/__init__.py +6 -1
- schemathesis/specs/openapi/_cache.py +1 -2
- schemathesis/specs/openapi/_hypothesis.py +142 -156
- schemathesis/specs/openapi/checks.py +368 -257
- schemathesis/specs/openapi/converter.py +4 -4
- schemathesis/specs/openapi/definitions.py +1 -1
- schemathesis/specs/openapi/examples.py +23 -21
- schemathesis/specs/openapi/expressions/__init__.py +31 -19
- schemathesis/specs/openapi/expressions/extractors.py +1 -4
- schemathesis/specs/openapi/expressions/lexer.py +1 -1
- schemathesis/specs/openapi/expressions/nodes.py +36 -41
- schemathesis/specs/openapi/expressions/parser.py +1 -1
- schemathesis/specs/openapi/formats.py +35 -7
- schemathesis/specs/openapi/media_types.py +53 -12
- schemathesis/specs/openapi/negative/__init__.py +7 -4
- schemathesis/specs/openapi/negative/mutations.py +6 -5
- schemathesis/specs/openapi/parameters.py +7 -10
- schemathesis/specs/openapi/patterns.py +94 -31
- schemathesis/specs/openapi/references.py +12 -53
- schemathesis/specs/openapi/schemas.py +233 -307
- schemathesis/specs/openapi/security.py +1 -1
- schemathesis/specs/openapi/serialization.py +12 -6
- schemathesis/specs/openapi/stateful/__init__.py +268 -133
- schemathesis/specs/openapi/stateful/control.py +87 -0
- schemathesis/specs/openapi/stateful/links.py +209 -0
- schemathesis/transport/__init__.py +142 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +124 -0
- schemathesis/transport/requests.py +244 -0
- schemathesis/{_xml.py → transport/serialization.py} +69 -11
- schemathesis/transport/wsgi.py +171 -0
- schemathesis-4.0.0.dist-info/METADATA +204 -0
- schemathesis-4.0.0.dist-info/RECORD +164 -0
- {schemathesis-3.39.16.dist-info → schemathesis-4.0.0.dist-info}/entry_points.txt +1 -1
- {schemathesis-3.39.16.dist-info → schemathesis-4.0.0.dist-info}/licenses/LICENSE +1 -1
- schemathesis/_compat.py +0 -74
- schemathesis/_dependency_versions.py +0 -19
- schemathesis/_hypothesis.py +0 -717
- schemathesis/_override.py +0 -50
- schemathesis/_patches.py +0 -21
- schemathesis/_rate_limiter.py +0 -7
- schemathesis/cli/callbacks.py +0 -466
- schemathesis/cli/cassettes.py +0 -561
- schemathesis/cli/context.py +0 -75
- schemathesis/cli/debug.py +0 -27
- schemathesis/cli/handlers.py +0 -19
- schemathesis/cli/junitxml.py +0 -124
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -920
- schemathesis/cli/output/short.py +0 -59
- schemathesis/cli/reporting.py +0 -79
- schemathesis/cli/sanitization.py +0 -26
- schemathesis/code_samples.py +0 -151
- schemathesis/constants.py +0 -54
- schemathesis/contrib/__init__.py +0 -11
- schemathesis/contrib/openapi/__init__.py +0 -11
- schemathesis/contrib/openapi/fill_missing_examples.py +0 -24
- schemathesis/contrib/openapi/formats/__init__.py +0 -9
- schemathesis/contrib/openapi/formats/uuid.py +0 -16
- schemathesis/contrib/unique_data.py +0 -41
- schemathesis/exceptions.py +0 -571
- schemathesis/experimental/__init__.py +0 -109
- schemathesis/extra/_aiohttp.py +0 -28
- schemathesis/extra/_flask.py +0 -13
- schemathesis/extra/_server.py +0 -18
- schemathesis/failures.py +0 -284
- schemathesis/fixups/__init__.py +0 -37
- schemathesis/fixups/fast_api.py +0 -41
- schemathesis/fixups/utf8_bom.py +0 -28
- schemathesis/generation/_methods.py +0 -44
- schemathesis/graphql.py +0 -3
- schemathesis/internal/__init__.py +0 -7
- schemathesis/internal/checks.py +0 -86
- schemathesis/internal/copy.py +0 -32
- schemathesis/internal/datetime.py +0 -5
- schemathesis/internal/deprecation.py +0 -37
- schemathesis/internal/diff.py +0 -15
- schemathesis/internal/extensions.py +0 -27
- schemathesis/internal/jsonschema.py +0 -36
- schemathesis/internal/output.py +0 -68
- schemathesis/internal/transformation.py +0 -26
- schemathesis/internal/validation.py +0 -34
- schemathesis/lazy.py +0 -474
- schemathesis/loaders.py +0 -122
- schemathesis/models.py +0 -1341
- schemathesis/parameters.py +0 -90
- schemathesis/runner/__init__.py +0 -605
- schemathesis/runner/events.py +0 -389
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/context.py +0 -88
- schemathesis/runner/impl/core.py +0 -1280
- schemathesis/runner/impl/solo.py +0 -80
- schemathesis/runner/impl/threadpool.py +0 -391
- schemathesis/runner/serialization.py +0 -544
- schemathesis/sanitization.py +0 -252
- schemathesis/serializers.py +0 -328
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -11
- schemathesis/service/ci.py +0 -202
- schemathesis/service/client.py +0 -133
- schemathesis/service/constants.py +0 -38
- schemathesis/service/events.py +0 -61
- schemathesis/service/extensions.py +0 -224
- schemathesis/service/hosts.py +0 -111
- schemathesis/service/metadata.py +0 -71
- schemathesis/service/models.py +0 -258
- schemathesis/service/report.py +0 -255
- schemathesis/service/serialization.py +0 -173
- schemathesis/service/usage.py +0 -66
- schemathesis/specs/graphql/loaders.py +0 -364
- schemathesis/specs/openapi/expressions/context.py +0 -16
- schemathesis/specs/openapi/links.py +0 -389
- schemathesis/specs/openapi/loaders.py +0 -707
- schemathesis/specs/openapi/stateful/statistic.py +0 -198
- schemathesis/specs/openapi/stateful/types.py +0 -14
- schemathesis/specs/openapi/validation.py +0 -26
- schemathesis/stateful/__init__.py +0 -147
- schemathesis/stateful/config.py +0 -97
- schemathesis/stateful/context.py +0 -135
- schemathesis/stateful/events.py +0 -274
- schemathesis/stateful/runner.py +0 -309
- schemathesis/stateful/sink.py +0 -68
- schemathesis/stateful/state_machine.py +0 -328
- schemathesis/stateful/statistic.py +0 -22
- schemathesis/stateful/validation.py +0 -100
- schemathesis/targets.py +0 -77
- schemathesis/transports/__init__.py +0 -369
- schemathesis/transports/asgi.py +0 -7
- schemathesis/transports/auth.py +0 -38
- schemathesis/transports/headers.py +0 -36
- schemathesis/transports/responses.py +0 -57
- schemathesis/types.py +0 -44
- schemathesis/utils.py +0 -164
- schemathesis-3.39.16.dist-info/METADATA +0 -293
- schemathesis-3.39.16.dist-info/RECORD +0 -160
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
- /schemathesis/{_lazy_import.py → core/lazy_import.py} +0 -0
- /schemathesis/{internal → core}/result.py +0 -0
- {schemathesis-3.39.16.dist-info → schemathesis-4.0.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,356 @@
|
|
1
|
+
from __future__ import annotations # noqa: I001
|
2
|
+
|
3
|
+
import queue
|
4
|
+
import time
|
5
|
+
import unittest
|
6
|
+
from dataclasses import dataclass
|
7
|
+
from typing import Any
|
8
|
+
from warnings import catch_warnings
|
9
|
+
|
10
|
+
import hypothesis
|
11
|
+
import requests
|
12
|
+
from hypothesis.control import current_build_context
|
13
|
+
from hypothesis.errors import Flaky, Unsatisfiable
|
14
|
+
from hypothesis.stateful import Rule
|
15
|
+
from requests.exceptions import ChunkedEncodingError
|
16
|
+
from requests.structures import CaseInsensitiveDict
|
17
|
+
|
18
|
+
from schemathesis.checks import CheckContext, CheckFunction, run_checks
|
19
|
+
from schemathesis.core.failures import Failure, FailureGroup
|
20
|
+
from schemathesis.core.transport import Response
|
21
|
+
from schemathesis.engine import Status, events
|
22
|
+
from schemathesis.engine.context import EngineContext
|
23
|
+
from schemathesis.engine.control import ExecutionControl
|
24
|
+
from schemathesis.engine.errors import (
|
25
|
+
TestingState,
|
26
|
+
UnrecoverableNetworkError,
|
27
|
+
clear_hypothesis_notes,
|
28
|
+
is_unrecoverable_network_error,
|
29
|
+
)
|
30
|
+
from schemathesis.engine.phases import PhaseName
|
31
|
+
from schemathesis.engine.phases.stateful.context import StatefulContext
|
32
|
+
from schemathesis.engine.recorder import ScenarioRecorder
|
33
|
+
from schemathesis.generation import overrides
|
34
|
+
from schemathesis.generation.case import Case
|
35
|
+
from schemathesis.generation.hypothesis.reporting import ignore_hypothesis_output
|
36
|
+
from schemathesis.generation.stateful import STATEFUL_TESTS_LABEL
|
37
|
+
from schemathesis.generation.stateful.state_machine import (
|
38
|
+
DEFAULT_STATE_MACHINE_SETTINGS,
|
39
|
+
APIStateMachine,
|
40
|
+
StepInput,
|
41
|
+
StepOutput,
|
42
|
+
)
|
43
|
+
from schemathesis.generation.metrics import MetricCollector
|
44
|
+
|
45
|
+
|
46
|
+
def _get_hypothesis_settings_kwargs_override(settings: hypothesis.settings) -> dict[str, Any]:
|
47
|
+
"""Get the settings that should be overridden to match the defaults for API state machines."""
|
48
|
+
kwargs = {}
|
49
|
+
hypothesis_default = hypothesis.settings()
|
50
|
+
if settings.phases == hypothesis_default.phases:
|
51
|
+
kwargs["phases"] = DEFAULT_STATE_MACHINE_SETTINGS.phases
|
52
|
+
if settings.stateful_step_count == hypothesis_default.stateful_step_count:
|
53
|
+
kwargs["stateful_step_count"] = DEFAULT_STATE_MACHINE_SETTINGS.stateful_step_count
|
54
|
+
if settings.deadline == hypothesis_default.deadline:
|
55
|
+
kwargs["deadline"] = DEFAULT_STATE_MACHINE_SETTINGS.deadline
|
56
|
+
if settings.suppress_health_check == hypothesis_default.suppress_health_check:
|
57
|
+
kwargs["suppress_health_check"] = DEFAULT_STATE_MACHINE_SETTINGS.suppress_health_check
|
58
|
+
return kwargs
|
59
|
+
|
60
|
+
|
61
|
+
@dataclass
|
62
|
+
class CachedCheckContextData:
|
63
|
+
override: Any
|
64
|
+
auth: Any
|
65
|
+
headers: Any
|
66
|
+
config: Any
|
67
|
+
transport_kwargs: Any
|
68
|
+
|
69
|
+
__slots__ = ("override", "auth", "headers", "config", "transport_kwargs")
|
70
|
+
|
71
|
+
|
72
|
+
def execute_state_machine_loop(
|
73
|
+
*,
|
74
|
+
state_machine: type[APIStateMachine],
|
75
|
+
event_queue: queue.Queue,
|
76
|
+
engine: EngineContext,
|
77
|
+
) -> None:
|
78
|
+
"""Execute the state machine testing loop."""
|
79
|
+
configured_hypothesis_settings = engine.config.get_hypothesis_settings(phase="stateful")
|
80
|
+
kwargs = _get_hypothesis_settings_kwargs_override(configured_hypothesis_settings)
|
81
|
+
hypothesis_settings = hypothesis.settings(configured_hypothesis_settings, **kwargs)
|
82
|
+
generation = engine.config.generation_for(phase="stateful")
|
83
|
+
|
84
|
+
ctx = StatefulContext(metric_collector=MetricCollector(metrics=generation.maximize))
|
85
|
+
state = TestingState()
|
86
|
+
|
87
|
+
# Caches for validate_response to avoid repeated config lookups per operation
|
88
|
+
_check_context_cache: dict[str, CachedCheckContextData] = {}
|
89
|
+
|
90
|
+
class _InstrumentedStateMachine(state_machine): # type: ignore[valid-type,misc]
|
91
|
+
"""State machine with additional hooks for emitting events."""
|
92
|
+
|
93
|
+
def setup(self) -> None:
|
94
|
+
scenario_started = events.ScenarioStarted(label=None, phase=PhaseName.STATEFUL_TESTING, suite_id=suite_id)
|
95
|
+
self._start_time = time.monotonic()
|
96
|
+
self._scenario_id = scenario_started.id
|
97
|
+
event_queue.put(scenario_started)
|
98
|
+
|
99
|
+
def get_call_kwargs(self, case: Case) -> dict[str, Any]:
|
100
|
+
return engine.get_transport_kwargs(operation=case.operation)
|
101
|
+
|
102
|
+
def _repr_step(self, rule: Rule, data: dict, result: StepOutput) -> str:
|
103
|
+
return ""
|
104
|
+
|
105
|
+
def before_call(self, case: Case) -> None:
|
106
|
+
override = overrides.for_operation(engine.config, operation=case.operation)
|
107
|
+
for location in ("query", "headers", "cookies", "path_parameters"):
|
108
|
+
entry = getattr(override, location)
|
109
|
+
if entry:
|
110
|
+
container = getattr(case, location) or {}
|
111
|
+
container.update(entry)
|
112
|
+
setattr(case, location, container)
|
113
|
+
return super().before_call(case)
|
114
|
+
|
115
|
+
def step(self, input: StepInput) -> StepOutput | None:
|
116
|
+
# Checking the stop event once inside `step` is sufficient as it is called frequently
|
117
|
+
# The idea is to stop the execution as soon as possible
|
118
|
+
if engine.has_to_stop:
|
119
|
+
raise KeyboardInterrupt
|
120
|
+
try:
|
121
|
+
if generation.unique_inputs:
|
122
|
+
cached = ctx.get_step_outcome(input.case)
|
123
|
+
if isinstance(cached, BaseException):
|
124
|
+
raise cached
|
125
|
+
elif cached is None:
|
126
|
+
return None
|
127
|
+
result = super().step(input)
|
128
|
+
ctx.step_succeeded()
|
129
|
+
except FailureGroup as exc:
|
130
|
+
if generation.unique_inputs:
|
131
|
+
for failure in exc.exceptions:
|
132
|
+
ctx.store_step_outcome(input.case, failure)
|
133
|
+
ctx.step_failed()
|
134
|
+
raise
|
135
|
+
except Exception as exc:
|
136
|
+
if isinstance(exc, (requests.ConnectionError, ChunkedEncodingError)) and is_unrecoverable_network_error(
|
137
|
+
exc
|
138
|
+
):
|
139
|
+
transport_kwargs = engine.get_transport_kwargs(operation=input.case.operation)
|
140
|
+
if exc.request is not None:
|
141
|
+
headers = {key: value[0] for key, value in exc.request.headers.items()}
|
142
|
+
else:
|
143
|
+
headers = {**dict(input.case.headers or {}), **transport_kwargs.get("headers", {})}
|
144
|
+
verify = transport_kwargs.get("verify", True)
|
145
|
+
state.unrecoverable_network_error = UnrecoverableNetworkError(
|
146
|
+
error=exc,
|
147
|
+
code_sample=input.case.as_curl_command(headers=headers, verify=verify),
|
148
|
+
)
|
149
|
+
|
150
|
+
if generation.unique_inputs:
|
151
|
+
ctx.store_step_outcome(input.case, exc)
|
152
|
+
ctx.step_errored()
|
153
|
+
raise
|
154
|
+
except KeyboardInterrupt:
|
155
|
+
ctx.step_interrupted()
|
156
|
+
raise
|
157
|
+
except BaseException as exc:
|
158
|
+
if generation.unique_inputs:
|
159
|
+
ctx.store_step_outcome(input.case, exc)
|
160
|
+
raise exc
|
161
|
+
else:
|
162
|
+
if generation.unique_inputs:
|
163
|
+
ctx.store_step_outcome(input.case, None)
|
164
|
+
return result
|
165
|
+
|
166
|
+
def validate_response(
|
167
|
+
self, response: Response, case: Case, additional_checks: tuple[CheckFunction, ...] = (), **kwargs: Any
|
168
|
+
) -> None:
|
169
|
+
self.recorder.record_response(case_id=case.id, response=response)
|
170
|
+
ctx.collect_metric(case, response)
|
171
|
+
ctx.current_response = response
|
172
|
+
|
173
|
+
label = case.operation.label
|
174
|
+
cached = _check_context_cache.get(label)
|
175
|
+
if cached is None:
|
176
|
+
headers = engine.config.headers_for(operation=case.operation)
|
177
|
+
cached = CachedCheckContextData(
|
178
|
+
override=overrides.for_operation(engine.config, operation=case.operation),
|
179
|
+
auth=engine.config.auth_for(operation=case.operation),
|
180
|
+
headers=CaseInsensitiveDict(headers) if headers else None,
|
181
|
+
config=engine.config.checks_config_for(operation=case.operation, phase="stateful"),
|
182
|
+
transport_kwargs=engine.get_transport_kwargs(operation=case.operation),
|
183
|
+
)
|
184
|
+
_check_context_cache[label] = cached
|
185
|
+
|
186
|
+
check_ctx = CheckContext(
|
187
|
+
override=cached.override,
|
188
|
+
auth=cached.auth,
|
189
|
+
headers=cached.headers,
|
190
|
+
config=cached.config,
|
191
|
+
transport_kwargs=cached.transport_kwargs,
|
192
|
+
recorder=self.recorder,
|
193
|
+
)
|
194
|
+
validate_response(
|
195
|
+
response=response,
|
196
|
+
case=case,
|
197
|
+
stateful_ctx=ctx,
|
198
|
+
check_ctx=check_ctx,
|
199
|
+
checks=check_ctx._checks,
|
200
|
+
control=engine.control,
|
201
|
+
recorder=self.recorder,
|
202
|
+
additional_checks=additional_checks,
|
203
|
+
)
|
204
|
+
|
205
|
+
def teardown(self) -> None:
|
206
|
+
build_ctx = current_build_context()
|
207
|
+
event_queue.put(
|
208
|
+
events.ScenarioFinished(
|
209
|
+
id=self._scenario_id,
|
210
|
+
suite_id=suite_id,
|
211
|
+
phase=PhaseName.STATEFUL_TESTING,
|
212
|
+
label=None,
|
213
|
+
status=ctx.current_scenario_status or Status.SKIP,
|
214
|
+
recorder=self.recorder,
|
215
|
+
elapsed_time=time.monotonic() - self._start_time,
|
216
|
+
skip_reason=None,
|
217
|
+
is_final=build_ctx.is_final,
|
218
|
+
)
|
219
|
+
)
|
220
|
+
ctx.maximize_metrics()
|
221
|
+
ctx.reset_scenario()
|
222
|
+
super().teardown()
|
223
|
+
|
224
|
+
seed = engine.config.seed
|
225
|
+
|
226
|
+
while True:
|
227
|
+
# This loop is running until no new failures are found in a single iteration
|
228
|
+
suite_started = events.SuiteStarted(phase=PhaseName.STATEFUL_TESTING)
|
229
|
+
suite_id = suite_started.id
|
230
|
+
event_queue.put(suite_started)
|
231
|
+
if engine.is_interrupted:
|
232
|
+
event_queue.put(events.Interrupted(phase=PhaseName.STATEFUL_TESTING))
|
233
|
+
event_queue.put(
|
234
|
+
events.SuiteFinished(
|
235
|
+
id=suite_started.id,
|
236
|
+
phase=PhaseName.STATEFUL_TESTING,
|
237
|
+
status=Status.INTERRUPTED,
|
238
|
+
)
|
239
|
+
)
|
240
|
+
break
|
241
|
+
suite_status = Status.SUCCESS
|
242
|
+
InstrumentedStateMachine = hypothesis.seed(seed)(_InstrumentedStateMachine)
|
243
|
+
# Predictably change the seed to avoid re-running the same sequences if tests fail
|
244
|
+
# yet have reproducible results
|
245
|
+
seed += 1
|
246
|
+
try:
|
247
|
+
with catch_warnings(), ignore_hypothesis_output(): # type: ignore
|
248
|
+
InstrumentedStateMachine.run(settings=hypothesis_settings)
|
249
|
+
except KeyboardInterrupt:
|
250
|
+
# Raised in the state machine when the stop event is set or it is raised by the user's code
|
251
|
+
# that is placed in the base class of the state machine.
|
252
|
+
# Therefore, set the stop event to cover the latter case
|
253
|
+
engine.stop()
|
254
|
+
suite_status = Status.INTERRUPTED
|
255
|
+
event_queue.put(events.Interrupted(phase=PhaseName.STATEFUL_TESTING))
|
256
|
+
break
|
257
|
+
except unittest.case.SkipTest:
|
258
|
+
# If `explicit` phase is used and there are no examples
|
259
|
+
suite_status = Status.SKIP
|
260
|
+
break
|
261
|
+
except FailureGroup as exc:
|
262
|
+
# When a check fails, the state machine is stopped
|
263
|
+
# The failure is already sent to the queue by the state machine
|
264
|
+
# Here we need to either exit or re-run the state machine with this failure marked as known
|
265
|
+
suite_status = Status.FAILURE
|
266
|
+
if engine.has_reached_the_failure_limit:
|
267
|
+
break # type: ignore[unreachable]
|
268
|
+
for failure in exc.exceptions:
|
269
|
+
ctx.mark_as_seen_in_run(failure)
|
270
|
+
continue
|
271
|
+
except Flaky:
|
272
|
+
# Ignore flakiness
|
273
|
+
if engine.has_reached_the_failure_limit:
|
274
|
+
break # type: ignore[unreachable]
|
275
|
+
# Mark all failures in this suite as seen to prevent them being re-discovered
|
276
|
+
ctx.mark_current_suite_as_seen_in_run()
|
277
|
+
continue
|
278
|
+
except Exception as exc:
|
279
|
+
if isinstance(exc, Unsatisfiable) and ctx.completed_scenarios > 0:
|
280
|
+
# Sometimes Hypothesis randomly gives up on generating some complex cases. However, if we know that
|
281
|
+
# values are possible to generate based on the previous observations, we retry the generation
|
282
|
+
if ctx.completed_scenarios >= hypothesis_settings.max_examples:
|
283
|
+
# Avoid infinite restarts
|
284
|
+
break
|
285
|
+
continue
|
286
|
+
clear_hypothesis_notes(exc)
|
287
|
+
# Any other exception is an inner error and the test run should be stopped
|
288
|
+
suite_status = Status.ERROR
|
289
|
+
code_sample: str | None = None
|
290
|
+
if state.unrecoverable_network_error is not None:
|
291
|
+
exc = state.unrecoverable_network_error.error
|
292
|
+
code_sample = state.unrecoverable_network_error.code_sample
|
293
|
+
event_queue.put(
|
294
|
+
events.NonFatalError(
|
295
|
+
error=exc,
|
296
|
+
phase=PhaseName.STATEFUL_TESTING,
|
297
|
+
label=STATEFUL_TESTS_LABEL,
|
298
|
+
related_to_operation=False,
|
299
|
+
code_sample=code_sample,
|
300
|
+
)
|
301
|
+
)
|
302
|
+
break
|
303
|
+
finally:
|
304
|
+
event_queue.put(
|
305
|
+
events.SuiteFinished(
|
306
|
+
id=suite_started.id,
|
307
|
+
phase=PhaseName.STATEFUL_TESTING,
|
308
|
+
status=suite_status,
|
309
|
+
)
|
310
|
+
)
|
311
|
+
ctx.reset()
|
312
|
+
# Exit on the first successful state machine execution
|
313
|
+
break
|
314
|
+
|
315
|
+
|
316
|
+
def validate_response(
|
317
|
+
*,
|
318
|
+
response: Response,
|
319
|
+
case: Case,
|
320
|
+
stateful_ctx: StatefulContext,
|
321
|
+
check_ctx: CheckContext,
|
322
|
+
control: ExecutionControl,
|
323
|
+
checks: list[CheckFunction],
|
324
|
+
recorder: ScenarioRecorder,
|
325
|
+
additional_checks: tuple[CheckFunction, ...] = (),
|
326
|
+
) -> None:
|
327
|
+
"""Validate the response against the provided checks."""
|
328
|
+
|
329
|
+
def on_failure(name: str, collected: set[Failure], failure: Failure) -> None:
|
330
|
+
if stateful_ctx.is_seen_in_suite(failure) or stateful_ctx.is_seen_in_run(failure):
|
331
|
+
return
|
332
|
+
failure_data = recorder.find_failure_data(parent_id=case.id, failure=failure)
|
333
|
+
recorder.record_check_failure(
|
334
|
+
name=name,
|
335
|
+
case_id=failure_data.case.id,
|
336
|
+
code_sample=failure_data.case.as_curl_command(headers=failure_data.headers, verify=failure_data.verify),
|
337
|
+
failure=failure,
|
338
|
+
)
|
339
|
+
control.count_failure()
|
340
|
+
stateful_ctx.mark_as_seen_in_suite(failure)
|
341
|
+
collected.add(failure)
|
342
|
+
|
343
|
+
def on_success(name: str, case: Case) -> None:
|
344
|
+
recorder.record_check_success(name=name, case_id=case.id)
|
345
|
+
|
346
|
+
failures = run_checks(
|
347
|
+
case=case,
|
348
|
+
response=response,
|
349
|
+
ctx=check_ctx,
|
350
|
+
checks=tuple(checks) + tuple(additional_checks),
|
351
|
+
on_failure=on_failure,
|
352
|
+
on_success=on_success,
|
353
|
+
)
|
354
|
+
|
355
|
+
if failures:
|
356
|
+
raise FailureGroup(list(failures)) from None
|
@@ -0,0 +1,85 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from dataclasses import dataclass, field
|
4
|
+
|
5
|
+
from schemathesis.core import NOT_SET, NotSet
|
6
|
+
from schemathesis.core.failures import Failure
|
7
|
+
from schemathesis.core.transport import Response
|
8
|
+
from schemathesis.engine import Status
|
9
|
+
from schemathesis.generation.case import Case
|
10
|
+
from schemathesis.generation.metrics import MetricCollector
|
11
|
+
|
12
|
+
|
13
|
+
@dataclass
|
14
|
+
class StatefulContext:
|
15
|
+
"""Mutable context for state machine execution."""
|
16
|
+
|
17
|
+
# All seen failure keys, both grouped and individual ones
|
18
|
+
seen_in_run: set[Failure] = field(default_factory=set)
|
19
|
+
# Failures keys seen in the current suite
|
20
|
+
seen_in_suite: set[Failure] = field(default_factory=set)
|
21
|
+
# Status of the current step
|
22
|
+
current_step_status: Status | None = None
|
23
|
+
# The currently processed response
|
24
|
+
current_response: Response | None = None
|
25
|
+
# Total number of failures
|
26
|
+
failures_count: int = 0
|
27
|
+
# The total number of completed test scenario
|
28
|
+
completed_scenarios: int = 0
|
29
|
+
# Metrics collector for targeted testing
|
30
|
+
metric_collector: MetricCollector = field(default_factory=MetricCollector)
|
31
|
+
step_outcomes: dict[int, BaseException | None] = field(default_factory=dict)
|
32
|
+
|
33
|
+
@property
|
34
|
+
def current_scenario_status(self) -> Status | None:
|
35
|
+
return self.current_step_status
|
36
|
+
|
37
|
+
def reset_scenario(self) -> None:
|
38
|
+
self.completed_scenarios += 1
|
39
|
+
self.current_step_status = None
|
40
|
+
self.current_response = None
|
41
|
+
self.step_outcomes.clear()
|
42
|
+
|
43
|
+
def step_succeeded(self) -> None:
|
44
|
+
self.current_step_status = Status.SUCCESS
|
45
|
+
|
46
|
+
def step_failed(self) -> None:
|
47
|
+
self.current_step_status = Status.FAILURE
|
48
|
+
|
49
|
+
def step_errored(self) -> None:
|
50
|
+
self.current_step_status = Status.ERROR
|
51
|
+
|
52
|
+
def step_interrupted(self) -> None:
|
53
|
+
self.current_step_status = Status.INTERRUPTED
|
54
|
+
|
55
|
+
def mark_as_seen_in_run(self, exc: Failure) -> None:
|
56
|
+
self.seen_in_run.add(exc)
|
57
|
+
|
58
|
+
def mark_as_seen_in_suite(self, exc: Failure) -> None:
|
59
|
+
self.seen_in_suite.add(exc)
|
60
|
+
|
61
|
+
def mark_current_suite_as_seen_in_run(self) -> None:
|
62
|
+
self.seen_in_run.update(self.seen_in_suite)
|
63
|
+
|
64
|
+
def is_seen_in_run(self, exc: Failure) -> bool:
|
65
|
+
return exc in self.seen_in_run
|
66
|
+
|
67
|
+
def is_seen_in_suite(self, exc: Failure) -> bool:
|
68
|
+
return exc in self.seen_in_suite
|
69
|
+
|
70
|
+
def collect_metric(self, case: Case, response: Response) -> None:
|
71
|
+
self.metric_collector.store(case, response)
|
72
|
+
|
73
|
+
def maximize_metrics(self) -> None:
|
74
|
+
self.metric_collector.maximize()
|
75
|
+
|
76
|
+
def reset(self) -> None:
|
77
|
+
self.seen_in_suite.clear()
|
78
|
+
self.reset_scenario()
|
79
|
+
self.metric_collector.reset()
|
80
|
+
|
81
|
+
def store_step_outcome(self, case: Case, outcome: BaseException | None) -> None:
|
82
|
+
self.step_outcomes[hash(case)] = outcome
|
83
|
+
|
84
|
+
def get_step_outcome(self, case: Case) -> BaseException | None | NotSet:
|
85
|
+
return self.step_outcomes.get(hash(case), NOT_SET)
|
@@ -0,0 +1,212 @@
|
|
1
|
+
"""Unit testing by Schemathesis Engine.
|
2
|
+
|
3
|
+
This module provides high-level flow for single-, and multi-threaded modes.
|
4
|
+
"""
|
5
|
+
|
6
|
+
from __future__ import annotations
|
7
|
+
|
8
|
+
import queue
|
9
|
+
import uuid
|
10
|
+
import warnings
|
11
|
+
from queue import Queue
|
12
|
+
from typing import TYPE_CHECKING, Any
|
13
|
+
|
14
|
+
from schemathesis.core.errors import InvalidSchema
|
15
|
+
from schemathesis.core.result import Ok
|
16
|
+
from schemathesis.engine import Status, events
|
17
|
+
from schemathesis.engine.phases import PhaseName, PhaseSkipReason
|
18
|
+
from schemathesis.engine.recorder import ScenarioRecorder
|
19
|
+
from schemathesis.generation import overrides
|
20
|
+
from schemathesis.generation.hypothesis.builder import HypothesisTestConfig, HypothesisTestMode
|
21
|
+
from schemathesis.generation.hypothesis.reporting import ignore_hypothesis_output
|
22
|
+
|
23
|
+
from ._pool import TaskProducer, WorkerPool
|
24
|
+
|
25
|
+
if TYPE_CHECKING:
|
26
|
+
from schemathesis.engine.context import EngineContext
|
27
|
+
from schemathesis.engine.phases import Phase
|
28
|
+
from schemathesis.schemas import APIOperation
|
29
|
+
|
30
|
+
WORKER_TIMEOUT = 0.1
|
31
|
+
|
32
|
+
|
33
|
+
def execute(engine: EngineContext, phase: Phase) -> events.EventGenerator:
|
34
|
+
"""Run a set of unit tests.
|
35
|
+
|
36
|
+
Implemented as a producer-consumer pattern via a task queue.
|
37
|
+
The main thread provides an iterator over API operations and worker threads create test functions and run them.
|
38
|
+
"""
|
39
|
+
if phase.name == PhaseName.EXAMPLES:
|
40
|
+
mode = HypothesisTestMode.EXAMPLES
|
41
|
+
elif phase.name == PhaseName.COVERAGE:
|
42
|
+
mode = HypothesisTestMode.COVERAGE
|
43
|
+
else:
|
44
|
+
mode = HypothesisTestMode.FUZZING
|
45
|
+
producer = TaskProducer(engine)
|
46
|
+
|
47
|
+
suite_started = events.SuiteStarted(phase=phase.name)
|
48
|
+
|
49
|
+
yield suite_started
|
50
|
+
|
51
|
+
status = None
|
52
|
+
is_executed = False
|
53
|
+
|
54
|
+
try:
|
55
|
+
with WorkerPool(
|
56
|
+
workers_num=engine.config.workers,
|
57
|
+
producer=producer,
|
58
|
+
worker_factory=worker_task,
|
59
|
+
ctx=engine,
|
60
|
+
mode=mode,
|
61
|
+
phase=phase.name,
|
62
|
+
suite_id=suite_started.id,
|
63
|
+
) as pool:
|
64
|
+
try:
|
65
|
+
while True:
|
66
|
+
try:
|
67
|
+
event = pool.events_queue.get(timeout=WORKER_TIMEOUT)
|
68
|
+
is_executed = True
|
69
|
+
if engine.is_interrupted:
|
70
|
+
raise KeyboardInterrupt
|
71
|
+
yield event
|
72
|
+
if isinstance(event, events.NonFatalError):
|
73
|
+
status = Status.ERROR
|
74
|
+
if isinstance(event, events.ScenarioFinished):
|
75
|
+
if event.status != Status.SKIP and (status is None or status < event.status):
|
76
|
+
status = event.status
|
77
|
+
if event.status in (Status.ERROR, Status.FAILURE):
|
78
|
+
engine.control.count_failure()
|
79
|
+
if isinstance(event, events.Interrupted) or engine.is_interrupted:
|
80
|
+
status = Status.INTERRUPTED
|
81
|
+
engine.stop()
|
82
|
+
if engine.has_to_stop:
|
83
|
+
break # type: ignore[unreachable]
|
84
|
+
except queue.Empty:
|
85
|
+
if all(not worker.is_alive() for worker in pool.workers):
|
86
|
+
break
|
87
|
+
continue
|
88
|
+
except KeyboardInterrupt:
|
89
|
+
# Soft stop, waiting for workers to terminate
|
90
|
+
engine.stop()
|
91
|
+
status = Status.INTERRUPTED
|
92
|
+
yield events.Interrupted(phase=phase.name)
|
93
|
+
except KeyboardInterrupt:
|
94
|
+
# Hard stop, don't wait for worker threads
|
95
|
+
pass
|
96
|
+
|
97
|
+
if not is_executed:
|
98
|
+
phase.skip_reason = PhaseSkipReason.NOTHING_TO_TEST
|
99
|
+
status = Status.SKIP
|
100
|
+
elif status is None:
|
101
|
+
status = Status.SKIP
|
102
|
+
# NOTE: Right now there is just one suite, hence two events go one after another
|
103
|
+
yield events.SuiteFinished(id=suite_started.id, phase=phase.name, status=status)
|
104
|
+
yield events.PhaseFinished(phase=phase, status=status, payload=None)
|
105
|
+
|
106
|
+
|
107
|
+
def worker_task(
|
108
|
+
*,
|
109
|
+
events_queue: Queue,
|
110
|
+
producer: TaskProducer,
|
111
|
+
ctx: EngineContext,
|
112
|
+
mode: HypothesisTestMode,
|
113
|
+
phase: PhaseName,
|
114
|
+
suite_id: uuid.UUID,
|
115
|
+
) -> None:
|
116
|
+
from hypothesis.errors import HypothesisWarning, InvalidArgument
|
117
|
+
|
118
|
+
from schemathesis.generation.hypothesis.builder import create_test
|
119
|
+
|
120
|
+
from ._executor import run_test, test_func
|
121
|
+
|
122
|
+
def on_error(error: Exception, *, method: str | None = None, path: str | None = None) -> None:
|
123
|
+
if method and path:
|
124
|
+
label = f"{method.upper()} {path}"
|
125
|
+
scenario_started = events.ScenarioStarted(label=label, phase=phase, suite_id=suite_id)
|
126
|
+
events_queue.put(scenario_started)
|
127
|
+
|
128
|
+
events_queue.put(events.NonFatalError(error=error, phase=phase, label=label, related_to_operation=True))
|
129
|
+
|
130
|
+
events_queue.put(
|
131
|
+
events.ScenarioFinished(
|
132
|
+
id=scenario_started.id,
|
133
|
+
suite_id=suite_id,
|
134
|
+
phase=phase,
|
135
|
+
label=label,
|
136
|
+
status=Status.ERROR,
|
137
|
+
recorder=ScenarioRecorder(label="Error"),
|
138
|
+
elapsed_time=0.0,
|
139
|
+
skip_reason=None,
|
140
|
+
is_final=True,
|
141
|
+
)
|
142
|
+
)
|
143
|
+
else:
|
144
|
+
events_queue.put(
|
145
|
+
events.NonFatalError(
|
146
|
+
error=error,
|
147
|
+
phase=phase,
|
148
|
+
label=path or "-",
|
149
|
+
related_to_operation=False,
|
150
|
+
)
|
151
|
+
)
|
152
|
+
|
153
|
+
warnings.filterwarnings("ignore", message="The recursion limit will not be reset", category=HypothesisWarning)
|
154
|
+
with ignore_hypothesis_output():
|
155
|
+
try:
|
156
|
+
while not ctx.has_to_stop:
|
157
|
+
result = producer.next_operation()
|
158
|
+
if result is None:
|
159
|
+
break
|
160
|
+
|
161
|
+
if isinstance(result, Ok):
|
162
|
+
operation = result.ok()
|
163
|
+
phases = ctx.config.phases_for(operation=operation)
|
164
|
+
# Skip tests if this phase is disabled
|
165
|
+
if (
|
166
|
+
(phase == PhaseName.EXAMPLES and not phases.examples.enabled)
|
167
|
+
or (phase == PhaseName.FUZZING and not phases.fuzzing.enabled)
|
168
|
+
or (phase == PhaseName.COVERAGE and not phases.coverage.enabled)
|
169
|
+
):
|
170
|
+
continue
|
171
|
+
as_strategy_kwargs = get_strategy_kwargs(ctx, operation=operation)
|
172
|
+
try:
|
173
|
+
test_function = create_test(
|
174
|
+
operation=operation,
|
175
|
+
test_func=test_func,
|
176
|
+
config=HypothesisTestConfig(
|
177
|
+
modes=[mode],
|
178
|
+
settings=ctx.config.get_hypothesis_settings(operation=operation, phase=phase.name),
|
179
|
+
seed=ctx.config.seed,
|
180
|
+
project=ctx.config,
|
181
|
+
as_strategy_kwargs=as_strategy_kwargs,
|
182
|
+
),
|
183
|
+
)
|
184
|
+
except (InvalidSchema, InvalidArgument) as exc:
|
185
|
+
on_error(exc, method=operation.method, path=operation.path)
|
186
|
+
continue
|
187
|
+
|
188
|
+
# The test is blocking, meaning that even if CTRL-C comes to the main thread, this tasks will continue
|
189
|
+
# executing. However, as we set a stop event, it will be checked before the next network request.
|
190
|
+
# However, this is still suboptimal, as there could be slow requests and they will block for longer
|
191
|
+
for event in run_test(
|
192
|
+
operation=operation, test_function=test_function, ctx=ctx, phase=phase, suite_id=suite_id
|
193
|
+
):
|
194
|
+
events_queue.put(event)
|
195
|
+
else:
|
196
|
+
error = result.err()
|
197
|
+
on_error(error, method=error.method, path=error.path)
|
198
|
+
except KeyboardInterrupt:
|
199
|
+
events_queue.put(events.Interrupted(phase=phase))
|
200
|
+
|
201
|
+
|
202
|
+
def get_strategy_kwargs(ctx: EngineContext, *, operation: APIOperation) -> dict[str, Any]:
|
203
|
+
kwargs = {}
|
204
|
+
override = overrides.for_operation(ctx.config, operation=operation)
|
205
|
+
for location in ("query", "headers", "cookies", "path_parameters"):
|
206
|
+
entry = getattr(override, location)
|
207
|
+
if entry:
|
208
|
+
kwargs[location] = entry
|
209
|
+
headers = ctx.config.headers_for(operation=operation)
|
210
|
+
if headers:
|
211
|
+
kwargs["headers"] = {key: value for key, value in headers.items() if key.lower() != "user-agent"}
|
212
|
+
return kwargs
|