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
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from enum import IntEnum, unique
3
+ from enum import Enum, unique
4
4
  from typing import TYPE_CHECKING, Any
5
5
 
6
6
  import click
@@ -16,12 +16,11 @@ HYPOTHESIS_IN_MEMORY_DATABASE_IDENTIFIER = ":memory:"
16
16
 
17
17
 
18
18
  @unique
19
- class Phase(IntEnum):
20
- explicit = 0 #: controls whether explicit examples are run.
21
- reuse = 1 #: controls whether previous examples will be reused.
22
- generate = 2 #: controls whether new examples will be generated.
23
- target = 3 #: controls whether examples will be mutated for targeting.
24
- shrink = 4 #: controls whether examples will be shrunk.
19
+ class Phase(str, Enum):
20
+ explicit = "explicit" #: controls whether explicit examples are run.
21
+ reuse = "reuse" #: controls whether previous examples will be reused.
22
+ generate = "generate" #: controls whether new examples will be generated.
23
+ target = "target" #: controls whether examples will be mutated for targeting.
25
24
  # The `explain` phase is not supported
26
25
 
27
26
  def as_hypothesis(self) -> hypothesis.Phase:
@@ -30,20 +29,23 @@ class Phase(IntEnum):
30
29
  return Phase[self.name]
31
30
 
32
31
  @staticmethod
33
- def filter_from_all(variants: list[Phase]) -> list[hypothesis.Phase]:
32
+ def filter_from_all(variants: list[Phase], no_shrink: bool) -> list[hypothesis.Phase]:
34
33
  from hypothesis import Phase
35
34
 
36
- return list(set(Phase) - {Phase.explain} - set(variants))
35
+ phases = set(Phase) - {Phase.explain} - set(variants)
36
+ if no_shrink:
37
+ return list(phases - {Phase.shrink})
38
+ return list(phases)
37
39
 
38
40
 
39
41
  @unique
40
- class HealthCheck(IntEnum):
42
+ class HealthCheck(str, Enum):
41
43
  # We remove not relevant checks
42
- data_too_large = 1
43
- filter_too_much = 2
44
- too_slow = 3
45
- large_base_example = 7
46
- all = 8
44
+ data_too_large = "data_too_large"
45
+ filter_too_much = "filter_too_much"
46
+ too_slow = "too_slow"
47
+ large_base_example = "large_base_example"
48
+ all = "all"
47
49
 
48
50
  def as_hypothesis(self) -> list[hypothesis.HealthCheck]:
49
51
  from hypothesis import HealthCheck
