schemathesis 3.39.7__py3-none-any.whl → 4.0.0a2__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 +27 -65
- schemathesis/auths.py +26 -68
- schemathesis/checks.py +130 -60
- schemathesis/cli/__init__.py +5 -2105
- schemathesis/cli/commands/__init__.py +37 -0
- schemathesis/cli/commands/run/__init__.py +662 -0
- schemathesis/cli/commands/run/checks.py +80 -0
- schemathesis/cli/commands/run/context.py +117 -0
- schemathesis/cli/commands/run/events.py +30 -0
- schemathesis/cli/commands/run/executor.py +141 -0
- schemathesis/cli/commands/run/filters.py +202 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +18 -0
- schemathesis/cli/{cassettes.py → commands/run/handlers/cassettes.py} +178 -247
- schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
- schemathesis/cli/commands/run/handlers/output.py +1368 -0
- schemathesis/cli/commands/run/hypothesis.py +105 -0
- schemathesis/cli/commands/run/loaders.py +129 -0
- schemathesis/cli/{callbacks.py → commands/run/validation.py} +59 -175
- schemathesis/cli/constants.py +5 -58
- schemathesis/cli/core.py +17 -0
- schemathesis/cli/ext/fs.py +14 -0
- schemathesis/cli/ext/groups.py +55 -0
- schemathesis/cli/{options.py → ext/options.py} +37 -16
- schemathesis/cli/hooks.py +36 -0
- schemathesis/contrib/__init__.py +1 -3
- schemathesis/contrib/openapi/__init__.py +1 -3
- schemathesis/contrib/openapi/fill_missing_examples.py +3 -7
- schemathesis/core/__init__.py +58 -0
- schemathesis/core/compat.py +25 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +58 -0
- schemathesis/core/deserialization.py +65 -0
- schemathesis/core/errors.py +370 -0
- schemathesis/core/failures.py +315 -0
- schemathesis/core/fs.py +19 -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/{internal/output.py → core/output/__init__.py} +1 -0
- schemathesis/core/output/sanitization.py +197 -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 +108 -0
- schemathesis/core/validation.py +38 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +30 -0
- schemathesis/engine/config.py +59 -0
- schemathesis/engine/context.py +119 -0
- schemathesis/engine/control.py +36 -0
- schemathesis/engine/core.py +157 -0
- schemathesis/engine/errors.py +394 -0
- schemathesis/engine/events.py +243 -0
- schemathesis/engine/phases/__init__.py +66 -0
- schemathesis/{runner → engine/phases}/probes.py +49 -68
- schemathesis/engine/phases/stateful/__init__.py +66 -0
- schemathesis/engine/phases/stateful/_executor.py +301 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +175 -0
- schemathesis/engine/phases/unit/_executor.py +322 -0
- schemathesis/engine/phases/unit/_pool.py +74 -0
- schemathesis/engine/recorder.py +246 -0
- schemathesis/errors.py +31 -0
- schemathesis/experimental/__init__.py +9 -40
- schemathesis/filters.py +7 -95
- schemathesis/generation/__init__.py +3 -3
- schemathesis/generation/case.py +190 -0
- schemathesis/generation/coverage.py +22 -22
- schemathesis/{_patches.py → generation/hypothesis/__init__.py} +15 -6
- schemathesis/generation/hypothesis/builder.py +585 -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/modes.py +28 -0
- schemathesis/generation/overrides.py +96 -0
- schemathesis/generation/stateful/__init__.py +20 -0
- schemathesis/{stateful → generation/stateful}/state_machine.py +84 -109
- schemathesis/generation/targets.py +69 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +131 -0
- schemathesis/hooks.py +17 -62
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +387 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +63 -0
- schemathesis/openapi/loaders.py +178 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +273 -0
- schemathesis/pytest/loaders.py +12 -0
- schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +94 -107
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +456 -228
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/_cache.py +1 -2
- schemathesis/specs/graphql/scalars.py +5 -3
- schemathesis/specs/graphql/schemas.py +122 -123
- 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 +97 -134
- schemathesis/specs/openapi/checks.py +238 -219
- schemathesis/specs/openapi/converter.py +4 -4
- schemathesis/specs/openapi/definitions.py +1 -1
- schemathesis/specs/openapi/examples.py +22 -20
- schemathesis/specs/openapi/expressions/__init__.py +11 -15
- schemathesis/specs/openapi/expressions/extractors.py +1 -4
- schemathesis/specs/openapi/expressions/nodes.py +33 -32
- schemathesis/specs/openapi/formats.py +3 -2
- schemathesis/specs/openapi/links.py +123 -299
- schemathesis/specs/openapi/media_types.py +10 -12
- schemathesis/specs/openapi/negative/__init__.py +2 -1
- schemathesis/specs/openapi/negative/mutations.py +3 -2
- schemathesis/specs/openapi/parameters.py +8 -6
- schemathesis/specs/openapi/patterns.py +1 -1
- schemathesis/specs/openapi/references.py +11 -51
- schemathesis/specs/openapi/schemas.py +177 -191
- schemathesis/specs/openapi/security.py +1 -1
- schemathesis/specs/openapi/serialization.py +10 -6
- schemathesis/specs/openapi/stateful/__init__.py +97 -91
- schemathesis/transport/__init__.py +104 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +99 -0
- schemathesis/transport/requests.py +221 -0
- schemathesis/{_xml.py → transport/serialization.py} +69 -7
- schemathesis/transport/wsgi.py +165 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/METADATA +18 -14
- schemathesis-4.0.0a2.dist-info/RECORD +151 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/entry_points.txt +1 -1
- schemathesis/_compat.py +0 -74
- schemathesis/_dependency_versions.py +0 -19
- schemathesis/_hypothesis.py +0 -559
- schemathesis/_override.py +0 -50
- schemathesis/_rate_limiter.py +0 -7
- 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 -936
- 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 -56
- 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/extra/_aiohttp.py +0 -28
- schemathesis/extra/_flask.py +0 -13
- schemathesis/extra/_server.py +0 -18
- schemathesis/failures.py +0 -277
- 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 -84
- schemathesis/internal/copy.py +0 -32
- schemathesis/internal/datetime.py +0 -5
- schemathesis/internal/deprecation.py +0 -38
- schemathesis/internal/diff.py +0 -15
- schemathesis/internal/extensions.py +0 -27
- schemathesis/internal/jsonschema.py +0 -36
- 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 -104
- schemathesis/runner/impl/core.py +0 -1246
- 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/loaders.py +0 -708
- 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/statistic.py +0 -22
- schemathesis/stateful/validation.py +0 -100
- schemathesis/targets.py +0 -77
- schemathesis/transports/__init__.py +0 -359
- 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.7.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.7.dist-info → schemathesis-4.0.0a2.dist-info}/WHEEL +0 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/licenses/LICENSE +0 -0
@@ -10,32 +10,56 @@ from __future__ import annotations
|
|
10
10
|
|
11
11
|
import enum
|
12
12
|
import warnings
|
13
|
-
from dataclasses import
|
14
|
-
from typing import TYPE_CHECKING
|
13
|
+
from dataclasses import dataclass
|
14
|
+
from typing import TYPE_CHECKING
|
15
15
|
|
16
|
-
from
|
17
|
-
from
|
18
|
-
from
|
19
|
-
from ..sanitization import sanitize_request, sanitize_response
|
20
|
-
from ..transports import RequestConfig
|
21
|
-
from ..transports.auth import get_requests_auth
|
16
|
+
from schemathesis.core.result import Err, Ok, Result
|
17
|
+
from schemathesis.core.transport import USER_AGENT
|
18
|
+
from schemathesis.engine import Status, events
|
22
19
|
|
23
20
|
if TYPE_CHECKING:
|
24
21
|
import requests
|
25
22
|
|
26
|
-
from
|
23
|
+
from schemathesis.engine.config import NetworkConfig
|
24
|
+
from schemathesis.engine.context import EngineContext
|
25
|
+
from schemathesis.engine.events import EventGenerator
|
26
|
+
from schemathesis.engine.phases import Phase
|
27
|
+
from schemathesis.schemas import BaseSchema
|
27
28
|
|
28
29
|
|
29
|
-
|
30
|
+
@dataclass
|
31
|
+
class ProbePayload:
|
32
|
+
probes: list[ProbeRun]
|
33
|
+
|
34
|
+
__slots__ = ("probes",)
|
35
|
+
|
36
|
+
|
37
|
+
def execute(ctx: EngineContext, phase: Phase) -> EventGenerator:
|
38
|
+
"""Discover capabilities of the tested app."""
|
39
|
+
probes = run(ctx.schema, ctx.session, ctx.config.network)
|
40
|
+
status = Status.SUCCESS
|
41
|
+
payload: Result[ProbePayload, Exception] | None = None
|
42
|
+
for result in probes:
|
43
|
+
if isinstance(result.probe, NullByteInHeader) and result.is_failure:
|
44
|
+
from ...specs.openapi import formats
|
45
|
+
from ...specs.openapi.formats import HEADER_FORMAT, header_values
|
46
|
+
|
47
|
+
formats.register(HEADER_FORMAT, header_values(blacklist_characters="\n\r\x00"))
|
48
|
+
if result.error is not None:
|
49
|
+
status = Status.ERROR
|
50
|
+
payload = Err(result.error)
|
51
|
+
else:
|
52
|
+
status = Status.SUCCESS
|
53
|
+
payload = Ok(ProbePayload(probes=probes))
|
54
|
+
yield events.PhaseFinished(phase=phase, status=status, payload=payload)
|
30
55
|
|
31
56
|
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
headers: dict[str, str] | None = None
|
57
|
+
def run(schema: BaseSchema, session: requests.Session, config: NetworkConfig) -> list[ProbeRun]:
|
58
|
+
"""Run all probes against the given schema."""
|
59
|
+
return [send(probe(), session, schema, config) for probe in PROBES]
|
60
|
+
|
61
|
+
|
62
|
+
HEADER_NAME = "X-Schemathesis-Probe"
|
39
63
|
|
40
64
|
|
41
65
|
@dataclass
|
@@ -45,7 +69,7 @@ class Probe:
|
|
45
69
|
name: str
|
46
70
|
|
47
71
|
def prepare_request(
|
48
|
-
self, session: requests.Session, request: requests.Request, schema: BaseSchema
|
72
|
+
self, session: requests.Session, request: requests.Request, schema: BaseSchema
|
49
73
|
) -> requests.PreparedRequest:
|
50
74
|
raise NotImplementedError
|
51
75
|
|
@@ -70,49 +94,24 @@ class ProbeRun:
|
|
70
94
|
outcome: ProbeOutcome
|
71
95
|
request: requests.PreparedRequest | None = None
|
72
96
|
response: requests.Response | None = None
|
73
|
-
error:
|
97
|
+
error: Exception | None = None
|
74
98
|
|
75
99
|
@property
|
76
100
|
def is_failure(self) -> bool:
|
77
101
|
return self.outcome == ProbeOutcome.FAILURE
|
78
102
|
|
79
|
-
def serialize(self) -> dict[str, Any]:
|
80
|
-
"""Serialize probe results so it can be sent over the network."""
|
81
|
-
if self.request:
|
82
|
-
_request = Request.from_prepared_request(self.request)
|
83
|
-
sanitize_request(_request)
|
84
|
-
request = asdict(_request)
|
85
|
-
else:
|
86
|
-
request = None
|
87
|
-
if self.response:
|
88
|
-
sanitize_response(self.response)
|
89
|
-
response = asdict(Response.from_requests(self.response))
|
90
|
-
else:
|
91
|
-
response = None
|
92
|
-
if self.error:
|
93
|
-
error = format_exception(self.error)
|
94
|
-
else:
|
95
|
-
error = None
|
96
|
-
return {
|
97
|
-
"name": self.probe.name,
|
98
|
-
"outcome": self.outcome.value,
|
99
|
-
"request": request,
|
100
|
-
"response": response,
|
101
|
-
"error": error,
|
102
|
-
}
|
103
|
-
|
104
103
|
|
105
104
|
@dataclass
|
106
105
|
class NullByteInHeader(Probe):
|
107
106
|
"""Support NULL bytes in headers."""
|
108
107
|
|
109
|
-
name: str = "
|
108
|
+
name: str = "Supports NULL byte in headers"
|
110
109
|
|
111
110
|
def prepare_request(
|
112
|
-
self, session: requests.Session, request: requests.Request, schema: BaseSchema
|
111
|
+
self, session: requests.Session, request: requests.Request, schema: BaseSchema
|
113
112
|
) -> requests.PreparedRequest:
|
114
113
|
request.method = "GET"
|
115
|
-
request.url =
|
114
|
+
request.url = schema.get_base_url()
|
116
115
|
request.headers = {"X-Schemathesis-Probe-Null": "\x00"}
|
117
116
|
return session.prepare_request(request)
|
118
117
|
|
@@ -125,22 +124,19 @@ class NullByteInHeader(Probe):
|
|
125
124
|
PROBES = (NullByteInHeader,)
|
126
125
|
|
127
126
|
|
128
|
-
def send(probe: Probe, session: requests.Session, schema: BaseSchema, config:
|
127
|
+
def send(probe: Probe, session: requests.Session, schema: BaseSchema, config: NetworkConfig) -> ProbeRun:
|
129
128
|
"""Send the probe to the application."""
|
130
129
|
from requests import PreparedRequest, Request, RequestException
|
131
130
|
from requests.exceptions import MissingSchema
|
132
131
|
from urllib3.exceptions import InsecureRequestWarning
|
133
132
|
|
134
133
|
try:
|
135
|
-
request = probe.prepare_request(session, Request(), schema
|
134
|
+
request = probe.prepare_request(session, Request(), schema)
|
136
135
|
request.headers[HEADER_NAME] = probe.name
|
137
136
|
request.headers["User-Agent"] = USER_AGENT
|
138
|
-
kwargs: dict[str, Any] = {"timeout": config.request.prepared_timeout or 2}
|
139
|
-
if config.request.proxy is not None:
|
140
|
-
kwargs["proxies"] = {"all": config.request.proxy}
|
141
137
|
with warnings.catch_warnings():
|
142
138
|
warnings.simplefilter("ignore", InsecureRequestWarning)
|
143
|
-
response = session.send(request,
|
139
|
+
response = session.send(request, timeout=config.timeout or 2)
|
144
140
|
except MissingSchema:
|
145
141
|
# In-process ASGI/WSGI testing will have local URLs and requires extra handling
|
146
142
|
# which is not currently implemented
|
@@ -150,18 +146,3 @@ def send(probe: Probe, session: requests.Session, schema: BaseSchema, config: Pr
|
|
150
146
|
return ProbeRun(probe, ProbeOutcome.ERROR, req, None, exc)
|
151
147
|
result_type = probe.analyze_response(response)
|
152
148
|
return ProbeRun(probe, result_type, request, response)
|
153
|
-
|
154
|
-
|
155
|
-
def run(schema: BaseSchema, config: ProbeConfig) -> list[ProbeRun]:
|
156
|
-
"""Run all probes against the given schema."""
|
157
|
-
from requests import Session
|
158
|
-
|
159
|
-
session = Session()
|
160
|
-
session.headers.update(config.headers or {})
|
161
|
-
session.verify = config.request.tls_verify
|
162
|
-
if config.request.cert is not None:
|
163
|
-
session.cert = config.request.cert
|
164
|
-
if config.auth is not None:
|
165
|
-
session.auth = get_requests_auth(config.auth, config.auth_type)
|
166
|
-
|
167
|
-
return [send(probe(), session, schema, config) for probe in PROBES]
|
@@ -0,0 +1,66 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import queue
|
4
|
+
import threading
|
5
|
+
from typing import TYPE_CHECKING
|
6
|
+
|
7
|
+
from schemathesis.engine import Status, events
|
8
|
+
from schemathesis.engine.phases import Phase, PhaseName, PhaseSkipReason
|
9
|
+
|
10
|
+
if TYPE_CHECKING:
|
11
|
+
from schemathesis.engine.context import EngineContext
|
12
|
+
|
13
|
+
EVENT_QUEUE_TIMEOUT = 0.01
|
14
|
+
|
15
|
+
|
16
|
+
def execute(engine: EngineContext, phase: Phase) -> events.EventGenerator:
|
17
|
+
from schemathesis.engine.phases.stateful._executor import execute_state_machine_loop
|
18
|
+
|
19
|
+
try:
|
20
|
+
state_machine = engine.schema.as_state_machine()
|
21
|
+
except Exception as exc:
|
22
|
+
yield events.NonFatalError(error=exc, phase=phase.name, label="Stateful tests", related_to_operation=False)
|
23
|
+
return
|
24
|
+
|
25
|
+
event_queue: queue.Queue = queue.Queue()
|
26
|
+
|
27
|
+
thread = threading.Thread(
|
28
|
+
target=execute_state_machine_loop,
|
29
|
+
kwargs={"state_machine": state_machine, "event_queue": event_queue, "engine": engine},
|
30
|
+
name="schemathesis_stateful_tests",
|
31
|
+
)
|
32
|
+
status: Status | None = None
|
33
|
+
is_executed = False
|
34
|
+
|
35
|
+
thread.start()
|
36
|
+
try:
|
37
|
+
while True:
|
38
|
+
try:
|
39
|
+
event = event_queue.get(timeout=EVENT_QUEUE_TIMEOUT)
|
40
|
+
is_executed = True
|
41
|
+
# Set the run status based on the suite status
|
42
|
+
# ERROR & INTERRUPTED statuses are terminal, therefore they should not be overridden
|
43
|
+
if (
|
44
|
+
isinstance(event, events.SuiteFinished)
|
45
|
+
and event.status != Status.SKIP
|
46
|
+
and (status is None or status < event.status)
|
47
|
+
):
|
48
|
+
status = event.status
|
49
|
+
yield event
|
50
|
+
except queue.Empty:
|
51
|
+
if not thread.is_alive():
|
52
|
+
break
|
53
|
+
except KeyboardInterrupt:
|
54
|
+
# Immediately notify the engine thread to stop, even though that the event will be set below in `finally`
|
55
|
+
engine.stop()
|
56
|
+
status = Status.INTERRUPTED
|
57
|
+
yield events.Interrupted(phase=PhaseName.STATEFUL_TESTING)
|
58
|
+
finally:
|
59
|
+
thread.join()
|
60
|
+
|
61
|
+
if not is_executed:
|
62
|
+
phase.skip_reason = PhaseSkipReason.NOTHING_TO_TEST
|
63
|
+
status = Status.SKIP
|
64
|
+
elif status is None:
|
65
|
+
status = Status.SKIP
|
66
|
+
yield events.PhaseFinished(phase=phase, status=status, payload=None)
|
@@ -0,0 +1,301 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import queue
|
4
|
+
import time
|
5
|
+
import unittest
|
6
|
+
from dataclasses import replace
|
7
|
+
from typing import Any
|
8
|
+
from warnings import catch_warnings
|
9
|
+
|
10
|
+
import hypothesis
|
11
|
+
from hypothesis.control import current_build_context
|
12
|
+
from hypothesis.errors import Flaky, Unsatisfiable
|
13
|
+
from hypothesis.stateful import Rule
|
14
|
+
|
15
|
+
from schemathesis.checks import CheckContext, CheckFunction, run_checks
|
16
|
+
from schemathesis.core.failures import Failure, FailureGroup
|
17
|
+
from schemathesis.core.transport import Response
|
18
|
+
from schemathesis.engine import Status, events
|
19
|
+
from schemathesis.engine.context import EngineContext
|
20
|
+
from schemathesis.engine.control import ExecutionControl
|
21
|
+
from schemathesis.engine.phases import PhaseName
|
22
|
+
from schemathesis.engine.phases.stateful.context import StatefulContext
|
23
|
+
from schemathesis.engine.recorder import ScenarioRecorder
|
24
|
+
from schemathesis.generation.case import Case
|
25
|
+
from schemathesis.generation.hypothesis.reporting import ignore_hypothesis_output
|
26
|
+
from schemathesis.generation.stateful.state_machine import (
|
27
|
+
DEFAULT_STATE_MACHINE_SETTINGS,
|
28
|
+
APIStateMachine,
|
29
|
+
StepInput,
|
30
|
+
StepOutput,
|
31
|
+
)
|
32
|
+
from schemathesis.generation.targets import TargetMetricCollector
|
33
|
+
|
34
|
+
|
35
|
+
def _get_hypothesis_settings_kwargs_override(settings: hypothesis.settings) -> dict[str, Any]:
|
36
|
+
"""Get the settings that should be overridden to match the defaults for API state machines."""
|
37
|
+
kwargs = {}
|
38
|
+
hypothesis_default = hypothesis.settings()
|
39
|
+
if settings.phases == hypothesis_default.phases:
|
40
|
+
kwargs["phases"] = DEFAULT_STATE_MACHINE_SETTINGS.phases
|
41
|
+
if settings.stateful_step_count == hypothesis_default.stateful_step_count:
|
42
|
+
kwargs["stateful_step_count"] = DEFAULT_STATE_MACHINE_SETTINGS.stateful_step_count
|
43
|
+
if settings.deadline == hypothesis_default.deadline:
|
44
|
+
kwargs["deadline"] = DEFAULT_STATE_MACHINE_SETTINGS.deadline
|
45
|
+
if settings.suppress_health_check == hypothesis_default.suppress_health_check:
|
46
|
+
kwargs["suppress_health_check"] = DEFAULT_STATE_MACHINE_SETTINGS.suppress_health_check
|
47
|
+
return kwargs
|
48
|
+
|
49
|
+
|
50
|
+
def execute_state_machine_loop(
|
51
|
+
*,
|
52
|
+
state_machine: type[APIStateMachine],
|
53
|
+
event_queue: queue.Queue,
|
54
|
+
engine: EngineContext,
|
55
|
+
) -> None:
|
56
|
+
"""Execute the state machine testing loop."""
|
57
|
+
kwargs = _get_hypothesis_settings_kwargs_override(engine.config.execution.hypothesis_settings)
|
58
|
+
if kwargs:
|
59
|
+
config = replace(
|
60
|
+
engine.config,
|
61
|
+
execution=replace(
|
62
|
+
engine.config.execution,
|
63
|
+
hypothesis_settings=hypothesis.settings(engine.config.execution.hypothesis_settings, **kwargs),
|
64
|
+
),
|
65
|
+
)
|
66
|
+
else:
|
67
|
+
config = engine.config
|
68
|
+
|
69
|
+
ctx = StatefulContext(metric_collector=TargetMetricCollector(targets=config.execution.targets))
|
70
|
+
|
71
|
+
transport_kwargs = engine.transport_kwargs
|
72
|
+
|
73
|
+
class _InstrumentedStateMachine(state_machine): # type: ignore[valid-type,misc]
|
74
|
+
"""State machine with additional hooks for emitting events."""
|
75
|
+
|
76
|
+
def setup(self) -> None:
|
77
|
+
scenario_started = events.ScenarioStarted(label=None, phase=PhaseName.STATEFUL_TESTING, suite_id=suite_id)
|
78
|
+
self._start_time = time.monotonic()
|
79
|
+
self._scenario_id = scenario_started.id
|
80
|
+
event_queue.put(scenario_started)
|
81
|
+
self.recorder = ScenarioRecorder(label="Stateful tests")
|
82
|
+
self._check_ctx = engine.get_check_context(self.recorder)
|
83
|
+
|
84
|
+
def get_call_kwargs(self, case: Case) -> dict[str, Any]:
|
85
|
+
return transport_kwargs
|
86
|
+
|
87
|
+
def _repr_step(self, rule: Rule, data: dict, result: StepOutput) -> str:
|
88
|
+
return ""
|
89
|
+
|
90
|
+
if config.override is not None:
|
91
|
+
|
92
|
+
def before_call(self, case: Case) -> None:
|
93
|
+
for location, entry in config.override.for_operation(case.operation).items(): # type: ignore[union-attr]
|
94
|
+
if entry:
|
95
|
+
container = getattr(case, location) or {}
|
96
|
+
container.update(entry)
|
97
|
+
setattr(case, location, container)
|
98
|
+
return super().before_call(case)
|
99
|
+
|
100
|
+
def step(self, input: StepInput) -> StepOutput | None:
|
101
|
+
# Checking the stop event once inside `step` is sufficient as it is called frequently
|
102
|
+
# The idea is to stop the execution as soon as possible
|
103
|
+
if input.transition is not None:
|
104
|
+
self.recorder.record_case(
|
105
|
+
parent_id=input.transition.parent_id, transition=input.transition, case=input.case
|
106
|
+
)
|
107
|
+
else:
|
108
|
+
self.recorder.record_case(parent_id=None, transition=None, case=input.case)
|
109
|
+
if engine.has_to_stop:
|
110
|
+
raise KeyboardInterrupt
|
111
|
+
try:
|
112
|
+
if config.execution.unique_inputs:
|
113
|
+
cached = ctx.get_step_outcome(input.case)
|
114
|
+
if isinstance(cached, BaseException):
|
115
|
+
raise cached
|
116
|
+
elif cached is None:
|
117
|
+
return None
|
118
|
+
result = super().step(input)
|
119
|
+
ctx.step_succeeded()
|
120
|
+
except FailureGroup as exc:
|
121
|
+
if config.execution.unique_inputs:
|
122
|
+
for failure in exc.exceptions:
|
123
|
+
ctx.store_step_outcome(input.case, failure)
|
124
|
+
ctx.step_failed()
|
125
|
+
raise
|
126
|
+
except Exception as exc:
|
127
|
+
if config.execution.unique_inputs:
|
128
|
+
ctx.store_step_outcome(input.case, exc)
|
129
|
+
ctx.step_errored()
|
130
|
+
raise
|
131
|
+
except KeyboardInterrupt:
|
132
|
+
ctx.step_interrupted()
|
133
|
+
raise
|
134
|
+
except BaseException as exc:
|
135
|
+
if config.execution.unique_inputs:
|
136
|
+
ctx.store_step_outcome(input.case, exc)
|
137
|
+
raise exc
|
138
|
+
else:
|
139
|
+
if config.execution.unique_inputs:
|
140
|
+
ctx.store_step_outcome(input.case, None)
|
141
|
+
return result
|
142
|
+
|
143
|
+
def validate_response(
|
144
|
+
self, response: Response, case: Case, additional_checks: tuple[CheckFunction, ...] = ()
|
145
|
+
) -> None:
|
146
|
+
self.recorder.record_response(case_id=case.id, response=response)
|
147
|
+
ctx.collect_metric(case, response)
|
148
|
+
ctx.current_response = response
|
149
|
+
validate_response(
|
150
|
+
response=response,
|
151
|
+
case=case,
|
152
|
+
stateful_ctx=ctx,
|
153
|
+
check_ctx=self._check_ctx,
|
154
|
+
checks=config.execution.checks,
|
155
|
+
control=engine.control,
|
156
|
+
recorder=self.recorder,
|
157
|
+
additional_checks=additional_checks,
|
158
|
+
)
|
159
|
+
|
160
|
+
def teardown(self) -> None:
|
161
|
+
build_ctx = current_build_context()
|
162
|
+
event_queue.put(
|
163
|
+
events.ScenarioFinished(
|
164
|
+
id=self._scenario_id,
|
165
|
+
suite_id=suite_id,
|
166
|
+
phase=PhaseName.STATEFUL_TESTING,
|
167
|
+
label=None,
|
168
|
+
status=ctx.current_scenario_status or Status.SKIP,
|
169
|
+
recorder=self.recorder,
|
170
|
+
elapsed_time=time.monotonic() - self._start_time,
|
171
|
+
skip_reason=None,
|
172
|
+
is_final=build_ctx.is_final,
|
173
|
+
)
|
174
|
+
)
|
175
|
+
ctx.maximize_metrics()
|
176
|
+
ctx.reset_scenario()
|
177
|
+
super().teardown()
|
178
|
+
|
179
|
+
if config.execution.seed is not None:
|
180
|
+
InstrumentedStateMachine = hypothesis.seed(config.execution.seed)(_InstrumentedStateMachine)
|
181
|
+
else:
|
182
|
+
InstrumentedStateMachine = _InstrumentedStateMachine
|
183
|
+
|
184
|
+
while True:
|
185
|
+
# This loop is running until no new failures are found in a single iteration
|
186
|
+
suite_started = events.SuiteStarted(phase=PhaseName.STATEFUL_TESTING)
|
187
|
+
suite_id = suite_started.id
|
188
|
+
event_queue.put(suite_started)
|
189
|
+
if engine.is_interrupted:
|
190
|
+
event_queue.put(events.Interrupted(phase=PhaseName.STATEFUL_TESTING))
|
191
|
+
event_queue.put(
|
192
|
+
events.SuiteFinished(
|
193
|
+
id=suite_started.id,
|
194
|
+
phase=PhaseName.STATEFUL_TESTING,
|
195
|
+
status=Status.INTERRUPTED,
|
196
|
+
)
|
197
|
+
)
|
198
|
+
break
|
199
|
+
suite_status = Status.SUCCESS
|
200
|
+
try:
|
201
|
+
with catch_warnings(), ignore_hypothesis_output(): # type: ignore
|
202
|
+
InstrumentedStateMachine.run(settings=config.execution.hypothesis_settings)
|
203
|
+
except KeyboardInterrupt:
|
204
|
+
# Raised in the state machine when the stop event is set or it is raised by the user's code
|
205
|
+
# that is placed in the base class of the state machine.
|
206
|
+
# Therefore, set the stop event to cover the latter case
|
207
|
+
engine.stop()
|
208
|
+
suite_status = Status.INTERRUPTED
|
209
|
+
event_queue.put(events.Interrupted(phase=PhaseName.STATEFUL_TESTING))
|
210
|
+
break
|
211
|
+
except unittest.case.SkipTest:
|
212
|
+
# If `explicit` phase is used and there are no examples
|
213
|
+
suite_status = Status.SKIP
|
214
|
+
break
|
215
|
+
except FailureGroup as exc:
|
216
|
+
# When a check fails, the state machine is stopped
|
217
|
+
# The failure is already sent to the queue by the state machine
|
218
|
+
# Here we need to either exit or re-run the state machine with this failure marked as known
|
219
|
+
suite_status = Status.FAILURE
|
220
|
+
if engine.has_reached_the_failure_limit:
|
221
|
+
break # type: ignore[unreachable]
|
222
|
+
for failure in exc.exceptions:
|
223
|
+
ctx.mark_as_seen_in_run(failure)
|
224
|
+
continue
|
225
|
+
except Flaky:
|
226
|
+
suite_status = Status.FAILURE
|
227
|
+
if engine.has_reached_the_failure_limit:
|
228
|
+
break # type: ignore[unreachable]
|
229
|
+
# Mark all failures in this suite as seen to prevent them being re-discovered
|
230
|
+
ctx.mark_current_suite_as_seen_in_run()
|
231
|
+
continue
|
232
|
+
except Exception as exc:
|
233
|
+
if isinstance(exc, Unsatisfiable) and ctx.completed_scenarios > 0:
|
234
|
+
# Sometimes Hypothesis randomly gives up on generating some complex cases. However, if we know that
|
235
|
+
# values are possible to generate based on the previous observations, we retry the generation
|
236
|
+
if ctx.completed_scenarios >= config.execution.hypothesis_settings.max_examples:
|
237
|
+
# Avoid infinite restarts
|
238
|
+
break
|
239
|
+
continue
|
240
|
+
# Any other exception is an inner error and the test run should be stopped
|
241
|
+
suite_status = Status.ERROR
|
242
|
+
event_queue.put(
|
243
|
+
events.NonFatalError(
|
244
|
+
error=exc, phase=PhaseName.STATEFUL_TESTING, label="Stateful tests", related_to_operation=False
|
245
|
+
)
|
246
|
+
)
|
247
|
+
break
|
248
|
+
finally:
|
249
|
+
event_queue.put(
|
250
|
+
events.SuiteFinished(
|
251
|
+
id=suite_started.id,
|
252
|
+
phase=PhaseName.STATEFUL_TESTING,
|
253
|
+
status=suite_status,
|
254
|
+
)
|
255
|
+
)
|
256
|
+
ctx.reset()
|
257
|
+
# Exit on the first successful state machine execution
|
258
|
+
break
|
259
|
+
|
260
|
+
|
261
|
+
def validate_response(
|
262
|
+
*,
|
263
|
+
response: Response,
|
264
|
+
case: Case,
|
265
|
+
stateful_ctx: StatefulContext,
|
266
|
+
check_ctx: CheckContext,
|
267
|
+
control: ExecutionControl,
|
268
|
+
checks: list[CheckFunction],
|
269
|
+
recorder: ScenarioRecorder,
|
270
|
+
additional_checks: tuple[CheckFunction, ...] = (),
|
271
|
+
) -> None:
|
272
|
+
"""Validate the response against the provided checks."""
|
273
|
+
|
274
|
+
def on_failure(name: str, collected: set[Failure], failure: Failure) -> None:
|
275
|
+
if stateful_ctx.is_seen_in_suite(failure) or stateful_ctx.is_seen_in_run(failure):
|
276
|
+
return
|
277
|
+
failure_data = recorder.find_failure_data(parent_id=case.id, failure=failure)
|
278
|
+
recorder.record_check_failure(
|
279
|
+
name=name,
|
280
|
+
case_id=failure_data.case.id,
|
281
|
+
code_sample=failure_data.case.as_curl_command(headers=failure_data.headers, verify=failure_data.verify),
|
282
|
+
failure=failure,
|
283
|
+
)
|
284
|
+
control.count_failure()
|
285
|
+
stateful_ctx.mark_as_seen_in_suite(failure)
|
286
|
+
collected.add(failure)
|
287
|
+
|
288
|
+
def on_success(name: str, case: Case) -> None:
|
289
|
+
recorder.record_check_success(name=name, case_id=case.id)
|
290
|
+
|
291
|
+
failures = run_checks(
|
292
|
+
case=case,
|
293
|
+
response=response,
|
294
|
+
ctx=check_ctx,
|
295
|
+
checks=tuple(checks) + tuple(additional_checks),
|
296
|
+
on_failure=on_failure,
|
297
|
+
on_success=on_success,
|
298
|
+
)
|
299
|
+
|
300
|
+
if failures:
|
301
|
+
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.targets import TargetMetricCollector
|
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: TargetMetricCollector = field(default_factory=TargetMetricCollector)
|
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)
|