schemathesis 4.0.0a1__py3-none-any.whl → 4.0.0a3__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/checks.py +6 -4
- schemathesis/cli/__init__.py +12 -1
- schemathesis/cli/commands/run/__init__.py +4 -4
- schemathesis/cli/commands/run/events.py +19 -4
- schemathesis/cli/commands/run/executor.py +9 -3
- schemathesis/cli/commands/run/filters.py +27 -19
- schemathesis/cli/commands/run/handlers/base.py +1 -1
- schemathesis/cli/commands/run/handlers/cassettes.py +1 -3
- schemathesis/cli/commands/run/handlers/output.py +860 -201
- schemathesis/cli/commands/run/validation.py +1 -1
- schemathesis/cli/ext/options.py +4 -1
- schemathesis/core/errors.py +8 -0
- schemathesis/core/failures.py +54 -24
- schemathesis/engine/core.py +1 -1
- schemathesis/engine/errors.py +11 -5
- schemathesis/engine/events.py +3 -97
- schemathesis/engine/phases/stateful/__init__.py +2 -0
- schemathesis/engine/phases/stateful/_executor.py +22 -50
- schemathesis/engine/phases/unit/__init__.py +1 -0
- schemathesis/engine/phases/unit/_executor.py +2 -1
- schemathesis/engine/phases/unit/_pool.py +1 -1
- schemathesis/engine/recorder.py +29 -23
- schemathesis/errors.py +19 -13
- schemathesis/generation/coverage.py +4 -4
- schemathesis/generation/hypothesis/builder.py +15 -12
- schemathesis/generation/stateful/state_machine.py +61 -45
- schemathesis/graphql/checks.py +3 -9
- schemathesis/openapi/checks.py +8 -33
- schemathesis/schemas.py +34 -14
- schemathesis/specs/graphql/schemas.py +16 -15
- schemathesis/specs/openapi/checks.py +50 -27
- schemathesis/specs/openapi/expressions/__init__.py +11 -15
- schemathesis/specs/openapi/expressions/nodes.py +20 -20
- schemathesis/specs/openapi/links.py +139 -118
- schemathesis/specs/openapi/patterns.py +170 -2
- schemathesis/specs/openapi/schemas.py +60 -36
- schemathesis/specs/openapi/stateful/__init__.py +185 -113
- schemathesis/specs/openapi/stateful/control.py +87 -0
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/METADATA +1 -1
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/RECORD +43 -43
- schemathesis/specs/openapi/expressions/context.py +0 -14
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/licenses/LICENSE +0 -0
@@ -256,7 +256,7 @@ def convert_experimental(
|
|
256
256
|
]
|
257
257
|
|
258
258
|
|
259
|
-
def
|
259
|
+
def reduce_list(ctx: click.core.Context, param: click.core.Parameter, value: tuple[list[str]]) -> list[str]:
|
260
260
|
return reduce(operator.iadd, value, [])
|
261
261
|
|
262
262
|
|
schemathesis/cli/ext/options.py
CHANGED
@@ -80,7 +80,10 @@ class RegistryChoice(BaseCsvChoice):
|
|
80
80
|
def convert( # type: ignore[return]
|
81
81
|
self, value: str, param: click.core.Parameter | None, ctx: click.core.Context | None
|
82
82
|
) -> list[str]:
|
83
|
-
|
83
|
+
selected, invalid_options = self.parse_value(value)
|
84
|
+
if not invalid_options and selected:
|
85
|
+
return selected
|
86
|
+
self.fail_on_invalid_options(invalid_options, selected)
|
84
87
|
|
85
88
|
|
86
89
|
class OptionalInt(click.types.IntRange):
|
schemathesis/core/errors.py
CHANGED
@@ -102,6 +102,10 @@ class InvalidRegexType(InvalidSchema):
|
|
102
102
|
"""Raised when an invalid type is used where a regex pattern is expected."""
|
103
103
|
|
104
104
|
|
105
|
+
class InvalidLinkDefinition(InvalidSchema):
|
106
|
+
"""Raised when an Open API link references a non-existent operation."""
|
107
|
+
|
108
|
+
|
105
109
|
class MalformedMediaType(ValueError):
|
106
110
|
"""Raised on parsing of incorrect media type."""
|
107
111
|
|
@@ -148,6 +152,10 @@ class IncorrectUsage(SchemathesisError):
|
|
148
152
|
"""Indicates incorrect usage of Schemathesis' public API."""
|
149
153
|
|
150
154
|
|
155
|
+
class NoLinksFound(IncorrectUsage):
|
156
|
+
"""Raised when no valid links are available for stateful testing."""
|
157
|
+
|
158
|
+
|
151
159
|
class InvalidRateLimit(IncorrectUsage):
|
152
160
|
"""Incorrect input for rate limiting."""
|
153
161
|
|
schemathesis/core/failures.py
CHANGED
@@ -2,11 +2,12 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
import http.client
|
4
4
|
import textwrap
|
5
|
+
import traceback
|
5
6
|
from collections.abc import Sequence
|
6
7
|
from dataclasses import dataclass
|
7
8
|
from enum import Enum, auto
|
8
9
|
from json import JSONDecodeError
|
9
|
-
from typing import Callable
|
10
|
+
from typing import Any, Callable
|
10
11
|
|
11
12
|
from schemathesis.core.compat import BaseExceptionGroup
|
12
13
|
from schemathesis.core.output import OutputConfig, prepare_response_payload
|
@@ -32,7 +33,7 @@ class Severity(Enum):
|
|
32
33
|
class Failure(AssertionError):
|
33
34
|
"""API check failure."""
|
34
35
|
|
35
|
-
__slots__ = ("operation", "title", "message", "
|
36
|
+
__slots__ = ("operation", "title", "message", "case_id", "severity")
|
36
37
|
|
37
38
|
def __init__(
|
38
39
|
self,
|
@@ -40,14 +41,12 @@ class Failure(AssertionError):
|
|
40
41
|
operation: str,
|
41
42
|
title: str,
|
42
43
|
message: str,
|
43
|
-
code: str,
|
44
44
|
case_id: str | None = None,
|
45
45
|
severity: Severity = Severity.MEDIUM,
|
46
46
|
) -> None:
|
47
47
|
self.operation = operation
|
48
48
|
self.title = title
|
49
49
|
self.message = message
|
50
|
-
self.code = code
|
51
50
|
self.case_id = case_id
|
52
51
|
self.severity = severity
|
53
52
|
|
@@ -72,20 +71,58 @@ class Failure(AssertionError):
|
|
72
71
|
return NotImplemented
|
73
72
|
return type(self) is type(other) and self.operation == other.operation and self._unique_key == other._unique_key
|
74
73
|
|
75
|
-
@classmethod
|
76
|
-
def from_assertion(cls, *, name: str, operation: str, exc: AssertionError) -> Failure:
|
77
|
-
return Failure(
|
78
|
-
operation=operation,
|
79
|
-
title=f"Custom check failed: `{name}`",
|
80
|
-
message=str(exc),
|
81
|
-
code="custom",
|
82
|
-
)
|
83
|
-
|
84
74
|
@property
|
85
|
-
def _unique_key(self) ->
|
75
|
+
def _unique_key(self) -> Any:
|
86
76
|
return self.message
|
87
77
|
|
88
78
|
|
79
|
+
def get_origin(exception: BaseException, seen: tuple[BaseException, ...] = ()) -> tuple:
|
80
|
+
filename, lineno = None, None
|
81
|
+
if tb := exception.__traceback__:
|
82
|
+
filename, lineno, *_ = traceback.extract_tb(tb)[-1]
|
83
|
+
seen = (*seen, exception)
|
84
|
+
context = ()
|
85
|
+
if exception.__context__ is not None and exception.__context__ not in seen:
|
86
|
+
context = get_origin(exception.__context__, seen=seen)
|
87
|
+
return (
|
88
|
+
type(exception),
|
89
|
+
filename,
|
90
|
+
lineno,
|
91
|
+
context,
|
92
|
+
(
|
93
|
+
tuple(get_origin(exc, seen=seen) for exc in exception.exceptions if exc not in seen)
|
94
|
+
if isinstance(exception, BaseExceptionGroup)
|
95
|
+
else ()
|
96
|
+
),
|
97
|
+
)
|
98
|
+
|
99
|
+
|
100
|
+
class CustomFailure(Failure):
|
101
|
+
__slots__ = ("operation", "title", "message", "exception", "case_id", "severity", "origin")
|
102
|
+
|
103
|
+
def __init__(
|
104
|
+
self,
|
105
|
+
*,
|
106
|
+
operation: str,
|
107
|
+
title: str,
|
108
|
+
message: str,
|
109
|
+
exception: AssertionError,
|
110
|
+
case_id: str | None = None,
|
111
|
+
severity: Severity = Severity.MEDIUM,
|
112
|
+
) -> None:
|
113
|
+
self.operation = operation
|
114
|
+
self.title = title
|
115
|
+
self.message = message
|
116
|
+
self.exception = exception
|
117
|
+
self.case_id = case_id
|
118
|
+
self.severity = severity
|
119
|
+
self.origin = get_origin(exception)
|
120
|
+
|
121
|
+
@property
|
122
|
+
def _unique_key(self) -> Any:
|
123
|
+
return self.origin
|
124
|
+
|
125
|
+
|
89
126
|
@dataclass
|
90
127
|
class MaxResponseTimeConfig:
|
91
128
|
limit: float = 10.0
|
@@ -94,7 +131,7 @@ class MaxResponseTimeConfig:
|
|
94
131
|
class ResponseTimeExceeded(Failure):
|
95
132
|
"""Response took longer than expected."""
|
96
133
|
|
97
|
-
__slots__ = ("operation", "elapsed", "deadline", "title", "message", "
|
134
|
+
__slots__ = ("operation", "elapsed", "deadline", "title", "message", "case_id", "severity")
|
98
135
|
|
99
136
|
def __init__(
|
100
137
|
self,
|
@@ -104,7 +141,6 @@ class ResponseTimeExceeded(Failure):
|
|
104
141
|
deadline: int,
|
105
142
|
message: str,
|
106
143
|
title: str = "Response time limit exceeded",
|
107
|
-
code: str = "response_time_exceeded",
|
108
144
|
case_id: str | None = None,
|
109
145
|
) -> None:
|
110
146
|
self.operation = operation
|
@@ -112,7 +148,6 @@ class ResponseTimeExceeded(Failure):
|
|
112
148
|
self.deadline = deadline
|
113
149
|
self.title = title
|
114
150
|
self.message = message
|
115
|
-
self.code = code
|
116
151
|
self.case_id = case_id
|
117
152
|
self.severity = Severity.LOW
|
118
153
|
|
@@ -124,7 +159,7 @@ class ResponseTimeExceeded(Failure):
|
|
124
159
|
class ServerError(Failure):
|
125
160
|
"""Server responded with an error."""
|
126
161
|
|
127
|
-
__slots__ = ("operation", "status_code", "title", "message", "
|
162
|
+
__slots__ = ("operation", "status_code", "title", "message", "case_id", "severity")
|
128
163
|
|
129
164
|
def __init__(
|
130
165
|
self,
|
@@ -133,14 +168,12 @@ class ServerError(Failure):
|
|
133
168
|
status_code: int,
|
134
169
|
title: str = "Server error",
|
135
170
|
message: str = "",
|
136
|
-
code: str = "server_error",
|
137
171
|
case_id: str | None = None,
|
138
172
|
) -> None:
|
139
173
|
self.operation = operation
|
140
174
|
self.status_code = status_code
|
141
175
|
self.title = title
|
142
176
|
self.message = message
|
143
|
-
self.code = code
|
144
177
|
self.case_id = case_id
|
145
178
|
self.severity = Severity.CRITICAL
|
146
179
|
|
@@ -161,7 +194,6 @@ class MalformedJson(Failure):
|
|
161
194
|
"colno",
|
162
195
|
"message",
|
163
196
|
"title",
|
164
|
-
"code",
|
165
197
|
"case_id",
|
166
198
|
"severity",
|
167
199
|
)
|
@@ -177,7 +209,6 @@ class MalformedJson(Failure):
|
|
177
209
|
colno: int,
|
178
210
|
message: str,
|
179
211
|
title: str = "JSON deserialization error",
|
180
|
-
code: str = "malformed_json",
|
181
212
|
case_id: str | None = None,
|
182
213
|
) -> None:
|
183
214
|
self.operation = operation
|
@@ -188,12 +219,11 @@ class MalformedJson(Failure):
|
|
188
219
|
self.colno = colno
|
189
220
|
self.message = message
|
190
221
|
self.title = title
|
191
|
-
self.code = code
|
192
222
|
self.case_id = case_id
|
193
223
|
self.severity = Severity.MEDIUM
|
194
224
|
|
195
225
|
@property
|
196
|
-
def _unique_key(self) ->
|
226
|
+
def _unique_key(self) -> Any:
|
197
227
|
return self.title
|
198
228
|
|
199
229
|
@classmethod
|
schemathesis/engine/core.py
CHANGED
schemathesis/engine/errors.py
CHANGED
@@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, Callable, Iterator, Sequence, cast
|
|
14
14
|
from schemathesis import errors
|
15
15
|
from schemathesis.core.errors import (
|
16
16
|
RECURSIVE_REFERENCE_ERROR_MESSAGE,
|
17
|
+
InvalidLinkDefinition,
|
17
18
|
SerializationNotPossible,
|
18
19
|
format_exception,
|
19
20
|
get_request_error_extras,
|
@@ -76,6 +77,9 @@ class EngineErrorInfo:
|
|
76
77
|
"""A general error description."""
|
77
78
|
import requests
|
78
79
|
|
80
|
+
if isinstance(self._error, InvalidLinkDefinition):
|
81
|
+
return "Invalid Link Definition"
|
82
|
+
|
79
83
|
if isinstance(self._error, requests.RequestException):
|
80
84
|
return "Network Error"
|
81
85
|
|
@@ -96,6 +100,7 @@ class EngineErrorInfo:
|
|
96
100
|
|
97
101
|
return {
|
98
102
|
RuntimeErrorKind.SCHEMA_UNSUPPORTED: "Unsupported Schema",
|
103
|
+
RuntimeErrorKind.SCHEMA_NO_LINKS_FOUND: "Missing Open API links",
|
99
104
|
RuntimeErrorKind.HYPOTHESIS_UNSUPPORTED_GRAPHQL_SCALAR: "Unknown GraphQL Scalar",
|
100
105
|
RuntimeErrorKind.SERIALIZATION_UNBOUNDED_PREFIX: "XML serialization error",
|
101
106
|
RuntimeErrorKind.SERIALIZATION_NOT_POSSIBLE: "Serialization not possible",
|
@@ -166,6 +171,7 @@ class EngineErrorInfo:
|
|
166
171
|
RuntimeErrorKind.SCHEMA_INVALID_REGULAR_EXPRESSION,
|
167
172
|
RuntimeErrorKind.SCHEMA_UNSUPPORTED,
|
168
173
|
RuntimeErrorKind.SCHEMA_GENERIC,
|
174
|
+
RuntimeErrorKind.SCHEMA_NO_LINKS_FOUND,
|
169
175
|
RuntimeErrorKind.SERIALIZATION_NOT_POSSIBLE,
|
170
176
|
RuntimeErrorKind.HYPOTHESIS_UNSUPPORTED_GRAPHQL_SCALAR,
|
171
177
|
RuntimeErrorKind.HYPOTHESIS_UNSATISFIABLE,
|
@@ -184,11 +190,7 @@ class EngineErrorInfo:
|
|
184
190
|
"""Format error message with optional styling and traceback."""
|
185
191
|
message = []
|
186
192
|
|
187
|
-
|
188
|
-
if self._kind == RuntimeErrorKind.SCHEMA_GENERIC:
|
189
|
-
title = "Schema Error"
|
190
|
-
else:
|
191
|
-
title = self.title
|
193
|
+
title = self.title
|
192
194
|
if title:
|
193
195
|
message.append(f"{title}\n")
|
194
196
|
|
@@ -246,6 +248,7 @@ def get_runtime_error_suggestion(error_type: RuntimeErrorKind, bold: Callable[[s
|
|
246
248
|
return {
|
247
249
|
RuntimeErrorKind.CONNECTION_SSL: f"Bypass SSL verification with {bold('`--request-tls-verify=false`')}.",
|
248
250
|
RuntimeErrorKind.HYPOTHESIS_UNSATISFIABLE: "Examine the schema for inconsistencies and consider simplifying it.",
|
251
|
+
RuntimeErrorKind.SCHEMA_NO_LINKS_FOUND: "Review your endpoint filters to include linked operations",
|
249
252
|
RuntimeErrorKind.SCHEMA_INVALID_REGULAR_EXPRESSION: "Ensure your regex is compatible with Python's syntax.\n"
|
250
253
|
"For guidance, visit: https://docs.python.org/3/library/re.html",
|
251
254
|
RuntimeErrorKind.HYPOTHESIS_UNSUPPORTED_GRAPHQL_SCALAR: "Define a custom strategy for it.\n"
|
@@ -299,6 +302,7 @@ class RuntimeErrorKind(str, enum.Enum):
|
|
299
302
|
HYPOTHESIS_HEALTH_CHECK_LARGE_BASE_EXAMPLE = "hypothesis_health_check_large_base_example"
|
300
303
|
|
301
304
|
SCHEMA_INVALID_REGULAR_EXPRESSION = "schema_invalid_regular_expression"
|
305
|
+
SCHEMA_NO_LINKS_FOUND = "schema_no_links_found"
|
302
306
|
SCHEMA_UNSUPPORTED = "schema_unsupported"
|
303
307
|
SCHEMA_GENERIC = "schema_generic"
|
304
308
|
|
@@ -350,6 +354,8 @@ def _classify(*, error: Exception) -> RuntimeErrorKind:
|
|
350
354
|
if isinstance(error, errors.InvalidRegexPattern):
|
351
355
|
return RuntimeErrorKind.SCHEMA_INVALID_REGULAR_EXPRESSION
|
352
356
|
return RuntimeErrorKind.SCHEMA_GENERIC
|
357
|
+
if isinstance(error, errors.NoLinksFound):
|
358
|
+
return RuntimeErrorKind.SCHEMA_NO_LINKS_FOUND
|
353
359
|
if isinstance(error, UnsupportedRecursiveReference):
|
354
360
|
# Recursive references are not supported right now
|
355
361
|
return RuntimeErrorKind.SCHEMA_UNSUPPORTED
|
schemathesis/engine/events.py
CHANGED
@@ -6,7 +6,6 @@ from dataclasses import dataclass
|
|
6
6
|
from typing import TYPE_CHECKING, Generator
|
7
7
|
|
8
8
|
from schemathesis.core.result import Result
|
9
|
-
from schemathesis.core.transport import Response
|
10
9
|
from schemathesis.engine.errors import EngineErrorInfo
|
11
10
|
from schemathesis.engine.phases import Phase, PhaseName
|
12
11
|
from schemathesis.engine.recorder import ScenarioRecorder
|
@@ -142,6 +141,7 @@ class ScenarioFinished(ScenarioEvent):
|
|
142
141
|
"timestamp",
|
143
142
|
"phase",
|
144
143
|
"suite_id",
|
144
|
+
"label",
|
145
145
|
"status",
|
146
146
|
"recorder",
|
147
147
|
"elapsed_time",
|
@@ -155,6 +155,7 @@ class ScenarioFinished(ScenarioEvent):
|
|
155
155
|
id: uuid.UUID,
|
156
156
|
phase: PhaseName,
|
157
157
|
suite_id: uuid.UUID,
|
158
|
+
label: str | None,
|
158
159
|
status: Status,
|
159
160
|
recorder: ScenarioRecorder,
|
160
161
|
elapsed_time: float,
|
@@ -165,6 +166,7 @@ class ScenarioFinished(ScenarioEvent):
|
|
165
166
|
self.timestamp = time.time()
|
166
167
|
self.phase = phase
|
167
168
|
self.suite_id = suite_id
|
169
|
+
self.label = label
|
168
170
|
self.status = status
|
169
171
|
self.recorder = recorder
|
170
172
|
self.elapsed_time = elapsed_time
|
@@ -172,102 +174,6 @@ class ScenarioFinished(ScenarioEvent):
|
|
172
174
|
self.is_final = is_final
|
173
175
|
|
174
176
|
|
175
|
-
@dataclass
|
176
|
-
class StepEvent(ScenarioEvent):
|
177
|
-
scenario_id: uuid.UUID
|
178
|
-
|
179
|
-
|
180
|
-
@dataclass
|
181
|
-
class StepStarted(StepEvent):
|
182
|
-
"""Before executing a test case."""
|
183
|
-
|
184
|
-
__slots__ = (
|
185
|
-
"id",
|
186
|
-
"timestamp",
|
187
|
-
"phase",
|
188
|
-
"suite_id",
|
189
|
-
"scenario_id",
|
190
|
-
)
|
191
|
-
|
192
|
-
def __init__(
|
193
|
-
self,
|
194
|
-
*,
|
195
|
-
phase: PhaseName,
|
196
|
-
suite_id: uuid.UUID,
|
197
|
-
scenario_id: uuid.UUID,
|
198
|
-
) -> None:
|
199
|
-
self.id = uuid.uuid4()
|
200
|
-
self.timestamp = time.time()
|
201
|
-
self.phase = phase
|
202
|
-
self.suite_id = suite_id
|
203
|
-
self.scenario_id = scenario_id
|
204
|
-
|
205
|
-
|
206
|
-
@dataclass
|
207
|
-
class TransitionId:
|
208
|
-
"""Id of the the that was hit."""
|
209
|
-
|
210
|
-
name: str
|
211
|
-
# Status code as defined in the transition, i.e. may be `default`
|
212
|
-
status_code: str
|
213
|
-
source: str
|
214
|
-
|
215
|
-
__slots__ = ("name", "status_code", "source")
|
216
|
-
|
217
|
-
|
218
|
-
@dataclass
|
219
|
-
class ResponseData:
|
220
|
-
"""Common data for responses."""
|
221
|
-
|
222
|
-
status_code: int
|
223
|
-
elapsed: float
|
224
|
-
__slots__ = ("status_code", "elapsed")
|
225
|
-
|
226
|
-
|
227
|
-
@dataclass
|
228
|
-
class StepFinished(StepEvent):
|
229
|
-
"""After executing a test case."""
|
230
|
-
|
231
|
-
status: Status | None
|
232
|
-
transition_id: TransitionId | None
|
233
|
-
target: str
|
234
|
-
response: Response | None
|
235
|
-
|
236
|
-
__slots__ = (
|
237
|
-
"id",
|
238
|
-
"timestamp",
|
239
|
-
"phase",
|
240
|
-
"status",
|
241
|
-
"suite_id",
|
242
|
-
"scenario_id",
|
243
|
-
"transition_id",
|
244
|
-
"target",
|
245
|
-
"response",
|
246
|
-
)
|
247
|
-
|
248
|
-
def __init__(
|
249
|
-
self,
|
250
|
-
*,
|
251
|
-
phase: PhaseName,
|
252
|
-
id: uuid.UUID,
|
253
|
-
suite_id: uuid.UUID,
|
254
|
-
scenario_id: uuid.UUID,
|
255
|
-
status: Status | None,
|
256
|
-
transition_id: TransitionId | None,
|
257
|
-
target: str,
|
258
|
-
response: Response | None,
|
259
|
-
) -> None:
|
260
|
-
self.id = id
|
261
|
-
self.timestamp = time.time()
|
262
|
-
self.phase = phase
|
263
|
-
self.status = status
|
264
|
-
self.suite_id = suite_id
|
265
|
-
self.scenario_id = scenario_id
|
266
|
-
self.transition_id = transition_id
|
267
|
-
self.target = target
|
268
|
-
self.response = response
|
269
|
-
|
270
|
-
|
271
177
|
@dataclass
|
272
178
|
class Interrupted(EngineEvent):
|
273
179
|
"""If execution was interrupted by Ctrl-C, or a received SIGTERM."""
|
@@ -20,6 +20,7 @@ def execute(engine: EngineContext, phase: Phase) -> events.EventGenerator:
|
|
20
20
|
state_machine = engine.schema.as_state_machine()
|
21
21
|
except Exception as exc:
|
22
22
|
yield events.NonFatalError(error=exc, phase=phase.name, label="Stateful tests", related_to_operation=False)
|
23
|
+
yield events.PhaseFinished(phase=phase, status=Status.ERROR, payload=None)
|
23
24
|
return
|
24
25
|
|
25
26
|
event_queue: queue.Queue = queue.Queue()
|
@@ -27,6 +28,7 @@ def execute(engine: EngineContext, phase: Phase) -> events.EventGenerator:
|
|
27
28
|
thread = threading.Thread(
|
28
29
|
target=execute_state_machine_loop,
|
29
30
|
kwargs={"state_machine": state_machine, "event_queue": event_queue, "engine": engine},
|
31
|
+
name="schemathesis_stateful_tests",
|
30
32
|
)
|
31
33
|
status: Status | None = None
|
32
34
|
is_executed = False
|
@@ -5,6 +5,7 @@ import time
|
|
5
5
|
import unittest
|
6
6
|
from dataclasses import replace
|
7
7
|
from typing import Any
|
8
|
+
from warnings import catch_warnings
|
8
9
|
|
9
10
|
import hypothesis
|
10
11
|
from hypothesis.control import current_build_context
|
@@ -25,8 +26,8 @@ from schemathesis.generation.hypothesis.reporting import ignore_hypothesis_outpu
|
|
25
26
|
from schemathesis.generation.stateful.state_machine import (
|
26
27
|
DEFAULT_STATE_MACHINE_SETTINGS,
|
27
28
|
APIStateMachine,
|
28
|
-
|
29
|
-
|
29
|
+
StepInput,
|
30
|
+
StepOutput,
|
30
31
|
)
|
31
32
|
from schemathesis.generation.targets import TargetMetricCollector
|
32
33
|
|
@@ -77,13 +78,12 @@ def execute_state_machine_loop(
|
|
77
78
|
self._start_time = time.monotonic()
|
78
79
|
self._scenario_id = scenario_started.id
|
79
80
|
event_queue.put(scenario_started)
|
80
|
-
self.recorder = ScenarioRecorder(label="Stateful tests")
|
81
81
|
self._check_ctx = engine.get_check_context(self.recorder)
|
82
82
|
|
83
83
|
def get_call_kwargs(self, case: Case) -> dict[str, Any]:
|
84
84
|
return transport_kwargs
|
85
85
|
|
86
|
-
def _repr_step(self, rule: Rule, data: dict, result:
|
86
|
+
def _repr_step(self, rule: Rule, data: dict, result: StepOutput) -> str:
|
87
87
|
return ""
|
88
88
|
|
89
89
|
if config.override is not None:
|
@@ -96,38 +96,29 @@ def execute_state_machine_loop(
|
|
96
96
|
setattr(case, location, container)
|
97
97
|
return super().before_call(case)
|
98
98
|
|
99
|
-
def step(self,
|
99
|
+
def step(self, input: StepInput) -> StepOutput | None:
|
100
100
|
# Checking the stop event once inside `step` is sufficient as it is called frequently
|
101
101
|
# The idea is to stop the execution as soon as possible
|
102
|
-
if previous is not None:
|
103
|
-
step_result, _ = previous
|
104
|
-
self.recorder.record_case(parent_id=step_result.case.id, case=case)
|
105
|
-
else:
|
106
|
-
self.recorder.record_case(parent_id=None, case=case)
|
107
102
|
if engine.has_to_stop:
|
108
103
|
raise KeyboardInterrupt
|
109
|
-
step_started = events.StepStarted(
|
110
|
-
phase=PhaseName.STATEFUL_TESTING, suite_id=suite_id, scenario_id=self._scenario_id
|
111
|
-
)
|
112
|
-
event_queue.put(step_started)
|
113
104
|
try:
|
114
105
|
if config.execution.unique_inputs:
|
115
|
-
cached = ctx.get_step_outcome(case)
|
106
|
+
cached = ctx.get_step_outcome(input.case)
|
116
107
|
if isinstance(cached, BaseException):
|
117
108
|
raise cached
|
118
109
|
elif cached is None:
|
119
110
|
return None
|
120
|
-
result = super().step(
|
111
|
+
result = super().step(input)
|
121
112
|
ctx.step_succeeded()
|
122
113
|
except FailureGroup as exc:
|
123
114
|
if config.execution.unique_inputs:
|
124
115
|
for failure in exc.exceptions:
|
125
|
-
ctx.store_step_outcome(case, failure)
|
116
|
+
ctx.store_step_outcome(input.case, failure)
|
126
117
|
ctx.step_failed()
|
127
118
|
raise
|
128
119
|
except Exception as exc:
|
129
120
|
if config.execution.unique_inputs:
|
130
|
-
ctx.store_step_outcome(case, exc)
|
121
|
+
ctx.store_step_outcome(input.case, exc)
|
131
122
|
ctx.step_errored()
|
132
123
|
raise
|
133
124
|
except KeyboardInterrupt:
|
@@ -135,34 +126,11 @@ def execute_state_machine_loop(
|
|
135
126
|
raise
|
136
127
|
except BaseException as exc:
|
137
128
|
if config.execution.unique_inputs:
|
138
|
-
ctx.store_step_outcome(case, exc)
|
129
|
+
ctx.store_step_outcome(input.case, exc)
|
139
130
|
raise exc
|
140
131
|
else:
|
141
132
|
if config.execution.unique_inputs:
|
142
|
-
ctx.store_step_outcome(case, None)
|
143
|
-
finally:
|
144
|
-
transition_id: events.TransitionId | None
|
145
|
-
if previous is not None:
|
146
|
-
transition = previous[1]
|
147
|
-
transition_id = events.TransitionId(
|
148
|
-
name=transition.name,
|
149
|
-
status_code=transition.status_code,
|
150
|
-
source=transition.operation.label,
|
151
|
-
)
|
152
|
-
else:
|
153
|
-
transition_id = None
|
154
|
-
event_queue.put(
|
155
|
-
events.StepFinished(
|
156
|
-
id=step_started.id,
|
157
|
-
suite_id=suite_id,
|
158
|
-
scenario_id=self._scenario_id,
|
159
|
-
phase=PhaseName.STATEFUL_TESTING,
|
160
|
-
status=ctx.current_step_status,
|
161
|
-
transition_id=transition_id,
|
162
|
-
target=case.operation.label,
|
163
|
-
response=ctx.current_response,
|
164
|
-
)
|
165
|
-
)
|
133
|
+
ctx.store_step_outcome(input.case, None)
|
166
134
|
return result
|
167
135
|
|
168
136
|
def validate_response(
|
@@ -189,7 +157,7 @@ def execute_state_machine_loop(
|
|
189
157
|
id=self._scenario_id,
|
190
158
|
suite_id=suite_id,
|
191
159
|
phase=PhaseName.STATEFUL_TESTING,
|
192
|
-
|
160
|
+
label=None,
|
193
161
|
status=ctx.current_scenario_status or Status.SKIP,
|
194
162
|
recorder=self.recorder,
|
195
163
|
elapsed_time=time.monotonic() - self._start_time,
|
@@ -201,10 +169,7 @@ def execute_state_machine_loop(
|
|
201
169
|
ctx.reset_scenario()
|
202
170
|
super().teardown()
|
203
171
|
|
204
|
-
|
205
|
-
InstrumentedStateMachine = hypothesis.seed(config.execution.seed)(_InstrumentedStateMachine)
|
206
|
-
else:
|
207
|
-
InstrumentedStateMachine = _InstrumentedStateMachine
|
172
|
+
seed = config.execution.seed
|
208
173
|
|
209
174
|
while True:
|
210
175
|
# This loop is running until no new failures are found in a single iteration
|
@@ -222,8 +187,15 @@ def execute_state_machine_loop(
|
|
222
187
|
)
|
223
188
|
break
|
224
189
|
suite_status = Status.SUCCESS
|
190
|
+
if seed is not None:
|
191
|
+
InstrumentedStateMachine = hypothesis.seed(seed)(_InstrumentedStateMachine)
|
192
|
+
# Predictably change the seed to avoid re-running the same sequences if tests fail
|
193
|
+
# yet have reproducible results
|
194
|
+
seed += 1
|
195
|
+
else:
|
196
|
+
InstrumentedStateMachine = _InstrumentedStateMachine
|
225
197
|
try:
|
226
|
-
with ignore_hypothesis_output(): # type: ignore
|
198
|
+
with catch_warnings(), ignore_hypothesis_output(): # type: ignore
|
227
199
|
InstrumentedStateMachine.run(settings=config.execution.hypothesis_settings)
|
228
200
|
except KeyboardInterrupt:
|
229
201
|
# Raised in the state machine when the stop event is set or it is raised by the user's code
|
@@ -234,7 +206,7 @@ def execute_state_machine_loop(
|
|
234
206
|
event_queue.put(events.Interrupted(phase=PhaseName.STATEFUL_TESTING))
|
235
207
|
break
|
236
208
|
except unittest.case.SkipTest:
|
237
|
-
# If `explicit` phase is used and there are
|
209
|
+
# If `explicit` phase is used and there are no examples
|
238
210
|
suite_status = Status.SKIP
|
239
211
|
break
|
240
212
|
except FailureGroup as exc:
|
@@ -140,6 +140,7 @@ def worker_task(*, events_queue: Queue, producer: TaskProducer, ctx: EngineConte
|
|
140
140
|
id=scenario_started.id,
|
141
141
|
suite_id=suite_id,
|
142
142
|
phase=PhaseName.UNIT_TESTING,
|
143
|
+
label=label,
|
143
144
|
status=Status.ERROR,
|
144
145
|
recorder=ScenarioRecorder(label="Error"),
|
145
146
|
elapsed_time=0.0,
|
@@ -201,6 +201,7 @@ def run_test(
|
|
201
201
|
id=scenario_started.id,
|
202
202
|
suite_id=suite_id,
|
203
203
|
phase=PhaseName.UNIT_TESTING,
|
204
|
+
label=operation.label,
|
204
205
|
recorder=recorder,
|
205
206
|
status=status,
|
206
207
|
elapsed_time=test_elapsed_time,
|
@@ -263,7 +264,7 @@ def cached_test_func(f: Callable) -> Callable:
|
|
263
264
|
|
264
265
|
@cached_test_func
|
265
266
|
def test_func(*, ctx: EngineContext, case: Case, recorder: ScenarioRecorder) -> None:
|
266
|
-
recorder.record_case(parent_id=None, case=case)
|
267
|
+
recorder.record_case(parent_id=None, transition=None, case=case)
|
267
268
|
try:
|
268
269
|
response = case.call(**ctx.transport_kwargs)
|
269
270
|
except (requests.Timeout, requests.ConnectionError) as error:
|