@@ -64,14 +66,23 @@ def prepare_health_checks(
64
66
 
65
67
 
66
68
  def prepare_phases(
67
- hypothesis_phases: list[Phase] | None, hypothesis_no_phases: list[Phase] | None
69
+ hypothesis_phases: list[Phase] | None,
70
+ hypothesis_no_phases: list[Phase] | None,
71
+ no_shrink: bool = False,
68
72
  ) -> list[hypothesis.Phase] | None:
73
+ from hypothesis import Phase as HypothesisPhase
74
+
69
75
  if hypothesis_phases is not None and hypothesis_no_phases is not None:
70
76
  raise click.UsageError(PHASES_INVALID_USAGE_MESSAGE)
71
77
  if hypothesis_phases:
72
- return [phase.as_hypothesis() for phase in hypothesis_phases]
73
- if hypothesis_no_phases:
74
- return Phase.filter_from_all(hypothesis_no_phases)
78
+ phases = [phase.as_hypothesis() for phase in hypothesis_phases]
79
+ if not no_shrink:
80
+ phases.append(HypothesisPhase.shrink)
81
+ return phases
82
+ elif hypothesis_no_phases:
83
+ return Phase.filter_from_all(hypothesis_no_phases, no_shrink)
84
+ elif no_shrink:
85
+ return Phase.filter_from_all([], no_shrink)
75
86
  return None
76
87
 
77
88
 
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+ from pathlib import Path
5
+
6
+ from click.utils import LazyFile
7
+
8
+ DEFAULT_REPORT_DIRECTORY = Path("./schemathesis-report")
9
+
10
+
11
+ class ReportFormat(str, Enum):
12
+ """Available report formats."""
13
+
14
+ JUNIT = "junit"
15
+ VCR = "vcr"
16
+ HAR = "har"
17
+
18
+ @property
19
+ def extension(self) -> str:
20
+ """File extension for this format."""
21
+ return {
22
+ self.JUNIT: "xml",
23
+ self.VCR: "yaml",
24
+ self.HAR: "json",
25
+ }[self]
26
+
27
+
28
+ class ReportConfig:
29
+ """Configuration for test report generation."""
30
+
31
+ __slots__ = (
32
+ "formats",
33
+ "directory",
34
+ "junit_path",
35
+ "vcr_path",
36
+ "har_path",
37
+ "preserve_bytes",
38
+ "sanitize_output",
39
+ )
40
+
41
+ def __init__(
42
+ self,
43
+ formats: list[ReportFormat] | None = None,
44
+ directory: Path = DEFAULT_REPORT_DIRECTORY,
45
+ *,
46
+ junit_path: LazyFile | None = None,
47
+ vcr_path: LazyFile | None = None,
48
+ har_path: LazyFile | None = None,
49
+ preserve_bytes: bool = False,
50
+ sanitize_output: bool = True,
51
+ ) -> None:
52
+ self.formats = formats or []
53
+ # Auto-enable formats when paths are specified
54
+ if junit_path and ReportFormat.JUNIT not in self.formats:
55
+ self.formats.append(ReportFormat.JUNIT)
56
+ if vcr_path and ReportFormat.VCR not in self.formats:
57
+ self.formats.append(ReportFormat.VCR)
58
+ if har_path and ReportFormat.HAR not in self.formats:
59
+ self.formats.append(ReportFormat.HAR)
60
+ self.directory = directory
61
+ self.junit_path = junit_path
62
+ self.vcr_path = vcr_path
63
+ self.har_path = har_path
64
+ self.preserve_bytes = preserve_bytes
65
+ self.sanitize_output = sanitize_output
66
+
67
+ def get_path(self, format: ReportFormat) -> LazyFile:
68
+ """Get the final path for a specific format."""
69
+ custom_path = getattr(self, f"{format.value}_path")
70
+ if custom_path is not None:
71
+ return custom_path
72
+ return LazyFile(self.directory / f"{format.value}.{format.extension}", mode="w", encoding="utf-8")
@@ -13,7 +13,7 @@ from urllib.parse import urlparse
13
13
  import click
14
14
 
15
15
  from schemathesis import errors, experimental
16
- from schemathesis.cli.commands.run.handlers.cassettes import CassetteFormat
16
+ from schemathesis.cli.commands.run.reports import ReportFormat
17
17
  from schemathesis.cli.constants import DEFAULT_WORKERS
18
18
  from schemathesis.core import rate_limit, string_to_boolean
19
19
  from schemathesis.core.fs import file_exists
@@ -24,8 +24,10 @@ from schemathesis.generation.overrides import Override
24
24
  INVALID_DERANDOMIZE_MESSAGE = (
25
25
  "`--generation-deterministic` implies no database, so passing `--generation-database` too is invalid."
26
26
  )
27
- MISSING_CASSETTE_PATH_ARGUMENT_MESSAGE = (
28
- "Missing argument, `--cassette-path` should be specified as well if you use `--cassette-preserve-exact-body-bytes`."
27
+ INVALID_REPORT_USAGE = (
28
+ "Can't use `--report-preserve-bytes` without enabling cassette formats. "
29
+ "Enable VCR or HAR format with `--report=vcr`, `--report-vcr-path`, "
30
+ "`--report=har`, or `--report-har-path`"
29
31
  )
30
32
  INVALID_SCHEMA_MESSAGE = "Invalid SCHEMA, must be a valid URL or file path."
31
33
  FILE_DOES_NOT_EXIST_MESSAGE = "The specified file does not exist. Please provide a valid path to an existing file."
@@ -33,7 +35,7 @@ INVALID_BASE_URL_MESSAGE = (
33
35
  "The provided base URL is invalid. This URL serves as a prefix for all API endpoints you want to test. "
34
36
  "Make sure it is a properly formatted URL."
35
37
  )
36
- MISSING_BASE_URL_MESSAGE = "The `--base-url` option is required when specifying a schema via a file."
38
+ MISSING_BASE_URL_MESSAGE = "The `--url` option is required when specifying a schema via a file."
37
39
  MISSING_REQUEST_CERT_MESSAGE = "The `--request-cert` option must be specified if `--request-cert-key` is used."
38
40
 
39
41
 
@@ -240,10 +242,18 @@ def validate_request_cert_key(
240
242
  return raw_value
241
243
 
242
244
 
243
- def validate_preserve_exact_body_bytes(ctx: click.core.Context, param: click.core.Parameter, raw_value: bool) -> bool:
244
- if raw_value and ctx.params["cassette_path"] is None:
245
- raise click.UsageError(MISSING_CASSETTE_PATH_ARGUMENT_MESSAGE)
246
- return raw_value
245
+ def validate_preserve_bytes(ctx: click.core.Context, param: click.core.Parameter, raw_value: bool) -> bool:
246
+ if not raw_value:
247
+ return False
248
+
249
+ report_formats = ctx.params.get("report_formats", []) or []
250
+ vcr_enabled = ReportFormat.VCR in report_formats or ctx.params.get("report_vcr_path")
251
+ har_enabled = ReportFormat.HAR in report_formats or ctx.params.get("report_har_path")
252
+
253
+ if not (vcr_enabled or har_enabled):
254
+ raise click.UsageError(INVALID_REPORT_USAGE)
255
+
256
+ return True
247
257
 
248
258
 
249
259
  def convert_experimental(
@@ -302,10 +312,6 @@ def convert_status_codes(
302
312
  return value
303
313
 
304
314
 
305
- def convert_cassette_format(ctx: click.core.Context, param: click.core.Parameter, value: str) -> CassetteFormat:
306
- return CassetteFormat.from_str(value)
307
-
308
-
309
315
  def convert_generation_mode(ctx: click.core.Context, param: click.core.Parameter, value: str) -> list[GenerationMode]:
310
316
  if value == "all":
311
317
  return GenerationMode.all()
@@ -1,34 +1,59 @@
1
1
  from __future__ import annotations
2
2
 
3
- from collections import defaultdict
3
+ import textwrap
4
4
  from typing import Any, Callable
5
5
 
6
6
  import click
7
7
 
8
- GROUPS: list[str] = []
8
+ GROUPS: dict[str, OptionGroup] = {}
9
+
10
+
11
+ class OptionGroup:
12
+ __slots__ = ("order", "name", "description", "options")
13
+
14
+ def __init__(
15
+ self,
16
+ name: str,
17
+ *,
18
+ order: int | None = None,
19
+ description: str | None = None,
20
+ ):
21
+ self.name = name
22
+ self.description = description
23
+ self.order = order if order is not None else len(GROUPS) * 100
24
+ self.options: list[tuple[str, str]] = []
9
25
 
10
26
 
11
27
  class CommandWithGroupedOptions(click.Command):
12
28
  def format_options(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
13
- groups = defaultdict(list)
29
+ # Collect options into groups or ungrouped list
14
30
  for param in self.get_params(ctx):
15
31
  rv = param.get_help_record(ctx)
16
32
  if rv is not None:
17
- (option_repr, message) = rv
33
+ option_repr, message = rv
18
34
  if isinstance(param.type, click.Choice):
19
35
  message += (
20
36
  getattr(param.type, "choices_repr", None)
21
37
  or f" [possible values: {', '.join(param.type.choices)}]"
22
38
  )
23
39
 
24
- if isinstance(param, GroupedOption):
25
- group = param.group
40
+ if isinstance(param, GroupedOption) and param.group is not None:
41
+ group = GROUPS.get(param.group)
42
+ if group:
43
+ group.options.append((option_repr, message))
26
44
  else:
27
- group = "Global options"
28
- groups[group].append((option_repr, message))
29
- for group in GROUPS:
30
- with formatter.section(group or "Options"):
31
- formatter.write_dl(groups[group], col_max=40)
45
+ GROUPS["Global options"].options.append((option_repr, message))
46
+
47
+ groups = sorted(GROUPS.values(), key=lambda g: g.order)
48
+ # Format each group
49
+ for group in groups:
50
+ with formatter.section(group.name):
51
+ if group.description:
52
+ formatter.write(textwrap.indent(group.description, " " * formatter.current_indent))
53
+ formatter.write("\n\n")
54
+
55
+ if group.options:
56
+ formatter.write_dl(group.options)
32
57
 
33
58
 
34
59
  class GroupedOption(click.Option):
@@ -37,8 +62,12 @@ class GroupedOption(click.Option):
37
62
  self.group = group
38
63
 
39
64
 
40
- def group(name: str) -> Callable:
41
- GROUPS.append(name)
65
+ def group(
66
+ name: str,
67
+ *,
68
+ description: str | None = None,
69
+ ) -> Callable:
70
+ GROUPS[name] = OptionGroup(name, description=description)
42
71
 
43
72
  def _inner(cmd: Callable) -> Callable:
44
73
  for param in reversed(cmd.__click_params__): # type: ignore[attr-defined]
@@ -22,8 +22,13 @@ class CustomHelpMessageChoice(click.Choice):
22
22
 
23
23
  class BaseCsvChoice(click.Choice):
24
24
  def parse_value(self, value: str) -> tuple[list[str], set[str]]:
25
- selected = [item for item in value.split(",") if item]
26
- invalid_options = set(selected) - set(self.choices)
25
+ selected = [item.strip() for item in value.split(",") if item.strip()]
26
+ if not self.case_sensitive:
27
+ invalid_options = {
28
+ item for item in selected if item.upper() not in {choice.upper() for choice in self.choices}
29
+ }
30
+ else:
31
+ invalid_options = set(selected) - set(self.choices)
27
32
  return selected, invalid_options
28
33
 
29
34
  def fail_on_invalid_options(self, invalid_options: set[str], selected: list[str]) -> NoReturn: # type: ignore[misc]
@@ -44,16 +49,18 @@ class CsvChoice(BaseCsvChoice):
44
49
 
45
50
 
46
51
  class CsvEnumChoice(BaseCsvChoice):
47
- def __init__(self, choices: type[Enum]):
52
+ def __init__(self, choices: type[Enum], case_sensitive: bool = False):
48
53
  self.enum = choices
49
- super().__init__(tuple(el.name for el in choices))
54
+ super().__init__(tuple(el.name.lower() for el in choices), case_sensitive=case_sensitive)
50
55
 
51
- def convert( # type: ignore[return]
52
- self, value: str, param: click.core.Parameter | None, ctx: click.core.Context | None
53
- ) -> list[Enum]:
56
+ def convert(self, value: str, param: click.core.Parameter | None, ctx: click.core.Context | None) -> list[Enum]:
54
57
  selected, invalid_options = self.parse_value(value)
55
58
  if not invalid_options and selected:
56
- return [self.enum[item] for item in selected]
59
+ # Match case-insensitively to find the correct enum
60
+ return [
61
+ next(enum_value for enum_value in self.enum if enum_value.value.upper() == item.upper())
62
+ for item in selected
63
+ ]
57
64
  self.fail_on_invalid_options(invalid_options, selected)
58
65
 
59
66
 
@@ -35,20 +35,16 @@ class InvalidSchema(SchemathesisError):
35
35
 
36
36
  def __init__(
37
37
  self,
38
- message: str | None = None,
38
+ message: str,
39
39
  path: str | None = None,
40
40
  method: str | None = None,
41
- full_path: str | None = None,
42
41
  ) -> None:
43
42
  self.message = message
44
43
  self.path = path
45
44
  self.method = method
46
- self.full_path = full_path
47
45
 
48
46
  @classmethod
49
- def from_jsonschema_error(
50
- cls, error: ValidationError, path: str | None, method: str | None, full_path: str | None
51
- ) -> InvalidSchema:
47
+ def from_jsonschema_error(cls, error: ValidationError, path: str | None, method: str | None) -> InvalidSchema:
52
48
  if error.absolute_path:
53
49
  part = error.absolute_path[-1]
54
50
  if isinstance(part, int) and len(error.absolute_path) > 1:
@@ -69,11 +65,11 @@ class InvalidSchema(SchemathesisError):
69
65
  else:
70
66
  message += error.message
71
67
  message += f"\n\n{SCHEMA_ERROR_SUGGESTION}"
72
- return cls(message, path=path, method=method, full_path=full_path)
68
+ return cls(message, path=path, method=method)
73
69
 
74
70
  @classmethod
75
71
  def from_reference_resolution_error(
76
- cls, error: RefResolutionError, path: str | None, method: str | None, full_path: str | None
72
+ cls, error: RefResolutionError, path: str | None, method: str | None
77
73
  ) -> InvalidSchema:
78
74
  notes = getattr(error, "__notes__", [])
79
75
  # Some exceptions don't have the actual reference in them, hence we add it manually via notes
@@ -83,7 +79,7 @@ class InvalidSchema(SchemathesisError):
83
79
  message += f"\n\nError details:\n JSON pointer: {pointer}"
84
80
  message += "\n This typically means that the schema is referencing a component that doesn't exist."
85
81
  message += f"\n\n{SCHEMA_ERROR_SUGGESTION}"
86
- return cls(message, path=path, method=method, full_path=full_path)
82
+ return cls(message, path=path, method=method)
87
83
 
88
84
  def as_failing_test_function(self) -> Callable:
89
85
  """Create a test function that will fail.
@@ -102,6 +98,82 @@ class InvalidRegexType(InvalidSchema):
102
98
  """Raised when an invalid type is used where a regex pattern is expected."""
103
99
 
104
100
 
101
+ class InvalidStateMachine(SchemathesisError):
102
+ """Collection of validation errors found in API state machine transitions.
103
+
104
+ Raised during schema initialization when one or more transitions
105
+ contain invalid definitions, such as references to non-existent parameters
106
+ or operations.
107
+ """
108
+
109
+ errors: list[InvalidTransition]
110
+
111
+ __slots__ = ("errors",)
112
+
113
+ def __init__(self, errors: list[InvalidTransition]) -> None:
114
+ self.errors = errors
115
+
116
+ def __str__(self) -> str:
117
+ """Format state machine validation errors in a clear, hierarchical structure."""
118
+ result = "The following API operations contain invalid link definitions:"
119
+
120
+ # Group transitions by source operation, then by target and status
121
+ by_source: dict[str, dict[tuple[str, str], list[InvalidTransition]]] = {}
122
+ for transition in self.errors:
123
+ source_group = by_source.setdefault(transition.source, {})
124
+ target_key = (transition.target, transition.status_code)
125
+ source_group.setdefault(target_key, []).append(transition)
126
+
127
+ for source, target_groups in by_source.items():
128
+ for (target, status), transitions in target_groups.items():
129
+ for transition in transitions:
130
+ result += f"\n\n {_format_transition(source, status, transition.name, target)}\n"
131
+ for error in transition.errors:
132
+ result += f"\n - {error.message}"
133
+ return result
134
+
135
+
136
+ def _format_transition(source: str, status: str, transition: str, target: str) -> str:
137
+ return f"{source} -> [{status}] {transition} -> {target}"
138
+
139
+
140
+ class InvalidTransition(SchemathesisError):
141
+ """Raised when a stateful transition contains one or more errors."""
142
+
143
+ name: str
144
+ source: str
145
+ target: str
146
+ status_code: str
147
+ errors: list[TransitionValidationError]
148
+
149
+ __slots__ = ("name", "source", "target", "status_code", "errors")
150
+
151
+ def __init__(
152
+ self,
153
+ name: str,
154
+ source: str,
155
+ target: str,
156
+ status_code: str,
157
+ errors: list[TransitionValidationError],
158
+ ) -> None:
159
+ self.name = name
160
+ self.source = source
161
+ self.target = target
162
+ self.status_code = status_code
163
+ self.errors = errors
164
+
165
+
166
+ class TransitionValidationError(SchemathesisError):
167
+ """Single validation error found during stateful transition validation."""
168
+
169
+ message: str
170
+
171
+ __slots__ = ("message",)
172
+
173
+ def __init__(self, message: str) -> None:
174
+ self.message = message
175
+
176
+
105
177
  class MalformedMediaType(ValueError):
106
178
  """Raised on parsing of incorrect media type."""
107
179
 
@@ -148,6 +220,10 @@ class IncorrectUsage(SchemathesisError):
148
220
  """Indicates incorrect usage of Schemathesis' public API."""
149
221
 
150
222
 
223
+ class NoLinksFound(IncorrectUsage):
224
+ """Raised when no valid links are available for stateful testing."""
225
+
226
+
151
227
  class InvalidRateLimit(IncorrectUsage):
152
228
  """Incorrect input for rate limiting."""
153
229
 
@@ -228,9 +228,10 @@ class MalformedJson(Failure):
228
228
 
229
229
  @classmethod
230
230
  def from_exception(cls, *, operation: str, exc: JSONDecodeError) -> MalformedJson:
231
+ message = f"Response must be valid JSON with 'Content-Type: application/json' header:\n\n {exc}"
231
232
  return cls(
232
233
  operation=operation,
233
- message=str(exc),
234
+ message=message,
234
235
  validation_message=exc.msg,
235
236
  document=exc.doc,
236
237
  position=exc.pos,
@@ -106,7 +106,7 @@ def resolve_pointer(document: Any, pointer: str) -> dict | list | str | int | fl
106
106
  elif isinstance(target, list):
107
107
  try:
108
108
  target = target[int(token)]
109
- except IndexError:
109
+ except (IndexError, ValueError):
110
110
  return UNRESOLVABLE
111
111
  else:
112
112
  return UNRESOLVABLE
@@ -68,7 +68,7 @@ class Engine:
68
68
  skip_reason=PhaseSkipReason.DISABLED,
69
69
  )
70
70
 
71
- if requires_links and self.schema.statistic.links.selected == 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
+ InvalidTransition,
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, InvalidTransition):
81
+ return "Invalid Link Definition"
82
+
79
83
  if isinstance(self._error, requests.RequestException):
80
84
  return "Network Error"
81
85
 
@@ -96,6 +100,8 @@ class EngineErrorInfo:
96
100
 
97
101
  return {
98
102
  RuntimeErrorKind.SCHEMA_UNSUPPORTED: "Unsupported Schema",
103
+ RuntimeErrorKind.SCHEMA_NO_LINKS_FOUND: "Missing Open API links",
104
+ RuntimeErrorKind.SCHEMA_INVALID_STATE_MACHINE: "Invalid OpenAPI Links Definition",
99
105
  RuntimeErrorKind.HYPOTHESIS_UNSUPPORTED_GRAPHQL_SCALAR: "Unknown GraphQL Scalar",
100
106
  RuntimeErrorKind.SERIALIZATION_UNBOUNDED_PREFIX: "XML serialization error",
101
107
  RuntimeErrorKind.SERIALIZATION_NOT_POSSIBLE: "Serialization not possible",
@@ -164,8 +170,10 @@ class EngineErrorInfo:
164
170
  def has_useful_traceback(self) -> bool:
165
171
  return self._kind not in (
166
172
  RuntimeErrorKind.SCHEMA_INVALID_REGULAR_EXPRESSION,
173
+ RuntimeErrorKind.SCHEMA_INVALID_STATE_MACHINE,
167
174
  RuntimeErrorKind.SCHEMA_UNSUPPORTED,
168
175
  RuntimeErrorKind.SCHEMA_GENERIC,
176
+ RuntimeErrorKind.SCHEMA_NO_LINKS_FOUND,
169
177
  RuntimeErrorKind.SERIALIZATION_NOT_POSSIBLE,
170
178
  RuntimeErrorKind.HYPOTHESIS_UNSUPPORTED_GRAPHQL_SCALAR,
171
179
  RuntimeErrorKind.HYPOTHESIS_UNSATISFIABLE,
@@ -184,11 +192,7 @@ class EngineErrorInfo:
184
192
  """Format error message with optional styling and traceback."""
185
193
  message = []
186
194
 
187
- # Title
188
- if self._kind == RuntimeErrorKind.SCHEMA_GENERIC:
189
- title = "Schema Error"
190
- else:
191
- title = self.title
195
+ title = self.title
192
196
  if title:
193
197
  message.append(f"{title}\n")
194
198
 
@@ -244,8 +248,9 @@ def get_runtime_error_suggestion(error_type: RuntimeErrorKind, bold: Callable[[s
244
248
  return f"Bypass this health check using {bold(f'`--suppress-health-check={label}`')}."
245
249
 
246
250
  return {
247
- RuntimeErrorKind.CONNECTION_SSL: f"Bypass SSL verification with {bold('`--request-tls-verify=false`')}.",
251
+ RuntimeErrorKind.CONNECTION_SSL: f"Bypass SSL verification with {bold('`--tls-verify=false`')}.",
248
252
  RuntimeErrorKind.HYPOTHESIS_UNSATISFIABLE: "Examine the schema for inconsistencies and consider simplifying it.",
253
+ RuntimeErrorKind.SCHEMA_NO_LINKS_FOUND: "Review your endpoint filters to include linked operations",
249
254
  RuntimeErrorKind.SCHEMA_INVALID_REGULAR_EXPRESSION: "Ensure your regex is compatible with Python's syntax.\n"
250
255
  "For guidance, visit: https://docs.python.org/3/library/re.html",
251
256
  RuntimeErrorKind.HYPOTHESIS_UNSUPPORTED_GRAPHQL_SCALAR: "Define a custom strategy for it.\n"
@@ -299,6 +304,8 @@ class RuntimeErrorKind(str, enum.Enum):
299
304
  HYPOTHESIS_HEALTH_CHECK_LARGE_BASE_EXAMPLE = "hypothesis_health_check_large_base_example"
300
305
 
301
306
  SCHEMA_INVALID_REGULAR_EXPRESSION = "schema_invalid_regular_expression"
307
+ SCHEMA_INVALID_STATE_MACHINE = "schema_invalid_state_machine"
308
+ SCHEMA_NO_LINKS_FOUND = "schema_no_links_found"
302
309
  SCHEMA_UNSUPPORTED = "schema_unsupported"
303
310
  SCHEMA_GENERIC = "schema_generic"
304
311
 
@@ -350,6 +357,10 @@ def _classify(*, error: Exception) -> RuntimeErrorKind:
350
357
  if isinstance(error, errors.InvalidRegexPattern):
351
358
  return RuntimeErrorKind.SCHEMA_INVALID_REGULAR_EXPRESSION
352
359
  return RuntimeErrorKind.SCHEMA_GENERIC
360
+ if isinstance(error, errors.InvalidStateMachine):
361
+ return RuntimeErrorKind.SCHEMA_INVALID_STATE_MACHINE
362
+ if isinstance(error, errors.NoLinksFound):
363
+ return RuntimeErrorKind.SCHEMA_NO_LINKS_FOUND
353
364
  if isinstance(error, UnsupportedRecursiveReference):
354
365
  # Recursive references are not supported right now
355
366
  return RuntimeErrorKind.SCHEMA_UNSUPPORTED
@@ -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()
@@ -78,7 +78,6 @@ def execute_state_machine_loop(
78
78
  self._start_time = time.monotonic()
79
79
  self._scenario_id = scenario_started.id
80
80
  event_queue.put(scenario_started)
81
- self.recorder = ScenarioRecorder(label="Stateful tests")
82
81
  self._check_ctx = engine.get_check_context(self.recorder)
83
82
 
84
83
  def get_call_kwargs(self, case: Case) -> dict[str, Any]:
@@ -100,12 +99,6 @@ def execute_state_machine_loop(
100
99
  def step(self, input: StepInput) -> StepOutput | None:
101
100
  # Checking the stop event once inside `step` is sufficient as it is called frequently
102
101
  # The idea is to stop the execution as soon as possible
103
- if input.transition is not None:
104
- self.recorder.record_case(
105
- parent_id=input.transition.parent_id, transition=input.transition, case=input.case
106
- )
107
- else:
108
- self.recorder.record_case(parent_id=None, transition=None, case=input.case)
109
102
  if engine.has_to_stop:
110
103
  raise KeyboardInterrupt
111
104
  try:
@@ -141,7 +134,7 @@ def execute_state_machine_loop(
141
134
  return result
142
135
 
143
136
  def validate_response(
144
- self, response: Response, case: Case, additional_checks: tuple[CheckFunction, ...] = ()
137
+ self, response: Response, case: Case, additional_checks: tuple[CheckFunction, ...] = (), **kwargs: Any
145
138
  ) -> None:
146
139
  self.recorder.record_response(case_id=case.id, response=response)
147
140
  ctx.collect_metric(case, response)
@@ -176,10 +169,7 @@ def execute_state_machine_loop(
176
169
  ctx.reset_scenario()
177
170
  super().teardown()
178
171
 
179
- if config.execution.seed is not None:
180
- InstrumentedStateMachine = hypothesis.seed(config.execution.seed)(_InstrumentedStateMachine)
181
- else:
182
- InstrumentedStateMachine = _InstrumentedStateMachine
172
+ seed = config.execution.seed
183
173
 
184
174
  while True:
185
175
  # This loop is running until no new failures are found in a single iteration
@@ -197,6 +187,13 @@ def execute_state_machine_loop(
197
187
  )
198
188
  break
199
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
200
197
  try:
201
198
  with catch_warnings(), ignore_hypothesis_output(): # type: ignore
202
199
  InstrumentedStateMachine.run(settings=config.execution.hypothesis_settings)