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.
- schemathesis/cli/__init__.py +15 -4
- schemathesis/cli/commands/run/__init__.py +148 -94
- schemathesis/cli/commands/run/context.py +72 -2
- schemathesis/cli/commands/run/events.py +22 -2
- schemathesis/cli/commands/run/executor.py +35 -12
- schemathesis/cli/commands/run/filters.py +1 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +27 -46
- schemathesis/cli/commands/run/handlers/junitxml.py +1 -1
- schemathesis/cli/commands/run/handlers/output.py +180 -87
- schemathesis/cli/commands/run/hypothesis.py +30 -19
- schemathesis/cli/commands/run/reports.py +72 -0
- schemathesis/cli/commands/run/validation.py +18 -12
- schemathesis/cli/ext/groups.py +42 -13
- schemathesis/cli/ext/options.py +15 -8
- schemathesis/core/errors.py +85 -9
- schemathesis/core/failures.py +2 -1
- schemathesis/core/transforms.py +1 -1
- schemathesis/engine/core.py +1 -1
- schemathesis/engine/errors.py +17 -6
- schemathesis/engine/phases/stateful/__init__.py +1 -0
- schemathesis/engine/phases/stateful/_executor.py +9 -12
- schemathesis/engine/phases/unit/__init__.py +2 -3
- schemathesis/engine/phases/unit/_executor.py +16 -13
- schemathesis/engine/recorder.py +22 -21
- schemathesis/errors.py +23 -13
- schemathesis/filters.py +8 -0
- schemathesis/generation/coverage.py +10 -5
- schemathesis/generation/hypothesis/builder.py +15 -12
- schemathesis/generation/stateful/state_machine.py +57 -12
- schemathesis/pytest/lazy.py +2 -3
- schemathesis/pytest/plugin.py +2 -3
- schemathesis/schemas.py +1 -1
- schemathesis/specs/openapi/checks.py +77 -37
- schemathesis/specs/openapi/expressions/__init__.py +22 -6
- schemathesis/specs/openapi/expressions/nodes.py +15 -21
- schemathesis/specs/openapi/expressions/parser.py +1 -1
- schemathesis/specs/openapi/parameters.py +0 -2
- schemathesis/specs/openapi/patterns.py +170 -2
- schemathesis/specs/openapi/schemas.py +67 -39
- schemathesis/specs/openapi/stateful/__init__.py +207 -84
- schemathesis/specs/openapi/stateful/control.py +87 -0
- schemathesis/specs/openapi/{links.py → stateful/links.py} +72 -14
- {schemathesis-4.0.0a2.dist-info → schemathesis-4.0.0a4.dist-info}/METADATA +1 -1
- {schemathesis-4.0.0a2.dist-info → schemathesis-4.0.0a4.dist-info}/RECORD +47 -45
- {schemathesis-4.0.0a2.dist-info → schemathesis-4.0.0a4.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.0a2.dist-info → schemathesis-4.0.0a4.dist-info}/entry_points.txt +0 -0
- {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
|
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(
|
20
|
-
explicit =
|
21
|
-
reuse =
|
22
|
-
generate =
|
23
|
-
target =
|
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
|
-
|
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(
|
42
|
+
class HealthCheck(str, Enum):
|
41
43
|
# We remove not relevant checks
|
42
|
-
data_too_large =
|
43
|
-
filter_too_much =
|
44
|
-
too_slow =
|
45
|
-
large_base_example =
|
46
|
-
all =
|
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,
|
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
|
-
|
73
|
-
|
74
|
-
|
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.
|
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
|
-
|
28
|
-
"
|
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 `--
|
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
|
244
|
-
if raw_value
|
245
|
-
|
246
|
-
|
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()
|
schemathesis/cli/ext/groups.py
CHANGED
@@ -1,34 +1,59 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
|
3
|
+
import textwrap
|
4
4
|
from typing import Any, Callable
|
5
5
|
|
6
6
|
import click
|
7
7
|
|
8
|
-
GROUPS:
|
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
|
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
|
-
|
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
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
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(
|
41
|
-
|
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]
|
schemathesis/cli/ext/options.py
CHANGED
@@ -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
|
-
|
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(
|
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
|
-
|
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
|
|
schemathesis/core/errors.py
CHANGED
@@ -35,20 +35,16 @@ class InvalidSchema(SchemathesisError):
|
|
35
35
|
|
36
36
|
def __init__(
|
37
37
|
self,
|
38
|
-
message: str
|
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
|
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
|
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
|
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
|
|
schemathesis/core/failures.py
CHANGED
@@ -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=
|
234
|
+
message=message,
|
234
235
|
validation_message=exc.msg,
|
235
236
|
document=exc.doc,
|
236
237
|
position=exc.pos,
|
schemathesis/core/transforms.py
CHANGED
@@ -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
|
schemathesis/engine/core.py
CHANGED
schemathesis/engine/errors.py
CHANGED
@@ -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
|
-
|
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('`--
|
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
|
-
|
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)
|