schemathesis 4.0.0a2__py3-none-any.whl → 4.0.0a4__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 (47) hide show
  1. schemathesis/cli/__init__.py +15 -4
  2. schemathesis/cli/commands/run/__init__.py +148 -94
  3. schemathesis/cli/commands/run/context.py +72 -2
  4. schemathesis/cli/commands/run/events.py +22 -2
  5. schemathesis/cli/commands/run/executor.py +35 -12
  6. schemathesis/cli/commands/run/filters.py +1 -0
  7. schemathesis/cli/commands/run/handlers/cassettes.py +27 -46
  8. schemathesis/cli/commands/run/handlers/junitxml.py +1 -1
  9. schemathesis/cli/commands/run/handlers/output.py +180 -87
  10. schemathesis/cli/commands/run/hypothesis.py +30 -19
  11. schemathesis/cli/commands/run/reports.py +72 -0
  12. schemathesis/cli/commands/run/validation.py +18 -12
  13. schemathesis/cli/ext/groups.py +42 -13
  14. schemathesis/cli/ext/options.py +15 -8
  15. schemathesis/core/errors.py +85 -9
  16. schemathesis/core/failures.py +2 -1
  17. schemathesis/core/transforms.py +1 -1
  18. schemathesis/engine/core.py +1 -1
  19. schemathesis/engine/errors.py +17 -6
  20. schemathesis/engine/phases/stateful/__init__.py +1 -0
  21. schemathesis/engine/phases/stateful/_executor.py +9 -12
  22. schemathesis/engine/phases/unit/__init__.py +2 -3
  23. schemathesis/engine/phases/unit/_executor.py +16 -13
  24. schemathesis/engine/recorder.py +22 -21
  25. schemathesis/errors.py +23 -13
  26. schemathesis/filters.py +8 -0
  27. schemathesis/generation/coverage.py +10 -5
  28. schemathesis/generation/hypothesis/builder.py +15 -12
  29. schemathesis/generation/stateful/state_machine.py +57 -12
  30. schemathesis/pytest/lazy.py +2 -3
  31. schemathesis/pytest/plugin.py +2 -3
  32. schemathesis/schemas.py +1 -1
  33. schemathesis/specs/openapi/checks.py +77 -37
  34. schemathesis/specs/openapi/expressions/__init__.py +22 -6
  35. schemathesis/specs/openapi/expressions/nodes.py +15 -21
  36. schemathesis/specs/openapi/expressions/parser.py +1 -1
  37. schemathesis/specs/openapi/parameters.py +0 -2
  38. schemathesis/specs/openapi/patterns.py +170 -2
  39. schemathesis/specs/openapi/schemas.py +67 -39
  40. schemathesis/specs/openapi/stateful/__init__.py +207 -84
  41. schemathesis/specs/openapi/stateful/control.py +87 -0
  42. schemathesis/specs/openapi/{links.py → stateful/links.py} +72 -14
  43. {schemathesis-4.0.0a2.dist-info → schemathesis-4.0.0a4.dist-info}/METADATA +1 -1
  44. {schemathesis-4.0.0a2.dist-info → schemathesis-4.0.0a4.dist-info}/RECORD +47 -45
  45. {schemathesis-4.0.0a2.dist-info → schemathesis-4.0.0a4.dist-info}/WHEEL +0 -0
  46. {schemathesis-4.0.0a2.dist-info → schemathesis-4.0.0a4.dist-info}/entry_points.txt +0 -0
  47. {schemathesis-4.0.0a2.dist-info → schemathesis-4.0.0a4.dist-info}/licenses/LICENSE +0 -0
@@ -123,7 +123,7 @@ def worker_task(*, events_queue: Queue, producer: TaskProducer, ctx: EngineConte
123
123
  else:
124
124
  error = result.err()
125
125
  if error.method:
126
- label = f"{error.method.upper()} {error.full_path}"
126
+ label = f"{error.method.upper()} {error.path}"
127
127
  scenario_started = events.ScenarioStarted(
128
128
  label=label, phase=PhaseName.UNIT_TESTING, suite_id=suite_id
129
129
  )
@@ -149,12 +149,11 @@ def worker_task(*, events_queue: Queue, producer: TaskProducer, ctx: EngineConte
149
149
  )
150
150
  )
