schemathesis 4.0.0a1__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.
Files changed (35) hide show
  1. schemathesis/checks.py +6 -4
  2. schemathesis/cli/commands/run/__init__.py +4 -4
  3. schemathesis/cli/commands/run/events.py +4 -9
  4. schemathesis/cli/commands/run/executor.py +6 -3
  5. schemathesis/cli/commands/run/filters.py +27 -19
  6. schemathesis/cli/commands/run/handlers/base.py +1 -1
  7. schemathesis/cli/commands/run/handlers/cassettes.py +1 -3
  8. schemathesis/cli/commands/run/handlers/output.py +765 -143
  9. schemathesis/cli/commands/run/validation.py +1 -1
  10. schemathesis/cli/ext/options.py +4 -1
  11. schemathesis/core/failures.py +54 -24
  12. schemathesis/engine/core.py +1 -1
  13. schemathesis/engine/events.py +3 -97
  14. schemathesis/engine/phases/stateful/__init__.py +1 -0
  15. schemathesis/engine/phases/stateful/_executor.py +19 -44
  16. schemathesis/engine/phases/unit/__init__.py +1 -0
  17. schemathesis/engine/phases/unit/_executor.py +2 -1
  18. schemathesis/engine/phases/unit/_pool.py +1 -1
  19. schemathesis/engine/recorder.py +8 -3
  20. schemathesis/generation/stateful/state_machine.py +53 -36
  21. schemathesis/graphql/checks.py +3 -9
  22. schemathesis/openapi/checks.py +8 -33
  23. schemathesis/schemas.py +34 -14
  24. schemathesis/specs/graphql/schemas.py +16 -15
  25. schemathesis/specs/openapi/expressions/__init__.py +11 -15
  26. schemathesis/specs/openapi/expressions/nodes.py +20 -20
  27. schemathesis/specs/openapi/links.py +126 -119
  28. schemathesis/specs/openapi/schemas.py +18 -22
  29. schemathesis/specs/openapi/stateful/__init__.py +77 -55
  30. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a2.dist-info}/METADATA +1 -1
  31. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a2.dist-info}/RECORD +34 -35
  32. schemathesis/specs/openapi/expressions/context.py +0 -14
  33. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a2.dist-info}/WHEEL +0 -0
  34. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a2.dist-info}/entry_points.txt +0 -0
  35. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a2.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):
