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.
- schemathesis/cli/__init__.py +3 -3
- schemathesis/cli/commands/run/__init__.py +159 -135
- schemathesis/cli/commands/run/checks.py +2 -3
- schemathesis/cli/commands/run/context.py +102 -19
- schemathesis/cli/commands/run/executor.py +33 -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 +238 -102
- schemathesis/cli/commands/run/hypothesis.py +14 -41
- 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/__init__.py +7 -1
- schemathesis/core/errors.py +79 -11
- schemathesis/core/failures.py +2 -1
- schemathesis/core/transforms.py +1 -1
- schemathesis/engine/config.py +2 -2
- schemathesis/engine/core.py +11 -1
- schemathesis/engine/errors.py +8 -3
- schemathesis/engine/events.py +7 -0
- schemathesis/engine/phases/__init__.py +16 -4
- schemathesis/engine/phases/stateful/_executor.py +1 -1
- schemathesis/engine/phases/unit/__init__.py +77 -53
- schemathesis/engine/phases/unit/_executor.py +28 -23
- schemathesis/engine/phases/unit/_pool.py +8 -0
- schemathesis/errors.py +6 -2
- schemathesis/experimental/__init__.py +0 -6
- schemathesis/filters.py +8 -0
- schemathesis/generation/coverage.py +6 -1
- schemathesis/generation/hypothesis/builder.py +222 -97
- schemathesis/generation/stateful/state_machine.py +49 -3
- schemathesis/openapi/checks.py +3 -1
- schemathesis/pytest/lazy.py +43 -5
- schemathesis/pytest/plugin.py +4 -4
- schemathesis/schemas.py +1 -1
- schemathesis/specs/openapi/checks.py +28 -11
- schemathesis/specs/openapi/examples.py +2 -5
- 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 +24 -7
- schemathesis/specs/openapi/schemas.py +13 -13
- schemathesis/specs/openapi/serialization.py +14 -0
- schemathesis/specs/openapi/stateful/__init__.py +96 -23
- schemathesis/specs/openapi/{links.py → stateful/links.py} +60 -16
- {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/METADATA +7 -26
- {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/RECORD +53 -52
- {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/entry_points.txt +0 -0
- {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
|
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
|
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 =
|
43
|
-
filter_too_much =
|
44
|
-
too_slow =
|
45
|
-
large_base_example =
|
46
|
-
all =
|
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
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
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.
|
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/__init__.py
CHANGED
@@ -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 {
|
44
|
+
return feature in {
|
45
|
+
SpecificationFeature.STATEFUL_TESTING,
|
46
|
+
SpecificationFeature.COVERAGE,
|
47
|
+
SpecificationFeature.EXAMPLES,
|
48
|
+
}
|
43
49
|
return False
|
44
50
|
|
45
51
|
|
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,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
|
106
|
-
"""
|
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):
|
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/config.py
CHANGED
@@ -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=
|
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
|
-
|
35
|
+
continue_on_failure: bool = False
|
36
36
|
seed: int | None = None
|
37
37
|
workers_num: int = 1
|
38
38
|
|
schemathesis/engine/core.py
CHANGED
@@ -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(
|
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),
|
schemathesis/engine/errors.py
CHANGED
@@ -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
|
-
|
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,
|
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('`--
|
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):
|
schemathesis/engine/events.py
CHANGED
@@ -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
|
-
|
18
|
-
|
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
|
-
"
|
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.
|
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)
|