schemathesis 4.0.0a3__py3-none-any.whl → 4.0.0a5__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 (53) hide show
  1. schemathesis/cli/__init__.py +3 -3
  2. schemathesis/cli/commands/run/__init__.py +159 -135
  3. schemathesis/cli/commands/run/checks.py +2 -3
  4. schemathesis/cli/commands/run/context.py +102 -19
  5. schemathesis/cli/commands/run/executor.py +33 -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 +238 -102
  10. schemathesis/cli/commands/run/hypothesis.py +14 -41
  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/__init__.py +7 -1
  16. schemathesis/core/errors.py +79 -11
  17. schemathesis/core/failures.py +2 -1
  18. schemathesis/core/transforms.py +1 -1
  19. schemathesis/engine/config.py +2 -2
  20. schemathesis/engine/core.py +11 -1
  21. schemathesis/engine/errors.py +8 -3
  22. schemathesis/engine/events.py +7 -0
  23. schemathesis/engine/phases/__init__.py +16 -4
  24. schemathesis/engine/phases/stateful/_executor.py +1 -1
  25. schemathesis/engine/phases/unit/__init__.py +77 -53
  26. schemathesis/engine/phases/unit/_executor.py +28 -23
  27. schemathesis/engine/phases/unit/_pool.py +8 -0
  28. schemathesis/errors.py +6 -2
  29. schemathesis/experimental/__init__.py +0 -6
  30. schemathesis/filters.py +8 -0
  31. schemathesis/generation/coverage.py +6 -1
  32. schemathesis/generation/hypothesis/builder.py +222 -97
  33. schemathesis/generation/stateful/state_machine.py +49 -3
  34. schemathesis/openapi/checks.py +3 -1
  35. schemathesis/pytest/lazy.py +43 -5
  36. schemathesis/pytest/plugin.py +4 -4
  37. schemathesis/schemas.py +1 -1
  38. schemathesis/specs/openapi/checks.py +28 -11
  39. schemathesis/specs/openapi/examples.py +2 -5
  40. schemathesis/specs/openapi/expressions/__init__.py +22 -6
  41. schemathesis/specs/openapi/expressions/nodes.py +15 -21
  42. schemathesis/specs/openapi/expressions/parser.py +1 -1
  43. schemathesis/specs/openapi/parameters.py +0 -2
  44. schemathesis/specs/openapi/patterns.py +24 -7
  45. schemathesis/specs/openapi/schemas.py +13 -13
  46. schemathesis/specs/openapi/serialization.py +14 -0
  47. schemathesis/specs/openapi/stateful/__init__.py +96 -23
  48. schemathesis/specs/openapi/{links.py → stateful/links.py} +60 -16
  49. {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/METADATA +7 -26
  50. {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/RECORD +53 -52
  51. {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/WHEEL +0 -0
  52. {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/entry_points.txt +0 -0
  53. {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/licenses/LICENSE +0 -0
@@ -1,14 +1,11 @@
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
- import click
7
-
8
6
  if TYPE_CHECKING:
9
7
  import hypothesis
10
8
 
11
- PHASES_INVALID_USAGE_MESSAGE = "Can't use `--hypothesis-phases` and `--hypothesis-no-phases` simultaneously"
12
9
  HYPOTHESIS_IN_MEMORY_DATABASE_IDENTIFIER = ":memory:"
13
10
 
14
11
  # Importing Hypothesis is expensive, hence we re-create the enums we need in CLI commands definitions
@@ -16,34 +13,13 @@ HYPOTHESIS_IN_MEMORY_DATABASE_IDENTIFIER = ":memory:"
16
13
 
17
14
 
18
15
  @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.
25
- # The `explain` phase is not supported
26
-
27
- def as_hypothesis(self) -> hypothesis.Phase:
28
- from hypothesis import Phase
29
-
30
- return Phase[self.name]
31
-
32
- @staticmethod
33
- def filter_from_all(variants: list[Phase]) -> list[hypothesis.Phase]:
34
- from hypothesis import Phase
35
-
36
- return list(set(Phase) - {Phase.explain} - set(variants))
37
-
38
-
39
- @unique
40
- class HealthCheck(IntEnum):
16
+ class HealthCheck(str, Enum):
41
17
  # 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
18
+ data_too_large = "data_too_large"
19
+ filter_too_much = "filter_too_much"
20
+ too_slow = "too_slow"
21
+ large_base_example = "large_base_example"
22
+ all = "all"
47
23
 
48
24
  def as_hypothesis(self) -> list[hypothesis.HealthCheck]:
49
25
  from hypothesis import HealthCheck
@@ -63,16 +39,13 @@ def prepare_health_checks(
63
39
  return [entry for health_check in hypothesis_suppress_health_check for entry in health_check.as_hypothesis()]
64
40
 
65
41
 
66
- def prepare_phases(
67
- hypothesis_phases: list[Phase] | None, hypothesis_no_phases: list[Phase] | None
68
- ) -> list[hypothesis.Phase] | None:
69
- if hypothesis_phases is not None and hypothesis_no_phases is not None:
70
- raise click.UsageError(PHASES_INVALID_USAGE_MESSAGE)
71
- 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)
75
- return None
42
+ def prepare_phases(no_shrink: bool = False) -> list[hypothesis.Phase] | None:
43
+ from hypothesis import Phase
44
+
45
+ phases = set(Phase) - {Phase.explain}
46
+ if no_shrink:
47
+ return list(phases - {Phase.shrink})
48
+ return list(phases)
76
49
 
77
50
 
78
51
  def prepare_settings(
@@ -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
 
@@ -16,6 +16,8 @@ class SpecificationFeature(str, enum.Enum):
16
16
  """Features that Schemathesis can provide for different specifications."""
17
17
 
18
18
  STATEFUL_TESTING = "stateful_testing"
19
+ COVERAGE = "coverage_tests"
20
+ EXAMPLES = "example_tests"
19
21
 
20
22
 
21
23
  @dataclass
@@ -39,7 +41,11 @@ class Specification:
39
41
  def supports_feature(self, feature: SpecificationFeature) -> bool:
40
42
  """Check if Schemathesis supports a given feature for this specification."""
41
43
  if self.kind == SpecificationKind.OPENAPI:
42
- return feature in {SpecificationFeature.STATEFUL_TESTING}
44
+ return feature in {
45
+ SpecificationFeature.STATEFUL_TESTING,
46
+ SpecificationFeature.COVERAGE,
47
+ SpecificationFeature.EXAMPLES,
48
+ }
43
49
  return False
44
50
 
45
51
 
@@ -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,8 +98,80 @@ class InvalidRegexType(InvalidSchema):
102
98
  """Raised when an invalid type is used where a regex pattern is expected."""
103
99
 
104
100
 
105
- class InvalidLinkDefinition(InvalidSchema):
106
- """Raised when an Open API link references a non-existent operation."""
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
107
175
 
108
176
 
109
177
  class MalformedMediaType(ValueError):
@@ -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
@@ -25,14 +25,14 @@ def _default_hypothesis_settings() -> hypothesis.settings:
25
25
  class ExecutionConfig:
26
26
  """Configuration for test execution."""
27
27
 
28
- phases: list[PhaseName] = field(default_factory=lambda: [PhaseName.UNIT_TESTING, PhaseName.STATEFUL_TESTING])
28
+ phases: list[PhaseName] = field(default_factory=PhaseName.defaults)
29
29
  checks: list[CheckFunction] = field(default_factory=lambda: [not_a_server_error])
30
30
  targets: list[TargetFunction] = field(default_factory=list)
31
31
  hypothesis_settings: hypothesis.settings = field(default_factory=_default_hypothesis_settings)
32
32
  generation: GenerationConfig = field(default_factory=GenerationConfig)
33
33
  max_failures: int | None = None
34
34
  unique_inputs: bool = False
35
- no_failfast: bool = False
35
+ continue_on_failure: bool = False
36
36
  seed: int | None = None
37
37
  workers_num: int = 1
38
38
 
@@ -34,7 +34,17 @@ class Engine:
34
34
  """Create execution plan based on configuration."""
35
35
  phases = [
36
36
  self.get_phase_config(PhaseName.PROBING, is_supported=True, requires_links=False),
37
- self.get_phase_config(PhaseName.UNIT_TESTING, is_supported=True, requires_links=False),
37
+ self.get_phase_config(
38
+ PhaseName.EXAMPLES,
39
+ is_supported=self.schema.specification.supports_feature(SpecificationFeature.EXAMPLES),
40
+ requires_links=False,
41
+ ),
42
+ self.get_phase_config(
43
+ PhaseName.COVERAGE,
44
+ is_supported=self.schema.specification.supports_feature(SpecificationFeature.COVERAGE),
45
+ requires_links=False,
46
+ ),
47
+ self.get_phase_config(PhaseName.FUZZING, is_supported=True, requires_links=False),
38
48
  self.get_phase_config(
39
49
  PhaseName.STATEFUL_TESTING,
40
50
  is_supported=self.schema.specification.supports_feature(SpecificationFeature.STATEFUL_TESTING),
@@ -14,7 +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
+ InvalidTransition,
18
18
  SerializationNotPossible,
19
19
  format_exception,
20
20
  get_request_error_extras,
@@ -77,7 +77,7 @@ class EngineErrorInfo:
77
77
  """A general error description."""
78
78
  import requests
79
79
 
80
- if isinstance(self._error, InvalidLinkDefinition):
80
+ if isinstance(self._error, InvalidTransition):
81
81
  return "Invalid Link Definition"
82
82
 
83
83
  if isinstance(self._error, requests.RequestException):
@@ -101,6 +101,7 @@ class EngineErrorInfo:
101
101
  return {
102
102
  RuntimeErrorKind.SCHEMA_UNSUPPORTED: "Unsupported Schema",
103
103
  RuntimeErrorKind.SCHEMA_NO_LINKS_FOUND: "Missing Open API links",
104
+ RuntimeErrorKind.SCHEMA_INVALID_STATE_MACHINE: "Invalid OpenAPI Links Definition",
104
105
  RuntimeErrorKind.HYPOTHESIS_UNSUPPORTED_GRAPHQL_SCALAR: "Unknown GraphQL Scalar",
105
106
  RuntimeErrorKind.SERIALIZATION_UNBOUNDED_PREFIX: "XML serialization error",
106
107
  RuntimeErrorKind.SERIALIZATION_NOT_POSSIBLE: "Serialization not possible",
@@ -169,6 +170,7 @@ class EngineErrorInfo:
169
170
  def has_useful_traceback(self) -> bool:
170
171
  return self._kind not in (
171
172
  RuntimeErrorKind.SCHEMA_INVALID_REGULAR_EXPRESSION,
173
+ RuntimeErrorKind.SCHEMA_INVALID_STATE_MACHINE,
172
174
  RuntimeErrorKind.SCHEMA_UNSUPPORTED,
173
175
  RuntimeErrorKind.SCHEMA_GENERIC,
174
176
  RuntimeErrorKind.SCHEMA_NO_LINKS_FOUND,
@@ -246,7 +248,7 @@ def get_runtime_error_suggestion(error_type: RuntimeErrorKind, bold: Callable[[s
246
248
  return f"Bypass this health check using {bold(f'`--suppress-health-check={label}`')}."
247
249
 
248
250
  return {
249
- 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`')}.",
250
252
  RuntimeErrorKind.HYPOTHESIS_UNSATISFIABLE: "Examine the schema for inconsistencies and consider simplifying it.",
251
253
  RuntimeErrorKind.SCHEMA_NO_LINKS_FOUND: "Review your endpoint filters to include linked operations",
252
254
  RuntimeErrorKind.SCHEMA_INVALID_REGULAR_EXPRESSION: "Ensure your regex is compatible with Python's syntax.\n"
@@ -302,6 +304,7 @@ class RuntimeErrorKind(str, enum.Enum):
302
304
  HYPOTHESIS_HEALTH_CHECK_LARGE_BASE_EXAMPLE = "hypothesis_health_check_large_base_example"
303
305
 
304
306
  SCHEMA_INVALID_REGULAR_EXPRESSION = "schema_invalid_regular_expression"
307
+ SCHEMA_INVALID_STATE_MACHINE = "schema_invalid_state_machine"
305
308
  SCHEMA_NO_LINKS_FOUND = "schema_no_links_found"
306
309
  SCHEMA_UNSUPPORTED = "schema_unsupported"
307
310
  SCHEMA_GENERIC = "schema_generic"
@@ -354,6 +357,8 @@ def _classify(*, error: Exception) -> RuntimeErrorKind:
354
357
  if isinstance(error, errors.InvalidRegexPattern):
355
358
  return RuntimeErrorKind.SCHEMA_INVALID_REGULAR_EXPRESSION
356
359
  return RuntimeErrorKind.SCHEMA_GENERIC
360
+ if isinstance(error, errors.InvalidStateMachine):
361
+ return RuntimeErrorKind.SCHEMA_INVALID_STATE_MACHINE
357
362
  if isinstance(error, errors.NoLinksFound):
358
363
  return RuntimeErrorKind.SCHEMA_NO_LINKS_FOUND
359
364
  if isinstance(error, UnsupportedRecursiveReference):
@@ -209,6 +209,13 @@ class NonFatalError(EngineEvent):
209
209
  self.label = label
210
210
  self.related_to_operation = related_to_operation
211
211
 
212
+ def __eq__(self, other: object) -> bool:
213
+ assert isinstance(other, NonFatalError)
214
+ return self.label == other.label and type(self.value) is type(other.value)
215
+
216
+ def __hash__(self) -> int:
217
+ return hash((self.label, type(self.value)))
218
+
212
219
 
213
220
  @dataclass
214
221
  class FatalError(EngineEvent):
@@ -14,14 +14,22 @@ class PhaseName(enum.Enum):
14
14
  """Available execution phases."""
15
15
 
16
16
  PROBING = "API probing"
17
- UNIT_TESTING = "Unit testing"
18
- STATEFUL_TESTING = "Stateful testing"
17
+ EXAMPLES = "Examples"
18
+ COVERAGE = "Coverage"
19
+ FUZZING = "Fuzzing"
20
+ STATEFUL_TESTING = "Stateful"
21
+
22
+ @classmethod
23
+ def defaults(cls) -> list[PhaseName]:
24
+ return [PhaseName.EXAMPLES, PhaseName.COVERAGE, PhaseName.FUZZING, PhaseName.STATEFUL_TESTING]
19
25
 
20
26
  @classmethod
21
27
  def from_str(cls, value: str) -> PhaseName:
22
28
  return {
23
29
  "probing": cls.PROBING,
24
- "unit": cls.UNIT_TESTING,
30
+ "examples": cls.EXAMPLES,
31
+ "coverage": cls.COVERAGE,
32
+ "fuzzing": cls.FUZZING,
25
33
  "stateful": cls.STATEFUL_TESTING,
26
34
  }[value.lower()]
27
35
 
@@ -60,7 +68,11 @@ def execute(ctx: EngineContext, phase: Phase) -> EventGenerator:
60
68
 
61
69
  if phase.name == PhaseName.PROBING:
62
70
  yield from probes.execute(ctx, phase)
63
- elif phase.name == PhaseName.UNIT_TESTING:
71
+ elif phase.name == PhaseName.EXAMPLES:
72
+ yield from unit.execute(ctx, phase)
73
+ elif phase.name == PhaseName.COVERAGE:
74
+ yield from unit.execute(ctx, phase)
75
+ elif phase.name == PhaseName.FUZZING:
64
76
  yield from unit.execute(ctx, phase)
65
77
  elif phase.name == PhaseName.STATEFUL_TESTING:
66
78
  yield from stateful.execute(ctx, phase)