@@ -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.selected == 0:
72
72
  return Phase(
73
73
  name=phase_name,
74
74
  is_supported=True,
@@ -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."""
@@ -27,6 +27,7 @@ def execute(engine: EngineContext, phase: Phase) -> events.EventGenerator:
27
27
  thread = threading.Thread(
28
28
  target=execute_state_machine_loop,
29
29
  kwargs={"state_machine": state_machine, "event_queue": event_queue, "engine": engine},
30
+ name="schemathesis_stateful_tests",
30
31
  )
31
32
  status: Status | None = None
32
33
  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
 
@@ -83,7 +84,7 @@ def execute_state_machine_loop(
83
84
  def get_call_kwargs(self, case: Case) -> dict[str, Any]:
84
85
  return transport_kwargs
85
86
 
86
- def _repr_step(self, rule: Rule, data: dict, result: StepResult) -> str:
87
+ def _repr_step(self, rule: Rule, data: dict, result: StepOutput) -> str:
87
88
  return ""
88
89
 
89
90
  if config.override is not None:
@@ -96,38 +97,35 @@ def execute_state_machine_loop(
96
97
  setattr(case, location, container)
97
98
  return super().before_call(case)
98
99
 
99
- def step(self, case: Case, previous: tuple[StepResult, Direction] | None = None) -> StepResult | None:
100
+ def step(self, input: StepInput) -> StepOutput | None:
100
101
  # Checking the stop event once inside `step` is sufficient as it is called frequently
101
102
  # 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)
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
+ )
105
107
  else:
106
- self.recorder.record_case(parent_id=None, case=case)
108
+ self.recorder.record_case(parent_id=None, transition=None, case=input.case)
107
109
  if engine.has_to_stop:
108
110
  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
111
  try:
114
112
  if config.execution.unique_inputs:
115
- cached = ctx.get_step_outcome(case)
113
+ cached = ctx.get_step_outcome(input.case)
116
114
  if isinstance(cached, BaseException):
117
115
  raise cached
118
116
  elif cached is None:
119
117
  return None
120
- result = super().step(case, previous)
118
+ result = super().step(input)
121
119
  ctx.step_succeeded()
122
120
  except FailureGroup as exc:
123
121
  if config.execution.unique_inputs:
124
122
  for failure in exc.exceptions:
125
- ctx.store_step_outcome(case, failure)
123
+ ctx.store_step_outcome(input.case, failure)
126
124
  ctx.step_failed()
127
125
  raise
128
126
  except Exception as exc:
129
127
  if config.execution.unique_inputs:
130
- ctx.store_step_outcome(case, exc)
128
+ ctx.store_step_outcome(input.case, exc)
131
129
  ctx.step_errored()
132
130
  raise
133
131
  except KeyboardInterrupt:
@@ -135,34 +133,11 @@ def execute_state_machine_loop(
135
133
  raise
136
134
  except BaseException as exc:
137
135
  if config.execution.unique_inputs:
138
- ctx.store_step_outcome(case, exc)
136
+ ctx.store_step_outcome(input.case, exc)
139
137
  raise exc
140
138
  else:
141
139
  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
- )
140
+ ctx.store_step_outcome(input.case, None)
166
141
  return result
167
142
 
168
143
  def validate_response(
@@ -189,7 +164,7 @@ def execute_state_machine_loop(
189
164
  id=self._scenario_id,
190
165
  suite_id=suite_id,
191
166
  phase=PhaseName.STATEFUL_TESTING,
192
- # With dry run there will be no status
167
+ label=None,
193
168
  status=ctx.current_scenario_status or Status.SKIP,
194
169
  recorder=self.recorder,
195
170
  elapsed_time=time.monotonic() - self._start_time,
@@ -223,7 +198,7 @@ def execute_state_machine_loop(
223
198
  break
224
199
  suite_status = Status.SUCCESS
225
200
  try:
226
- with ignore_hypothesis_output(): # type: ignore
201
+ with catch_warnings(), ignore_hypothesis_output(): # type: ignore
227
202
  InstrumentedStateMachine.run(settings=config.execution.hypothesis_settings)
228
203
  except KeyboardInterrupt:
229
204
  # Raised in the state machine when the stop event is set or it is raised by the user's code
@@ -234,7 +209,7 @@ def execute_state_machine_loop(
234
209
  event_queue.put(events.Interrupted(phase=PhaseName.STATEFUL_TESTING))
235
210
  break
236
211
  except unittest.case.SkipTest:
237
- # If `explicit` phase is used and there are not examples
212
+ # If `explicit` phase is used and there are no examples
238
213
  suite_status = Status.SKIP
239
214
  break
240
215
  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)
@@ -14,6 +14,8 @@ from schemathesis.generation.case import Case
14
14
  if TYPE_CHECKING:
15
15
  import requests
16
16
 
17
+ from schemathesis.generation.stateful.state_machine import Transition
18
+
17
19
 
18
20
  @dataclass
19
21
  class ScenarioRecorder:
@@ -42,9 +44,9 @@ class ScenarioRecorder:
42
44
  self.checks = {}
43
45
  self.interactions = {}
44
46
 
45
- def record_case(self, *, parent_id: str | None, case: Case) -> None:
47
+ def record_case(self, *, parent_id: str | None, transition: Transition | None, case: Case) -> None:
46
48
  """Record a test case and its relationship to a parent, if applicable."""
47
- self.cases[case.id] = CaseNode(value=case, parent_id=parent_id)
49
+ self.cases[case.id] = CaseNode(value=case, parent_id=parent_id, transition=transition)
48
50
 
49
51
  def record_response(self, *, case_id: str, response: Response) -> None:
50
52
  """Record the API response for a given test case."""
@@ -133,8 +135,11 @@ class CaseNode:
133
135
 
134
136
  value: Case
135
137
  parent_id: str | None
138
+ # Transition may be absent if `parent_id` is present for cases when a case is derived inside a check
139
+ # and outside of the implemented transition logic (e.g. Open API links)
140
+ transition: Transition | None
136
141
 
137
- __slots__ = ("value", "parent_id")
142
+ __slots__ = ("value", "parent_id", "transition")
138
143
 
139
144
 
140
145
  @dataclass