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.
Files changed (44) hide show
  1. schemathesis/checks.py +6 -4
  2. schemathesis/cli/__init__.py +12 -1
  3. schemathesis/cli/commands/run/__init__.py +4 -4
  4. schemathesis/cli/commands/run/events.py +19 -4
  5. schemathesis/cli/commands/run/executor.py +9 -3
  6. schemathesis/cli/commands/run/filters.py +27 -19
  7. schemathesis/cli/commands/run/handlers/base.py +1 -1
  8. schemathesis/cli/commands/run/handlers/cassettes.py +1 -3
  9. schemathesis/cli/commands/run/handlers/output.py +860 -201
  10. schemathesis/cli/commands/run/validation.py +1 -1
  11. schemathesis/cli/ext/options.py +4 -1
  12. schemathesis/core/errors.py +8 -0
  13. schemathesis/core/failures.py +54 -24
  14. schemathesis/engine/core.py +1 -1
  15. schemathesis/engine/errors.py +11 -5
  16. schemathesis/engine/events.py +3 -97
  17. schemathesis/engine/phases/stateful/__init__.py +2 -0
  18. schemathesis/engine/phases/stateful/_executor.py +22 -50
  19. schemathesis/engine/phases/unit/__init__.py +1 -0
  20. schemathesis/engine/phases/unit/_executor.py +2 -1
  21. schemathesis/engine/phases/unit/_pool.py +1 -1
  22. schemathesis/engine/recorder.py +29 -23
  23. schemathesis/errors.py +19 -13
  24. schemathesis/generation/coverage.py +4 -4
  25. schemathesis/generation/hypothesis/builder.py +15 -12
  26. schemathesis/generation/stateful/state_machine.py +61 -45
  27. schemathesis/graphql/checks.py +3 -9
  28. schemathesis/openapi/checks.py +8 -33
  29. schemathesis/schemas.py +34 -14
  30. schemathesis/specs/graphql/schemas.py +16 -15
  31. schemathesis/specs/openapi/checks.py +50 -27
  32. schemathesis/specs/openapi/expressions/__init__.py +11 -15
  33. schemathesis/specs/openapi/expressions/nodes.py +20 -20
  34. schemathesis/specs/openapi/links.py +139 -118
  35. schemathesis/specs/openapi/patterns.py +170 -2
  36. schemathesis/specs/openapi/schemas.py +60 -36
  37. schemathesis/specs/openapi/stateful/__init__.py +185 -113
  38. schemathesis/specs/openapi/stateful/control.py +87 -0
  39. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/METADATA +1 -1
  40. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/RECORD +43 -43
  41. schemathesis/specs/openapi/expressions/context.py +0 -14
  42. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/WHEEL +0 -0
  43. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/entry_points.txt +0 -0
  44. {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 convert_checks(ctx: click.core.Context, param: click.core.Parameter, value: tuple[list[str]]) -> list[str]:
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
 
@@ -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
- return [item for item in value.split(",") if item]
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):
@@ -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
 
@@ -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", "code", "case_id", "severity")
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) -> str:
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", "code", "case_id", "severity")
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", "code", "case_id", "severity")
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) -> str:
226
+ def _unique_key(self) -> Any:
197
227
  return self.title
198
228
 
199
229
  @classmethod
@@ -68,7 +68,7 @@ class Engine:
68
68
  skip_reason=PhaseSkipReason.DISABLED,
69
69
  )
70
70
 
71
- if requires_links and self.schema.links_count == 0:
71
+ if requires_links and self.schema.statistic.links.total == 0:
72
72
  return Phase(
73
73
  name=phase_name,
74
74
  is_supported=True,
@@ -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
- # Title
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
@@ -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
- Direction,
29
- StepResult,
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: StepResult) -> str:
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, case: Case, previous: tuple[StepResult, Direction] | None = None) -> StepResult | None:
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(case, previous)
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
- # With dry run there will be no status
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
- if config.execution.seed is not None:
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 not examples
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:
@@ -55,7 +55,7 @@ class WorkerPool:
55
55
  "producer": self.producer,
56
56
  "suite_id": self.suite_id,
57
57
  },
58
- name=f"schemathesis_{i}",
58
+ name=f"schemathesis_unit_tests_{i}",
59
59
  daemon=True,
60
60
  )
61
61
  self.workers.append(worker)