151
151
  else:
152
- assert error.full_path is not None
153
152
  events_queue.put(
154
153
  events.NonFatalError(
155
154
  error=error,
156
155
  phase=PhaseName.UNIT_TESTING,
157
- label=error.full_path,
156
+ label=error.path,
158
157
  related_to_operation=False,
159
158
  )
160
159
  )
@@ -70,6 +70,19 @@ def run_test(
70
70
  error=error, phase=PhaseName.UNIT_TESTING, label=operation.label, related_to_operation=True
71
71
  )
72
72
 
73
+ def scenario_finished(status: Status) -> events.ScenarioFinished:
74
+ return events.ScenarioFinished(
75
+ id=scenario_started.id,
76
+ suite_id=suite_id,
77
+ phase=PhaseName.UNIT_TESTING,
78
+ label=operation.label,
79
+ recorder=recorder,
80
+ status=status,
81
+ elapsed_time=time.monotonic() - test_start_time,
82
+ skip_reason=skip_reason,
83
+ is_final=False,
84
+ )
85
+
73
86
  try:
74
87
  setup_hypothesis_database_key(test_function, operation)
75
88
  with catch_warnings(record=True) as warnings, ignore_hypothesis_output():
@@ -111,6 +124,7 @@ def run_test(
111
124
  status = Status.ERROR
112
125
  yield non_fatal_error(hypothesis.errors.Unsatisfiable("Failed to generate test cases for this API operation"))
113
126
  except KeyboardInterrupt:
127
+ yield scenario_finished(Status.INTERRUPTED)
114
128
  yield events.Interrupted(phase=PhaseName.UNIT_TESTING)
115
129
  return
116
130
  except AssertionError as exc: # May come from `hypothesis-jsonschema` or `hypothesis`
@@ -131,7 +145,6 @@ def run_test(
131
145
  exc,
132
146
  path=operation.path,
133
147
  method=operation.method,
134
- full_path=operation.schema.get_full_path(operation.path),
135
148
  )
136
149
  )
137
150
  except HypothesisRefResolutionError:
@@ -194,20 +207,10 @@ def run_test(
194
207
  if invalid_headers:
195
208
  status = Status.ERROR
196
209
  yield non_fatal_error(InvalidHeadersExample.from_headers(invalid_headers))
197
- test_elapsed_time = time.monotonic() - test_start_time
198
210
  for error in deduplicate_errors(errors):
199
211
  yield non_fatal_error(error)
200
- yield events.ScenarioFinished(
201
- id=scenario_started.id,
202
- suite_id=suite_id,
203
- phase=PhaseName.UNIT_TESTING,
204
- label=operation.label,
205
- recorder=recorder,
206
- status=status,
207
- elapsed_time=test_elapsed_time,
208
- skip_reason=skip_reason,
209
- is_final=False,
210
- )
212
+
213
+ yield scenario_finished(status)
211
214
 
212
215
 
213
216
  def setup_hypothesis_database_key(test: Callable, operation: APIOperation) -> None:
@@ -2,7 +2,6 @@ from __future__ import annotations
2
2
 
3
3
  import base64
4
4
  import time
5
- import uuid
6
5
  from dataclasses import dataclass
7
6
  from typing import TYPE_CHECKING, Iterator, cast
8
7
 
@@ -24,7 +23,6 @@ class ScenarioRecorder:
24
23
  Records test cases, their hierarchy, API interactions, and results of checks performed during execution.
25
24
  """
26
25
 
27
- id: uuid.UUID
28
26
  # Human-readable label
29
27
  label: str
30
28
 
@@ -35,10 +33,9 @@ class ScenarioRecorder:
35
33
  # Network interactions by test case ID
36
34
  interactions: dict[str, Interaction]
37
35
 
38
- __slots__ = ("id", "label", "status", "roots", "cases", "checks", "interactions")
36
+ __slots__ = ("label", "status", "roots", "cases", "checks", "interactions")
39
37
 
40
38
  def __init__(self, *, label: str) -> None:
41
- self.id = uuid.uuid4()
42
39
  self.label = label
43
40
  self.cases = {}
44
41
  self.checks = {}
@@ -96,30 +93,34 @@ class ScenarioRecorder:
96
93
  return None
97
94
 
98
95
  def find_related(self, *, case_id: str) -> Iterator[Case]:
99
- """Iterate over all ancestors and their children for a given case."""
100
- current_id = case_id
101
- seen = {current_id}
96
+ """Iterate over all cases in the tree, starting from the root."""
97
+ seen = {case_id}
102
98
 
99
+ # First, find the root by going up
100
+ current_id = case_id
103
101
  while True:
104
102
  current_node = self.cases.get(current_id)
105
103
  if current_node is None or current_node.parent_id is None:
104
+ root_id = current_id
106
105
  break
106
+ current_id = current_node.parent_id
107
107
 
108
- # Get all children of the parent (siblings of the current case)
109
- parent_id = current_node.parent_id
110
- for case_id, maybe_child in self.cases.items():
111
- # If this case has the same parent and we haven't seen it yet
112
- if parent_id == maybe_child.parent_id and case_id not in seen:
108
+ # Then traverse the whole tree from root
109
+ def traverse(node_id: str) -> Iterator[Case]:
110
+ # Get all children
111
+ for case_id, node in self.cases.items():
112
+ if node.parent_id == node_id and case_id not in seen:
113
113
  seen.add(case_id)
114
- yield maybe_child.value
115
-
116
- # Move up to the parent
117
- current_id = parent_id
118
- if current_id not in seen:
119
- seen.add(current_id)
120
- parent_node = self.cases.get(current_id)
121
- if parent_node:
122
- yield parent_node.value
114
+ yield node.value
115
+ # Recurse into children
116
+ yield from traverse(case_id)
117
+
118
+ # Start traversal from root
119
+ root_node = self.cases.get(root_id)
120
+ if root_node and root_id not in seen:
121
+ seen.add(root_id)
122
+ yield root_node.value
123
+ yield from traverse(root_id)
123
124
 
124
125
  def find_response(self, *, case_id: str) -> Response | None:
125
126
  """Retrieve the API response for a given test case, if available."""
schemathesis/errors.py CHANGED
@@ -1,18 +1,24 @@
1
1
  """Public Schemathesis errors."""
2
2
 
3
- from schemathesis.core.errors import IncorrectUsage as IncorrectUsage
4
- from schemathesis.core.errors import InternalError as InternalError
5
- from schemathesis.core.errors import InvalidHeadersExample as InvalidHeadersExample
6
- from schemathesis.core.errors import InvalidRateLimit as InvalidRateLimit
7
- from schemathesis.core.errors import InvalidRegexPattern as InvalidRegexPattern
8
- from schemathesis.core.errors import InvalidRegexType as InvalidRegexType
9
- from schemathesis.core.errors import InvalidSchema as InvalidSchema
10
- from schemathesis.core.errors import LoaderError as LoaderError
11
- from schemathesis.core.errors import OperationNotFound as OperationNotFound
12
- from schemathesis.core.errors import SchemathesisError as SchemathesisError
13
- from schemathesis.core.errors import SerializationError as SerializationError
14
- from schemathesis.core.errors import SerializationNotPossible as SerializationNotPossible
15
- from schemathesis.core.errors import UnboundPrefix as UnboundPrefix
3
+ from schemathesis.core.errors import (
4
+ IncorrectUsage,
5
+ InternalError,
6
+ InvalidHeadersExample,
7
+ InvalidRateLimit,
8
+ InvalidRegexPattern,
9
+ InvalidRegexType,
10
+ InvalidSchema,
11
+ InvalidStateMachine,
12
+ InvalidTransition,
13
+ LoaderError,
14
+ NoLinksFound,
15
+ OperationNotFound,
16
+ SchemathesisError,
17
+ SerializationError,
18
+ SerializationNotPossible,
19
+ TransitionValidationError,
20
+ UnboundPrefix,
21
+ )
16
22
 
17
23
  __all__ = [
18
24
  "IncorrectUsage",
@@ -22,10 +28,14 @@ __all__ = [
22
28
  "InvalidRegexPattern",
23
29
  "InvalidRegexType",
24
30
  "InvalidSchema",
31
+ "InvalidStateMachine",
32
+ "InvalidTransition",
25
33
  "LoaderError",
26
34
  "OperationNotFound",
35
+ "NoLinksFound",
27
36
  "SchemathesisError",
28
37
  "SerializationError",
29
38
  "SerializationNotPossible",
39
+ "TransitionValidationError",
30
40
  "UnboundPrefix",
31
41
  ]
schemathesis/filters.py CHANGED
@@ -268,6 +268,8 @@ class FilterSet:
268
268
  # To match anything the regex should match the expected value, hence passing them together is useless
269
269
  raise IncorrectUsage(ERROR_EXPECTED_AND_REGEX)
270
270
  if expected is not None:
271
+ if attribute == "method":
272
+ expected = _normalize_method(expected)
271
273
  matchers.append(Matcher.for_value(attribute, expected))
272
274
  if regex is not None:
273
275
  matchers.append(Matcher.for_regex(attribute, regex))
@@ -283,6 +285,12 @@ class FilterSet:
283
285
  self._excludes.add(filter_)
284
286
 
285
287
 
288
+ def _normalize_method(value: FilterValue) -> FilterValue:
289
+ if isinstance(value, list):
290
+ return [item.upper() for item in value]
291
+ return value.upper()
292
+
293
+
286
294
  def attach_filter_chain(
287
295
  target: Callable,
288
296
  attribute: str,
@@ -194,6 +194,10 @@ class CoverageContext:
194
194
  re.compile(pattern)
195
195
  except re.error:
196
196
  raise Unsatisfiable from None
197
+ if "minLength" in schema or "maxLength" in schema:
198
+ min_length = schema.get("minLength")
199
+ max_length = schema.get("maxLength")
200
+ pattern = update_quantifier(pattern, min_length, max_length)
197
201
  return cached_draw(st.from_regex(pattern))
198
202
  if (keys == ["items", "type"] or keys == ["items", "minItems", "type"]) and isinstance(schema["items"], dict):
199
203
  items = schema["items"]
@@ -433,7 +437,12 @@ def cover_schema_iter(
433
437
  elif key == "required":
434
438
  template = template or ctx.generate_from_schema(_get_template_schema(schema, "object"))
435
439
  yield from _negative_required(ctx, template, value)
436
- elif key == "additionalProperties" and not value and "pattern" not in schema:
440
+ elif (
441
+ key == "additionalProperties"
442
+ and not value
443
+ and "pattern" not in schema
444
+ and schema.get("type") in ["object", None]
445
+ ):
437
446
  template = template or ctx.generate_from_schema(_get_template_schema(schema, "object"))
438
447
  yield NegativeValue(
439
448
  {**template, UNKNOWN_PROPERTY_KEY: UNKNOWN_PROPERTY_VALUE},
@@ -514,11 +523,7 @@ def _positive_string(ctx: CoverageContext, schema: dict) -> Generator[GeneratedV
514
523
  # Default positive value
515
524
  yield PositiveValue(ctx.generate_from_schema(schema), description="Valid string")
516
525
  elif "pattern" in schema:
517
- # Without merging `maxLength` & `minLength` into a regex it is problematic
518
- # to generate a valid value as the unredlying machinery will resort to filtering
519
- # and it is unlikely that it will generate a string of that length
520
526
  yield PositiveValue(ctx.generate_from_schema(schema), description="Valid string")
521
- return
522
527
 
523
528
  seen = set()
524
529
 
@@ -386,19 +386,22 @@ def _iter_coverage_cases(
386
386
  container = template["query"]
387
387
  for parameter in operation.query:
388
388
  instant = Instant()
389
- value = container[parameter.name]
390
- yield operation.Case(
391
- **{**template, "query": {**container, parameter.name: [value, value]}},
392
- meta=CaseMetadata(
393
- generation=GenerationInfo(time=instant.elapsed, mode=GenerationMode.NEGATIVE),
394
- components={},
395
- phase=PhaseInfo.coverage(
396
- description=f"Duplicate `{parameter.name}` query parameter",
397
- parameter=parameter.name,
398
- parameter_location="query",
389
+ # Could be absent if value schema can't be negated
390
+ # I.e. contains just `default` value without any other keywords
391
+ value = container.get(parameter.name, NOT_SET)
392
+ if value is not NOT_SET:
393
+ yield operation.Case(
394
+ **{**template, "query": {**container, parameter.name: [value, value]}},
395
+ meta=CaseMetadata(
396
+ generation=GenerationInfo(time=instant.elapsed, mode=GenerationMode.NEGATIVE),
397
+ components={},
398
+ phase=PhaseInfo.coverage(
399
+ description=f"Duplicate `{parameter.name}` query parameter",
400
+ parameter=parameter.name,
401
+ parameter_location="query",
402
+ ),
399
403
  ),
400
- ),
401
- )
404
+ )
402
405
  # Generate missing required parameters
403
406
  for parameter in operation.iter_parameters():
404
407
  if parameter.is_required and parameter.location != "path":
@@ -10,7 +10,7 @@ from hypothesis.errors import InvalidDefinition
10
10
  from hypothesis.stateful import RuleBasedStateMachine
11
11
 
12
12
  from schemathesis.checks import CheckFunction
13
- from schemathesis.core.errors import IncorrectUsage
13
+ from schemathesis.core.errors import NoLinksFound
14
14
  from schemathesis.core.result import Result
15
15
  from schemathesis.core.transport import Response
16
16
  from schemathesis.generation.case import Case
@@ -22,16 +22,11 @@ if TYPE_CHECKING:
22
22
  from schemathesis.schemas import BaseSchema
23
23
 
24
24
 
25
- NO_LINKS_ERROR_MESSAGE = (
26
- "Stateful testing requires at least one OpenAPI link in the schema, but no links detected. "
27
- "Please add OpenAPI links to enable stateful testing or use stateless tests instead. \n"
28
- "See https://schemathesis.readthedocs.io/en/stable/stateful.html#how-to-specify-connections for more information."
29
- )
30
-
25
+ DEFAULT_STATEFUL_STEP_COUNT = 6
31
26
  DEFAULT_STATE_MACHINE_SETTINGS = hypothesis.settings(
32
27
  phases=[hypothesis.Phase.generate],
33
28
  deadline=None,
34
- stateful_step_count=6,
29
+ stateful_step_count=DEFAULT_STATEFUL_STEP_COUNT,
35
30
  suppress_health_check=list(hypothesis.HealthCheck),
36
31
  )
37
32
 
@@ -73,6 +68,52 @@ class ExtractedParam:
73
68
  __slots__ = ("definition", "value")
74
69
 
75
70
 
71
+ @dataclass
72
+ class ExtractionFailure:
73
+ """Represents a failure to extract data from a transition."""
74
+
75
+ # e.g., "GetUser"
76
+ id: str
77
+ case_id: str
78
+ # e.g., "POST /users"
79
+ source: str
80
+ # e.g., "GET /users/{userId}"
81
+ target: str
82
+ # e.g., "userId"
83
+ parameter_name: str
84
+ # e.g., "$response.body#/id"
85
+ expression: str
86
+ # Previous test cases in the chain, from newest to oldest
87
+ # Stored as a case + response pair
88
+ history: list[tuple[Case, Response]]
89
+ # The actual response that caused the failure
90
+ response: Response
91
+ error: Exception | None
92
+
93
+ __slots__ = ("id", "case_id", "source", "target", "parameter_name", "expression", "history", "response", "error")
94
+
95
+ def __eq__(self, other: object) -> bool:
96
+ assert isinstance(other, ExtractionFailure)
97
+ return (
98
+ self.source == other.source
99
+ and self.target == other.target
100
+ and self.id == other.id
101
+ and self.parameter_name == other.parameter_name
102
+ and self.expression == other.expression
103
+ )
104
+
105
+ def __hash__(self) -> int:
106
+ return hash(
107
+ (
108
+ self.source,
109
+ self.target,
110
+ self.id,
111
+ self.parameter_name,
112
+ self.expression,
113
+ )
114
+ )
115
+
116
+
76
117
  @dataclass
77
118
  class StepOutput:
78
119
  """Output from a single transition of a state machine."""
@@ -104,7 +145,11 @@ class APIStateMachine(RuleBasedStateMachine):
104
145
  super().__init__() # type: ignore
105
146
  except InvalidDefinition as exc:
106
147
  if "defines no rules" in str(exc):
107
- raise IncorrectUsage(NO_LINKS_ERROR_MESSAGE) from None
148
+ if not self.schema.statistic.links.total:
149
+ message = "Schema contains no link definitions required for stateful testing"
150
+ else:
151
+ message = "All link definitions required for stateful testing are excluded by filters"
152
+ raise NoLinksFound(message) from None
108
153
  raise
109
154
  self.setup()
110
155
 
@@ -173,7 +218,7 @@ class APIStateMachine(RuleBasedStateMachine):
173
218
  kwargs = self.get_call_kwargs(input.case)
174
219
  response = self.call(input.case, **kwargs)
175
220
  self.after_call(response, input.case)
176
- self.validate_response(response, input.case)
221
+ self.validate_response(response, input.case, **kwargs)
177
222
  return StepOutput(response, input.case)
178
223
 
179
224
  def before_call(self, case: Case) -> None:
@@ -267,7 +312,7 @@ class APIStateMachine(RuleBasedStateMachine):
267
312
  return {}
268
313
 
269
314
  def validate_response(
270
- self, response: Response, case: Case, additional_checks: list[CheckFunction] | None = None
315
+ self, response: Response, case: Case, additional_checks: list[CheckFunction] | None = None, **kwargs: Any
271
316
  ) -> None:
272
317
  """Validate an API response.
273
318
 
@@ -299,4 +344,4 @@ class APIStateMachine(RuleBasedStateMachine):
299
344
  all provided checks rather than only the first encountered exception.
300
345
  """
301
346
  __tracebackhide__ = True
302
- case.validate_response(response, additional_checks=additional_checks)
347
+ case.validate_response(response, additional_checks=additional_checks, transport_kwargs=kwargs)
@@ -168,7 +168,7 @@ class LazySchema:
168
168
  for result in tests:
169
169
  if isinstance(result, Ok):
170
170
  operation, sub_test = result.ok()
171
- subtests.item._nodeid = f"{node_id}[{operation.method.upper()} {operation.full_path}]"
171
+ subtests.item._nodeid = f"{node_id}[{operation.method.upper()} {operation.path}]"
172
172
  run_subtest(operation, fixtures, sub_test, subtests)
173
173
  else:
174
174
  _schema_error(subtests, result.err(), node_id)
@@ -236,8 +236,7 @@ SEPARATOR = "\n===================="
236
236
  def _schema_error(subtests: SubTests, error: InvalidSchema, node_id: str) -> None:
237
237
  """Run a failing test, that will show the underlying problem."""
238
238
  sub_test = error.as_failing_test_function()
239
- # `full_path` is always available in this case
240
- kwargs = {"path": error.full_path}
239
+ kwargs = {"path": error.path}
241
240
  if error.method:
242
241
  kwargs["method"] = error.method.upper()
243
242
  subtests.item._nodeid = _get_partial_node_name(node_id, **kwargs)
@@ -149,11 +149,10 @@ class SchemathesisCase(PyCollector):
149
149
  error = result.err()
150
150
  funcobj = error.as_failing_test_function()
151
151
  name = self.name
152
- # `full_path` is always available in this case
153
152
  if error.method:
154
- name += f"[{error.method.upper()} {error.full_path}]"
153
+ name += f"[{error.method.upper()} {error.path}]"
155
154
  else:
156
- name += f"[{error.full_path}]"
155
+ name += f"[{error.path}]"
157
156
 
158
157
  cls = self._get_class_parent()
159
158
  definition: FunctionDefinition = FunctionDefinition.from_parent(
schemathesis/schemas.py CHANGED
@@ -619,7 +619,7 @@ class APIOperation(Generic[P]):
619
619
 
620
620
  def __post_init__(self) -> None:
621
621
  if self.label is None:
622
- self.label = f"{self.method.upper()} {self.full_path}" # type: ignore
622
+ self.label = f"{self.method.upper()} {self.path}" # type: ignore
623
623
 
624
624
  @property
625
625
  def full_path(self) -> str: