schemathesis 3.25.5__py3-none-any.whl → 4.0.0a1__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/__init__.py +27 -65
- schemathesis/auths.py +102 -82
- schemathesis/checks.py +126 -46
- schemathesis/cli/__init__.py +11 -1766
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/commands/__init__.py +37 -0
- schemathesis/cli/commands/run/__init__.py +662 -0
- schemathesis/cli/commands/run/checks.py +80 -0
- schemathesis/cli/commands/run/context.py +117 -0
- schemathesis/cli/commands/run/events.py +35 -0
- schemathesis/cli/commands/run/executor.py +138 -0
- schemathesis/cli/commands/run/filters.py +194 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +18 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +494 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
- schemathesis/cli/commands/run/handlers/output.py +746 -0
- schemathesis/cli/commands/run/hypothesis.py +105 -0
- schemathesis/cli/commands/run/loaders.py +129 -0
- schemathesis/cli/{callbacks.py → commands/run/validation.py} +103 -174
- schemathesis/cli/constants.py +5 -52
- schemathesis/cli/core.py +17 -0
- schemathesis/cli/ext/fs.py +14 -0
- schemathesis/cli/ext/groups.py +55 -0
- schemathesis/cli/{options.py → ext/options.py} +39 -10
- schemathesis/cli/hooks.py +36 -0
- schemathesis/contrib/__init__.py +1 -3
- schemathesis/contrib/openapi/__init__.py +1 -3
- schemathesis/contrib/openapi/fill_missing_examples.py +3 -5
- schemathesis/core/__init__.py +58 -0
- schemathesis/core/compat.py +25 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +58 -0
- schemathesis/core/deserialization.py +65 -0
- schemathesis/core/errors.py +370 -0
- schemathesis/core/failures.py +285 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/{_lazy_import.py → core/lazy_import.py} +1 -0
- schemathesis/core/loaders.py +104 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/{transports/content_types.py → core/media_types.py} +17 -13
- schemathesis/core/output/__init__.py +69 -0
- schemathesis/core/output/sanitization.py +197 -0
- schemathesis/core/rate_limit.py +60 -0
- schemathesis/core/registries.py +31 -0
- schemathesis/{internal → core}/result.py +1 -1
- schemathesis/core/transforms.py +113 -0
- schemathesis/core/transport.py +108 -0
- schemathesis/core/validation.py +38 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +30 -0
- schemathesis/engine/config.py +59 -0
- schemathesis/engine/context.py +119 -0
- schemathesis/engine/control.py +36 -0
- schemathesis/engine/core.py +157 -0
- schemathesis/engine/errors.py +394 -0
- schemathesis/engine/events.py +337 -0
- schemathesis/engine/phases/__init__.py +66 -0
- schemathesis/{cli → engine/phases}/probes.py +63 -70
- schemathesis/engine/phases/stateful/__init__.py +65 -0
- schemathesis/engine/phases/stateful/_executor.py +326 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +174 -0
- schemathesis/engine/phases/unit/_executor.py +321 -0
- schemathesis/engine/phases/unit/_pool.py +74 -0
- schemathesis/engine/recorder.py +241 -0
- schemathesis/errors.py +31 -0
- schemathesis/experimental/__init__.py +18 -14
- schemathesis/filters.py +103 -14
- schemathesis/generation/__init__.py +21 -37
- schemathesis/generation/case.py +190 -0
- schemathesis/generation/coverage.py +931 -0
- schemathesis/generation/hypothesis/__init__.py +30 -0
- schemathesis/generation/hypothesis/builder.py +585 -0
- schemathesis/generation/hypothesis/examples.py +50 -0
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +14 -0
- schemathesis/generation/hypothesis/strategies.py +16 -0
- schemathesis/generation/meta.py +115 -0
- schemathesis/generation/modes.py +28 -0
- schemathesis/generation/overrides.py +96 -0
- schemathesis/generation/stateful/__init__.py +20 -0
- schemathesis/{stateful → generation/stateful}/state_machine.py +68 -81
- schemathesis/generation/targets.py +69 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +115 -0
- schemathesis/graphql/loaders.py +131 -0
- schemathesis/hooks.py +99 -67
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +412 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +63 -0
- schemathesis/openapi/loaders.py +178 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +273 -0
- schemathesis/pytest/loaders.py +12 -0
- schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +106 -127
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +537 -261
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/_cache.py +25 -0
- schemathesis/specs/graphql/nodes.py +1 -0
- schemathesis/specs/graphql/scalars.py +7 -5
- schemathesis/specs/graphql/schemas.py +215 -187
- schemathesis/specs/graphql/validation.py +11 -18
- schemathesis/specs/openapi/__init__.py +7 -1
- schemathesis/specs/openapi/_cache.py +122 -0
- schemathesis/specs/openapi/_hypothesis.py +146 -165
- schemathesis/specs/openapi/checks.py +565 -67
- schemathesis/specs/openapi/converter.py +33 -6
- schemathesis/specs/openapi/definitions.py +11 -18
- schemathesis/specs/openapi/examples.py +153 -39
- schemathesis/specs/openapi/expressions/__init__.py +37 -2
- schemathesis/specs/openapi/expressions/context.py +4 -6
- schemathesis/specs/openapi/expressions/extractors.py +23 -0
- schemathesis/specs/openapi/expressions/lexer.py +20 -18
- schemathesis/specs/openapi/expressions/nodes.py +38 -14
- schemathesis/specs/openapi/expressions/parser.py +26 -5
- schemathesis/specs/openapi/formats.py +45 -0
- schemathesis/specs/openapi/links.py +65 -165
- schemathesis/specs/openapi/media_types.py +32 -0
- schemathesis/specs/openapi/negative/__init__.py +7 -3
- schemathesis/specs/openapi/negative/mutations.py +24 -8
- schemathesis/specs/openapi/parameters.py +46 -30
- schemathesis/specs/openapi/patterns.py +137 -0
- schemathesis/specs/openapi/references.py +47 -57
- schemathesis/specs/openapi/schemas.py +483 -367
- schemathesis/specs/openapi/security.py +25 -7
- schemathesis/specs/openapi/serialization.py +11 -6
- schemathesis/specs/openapi/stateful/__init__.py +185 -73
- schemathesis/specs/openapi/utils.py +6 -1
- schemathesis/transport/__init__.py +104 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +99 -0
- schemathesis/transport/requests.py +221 -0
- schemathesis/{_xml.py → transport/serialization.py} +143 -28
- schemathesis/transport/wsgi.py +165 -0
- schemathesis-4.0.0a1.dist-info/METADATA +297 -0
- schemathesis-4.0.0a1.dist-info/RECORD +152 -0
- {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/WHEEL +1 -1
- {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/entry_points.txt +1 -1
- schemathesis/_compat.py +0 -74
- schemathesis/_dependency_versions.py +0 -17
- schemathesis/_hypothesis.py +0 -246
- schemathesis/_override.py +0 -49
- schemathesis/cli/cassettes.py +0 -375
- schemathesis/cli/context.py +0 -55
- schemathesis/cli/debug.py +0 -26
- schemathesis/cli/handlers.py +0 -16
- schemathesis/cli/junitxml.py +0 -43
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -765
- schemathesis/cli/output/short.py +0 -40
- schemathesis/cli/sanitization.py +0 -20
- schemathesis/code_samples.py +0 -149
- schemathesis/constants.py +0 -55
- schemathesis/contrib/openapi/formats/__init__.py +0 -9
- schemathesis/contrib/openapi/formats/uuid.py +0 -15
- schemathesis/contrib/unique_data.py +0 -41
- schemathesis/exceptions.py +0 -560
- schemathesis/extra/_aiohttp.py +0 -27
- schemathesis/extra/_flask.py +0 -10
- schemathesis/extra/_server.py +0 -17
- schemathesis/failures.py +0 -209
- schemathesis/fixups/__init__.py +0 -36
- schemathesis/fixups/fast_api.py +0 -41
- schemathesis/fixups/utf8_bom.py +0 -29
- schemathesis/graphql.py +0 -4
- schemathesis/internal/__init__.py +0 -7
- schemathesis/internal/copy.py +0 -13
- schemathesis/internal/datetime.py +0 -5
- schemathesis/internal/deprecation.py +0 -34
- schemathesis/internal/jsonschema.py +0 -35
- schemathesis/internal/transformation.py +0 -15
- schemathesis/internal/validation.py +0 -34
- schemathesis/lazy.py +0 -361
- schemathesis/loaders.py +0 -120
- schemathesis/models.py +0 -1231
- schemathesis/parameters.py +0 -86
- schemathesis/runner/__init__.py +0 -555
- schemathesis/runner/events.py +0 -309
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/core.py +0 -986
- schemathesis/runner/impl/solo.py +0 -90
- schemathesis/runner/impl/threadpool.py +0 -400
- schemathesis/runner/serialization.py +0 -411
- schemathesis/sanitization.py +0 -248
- schemathesis/serializers.py +0 -315
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -11
- schemathesis/service/ci.py +0 -201
- schemathesis/service/client.py +0 -100
- schemathesis/service/constants.py +0 -38
- schemathesis/service/events.py +0 -57
- schemathesis/service/hosts.py +0 -107
- schemathesis/service/metadata.py +0 -46
- schemathesis/service/models.py +0 -49
- schemathesis/service/report.py +0 -255
- schemathesis/service/serialization.py +0 -184
- schemathesis/service/usage.py +0 -65
- schemathesis/specs/graphql/loaders.py +0 -344
- schemathesis/specs/openapi/filters.py +0 -49
- schemathesis/specs/openapi/loaders.py +0 -667
- schemathesis/specs/openapi/stateful/links.py +0 -92
- schemathesis/specs/openapi/validation.py +0 -25
- schemathesis/stateful/__init__.py +0 -133
- schemathesis/targets.py +0 -45
- schemathesis/throttling.py +0 -41
- schemathesis/transports/__init__.py +0 -5
- schemathesis/transports/auth.py +0 -15
- schemathesis/transports/headers.py +0 -35
- schemathesis/transports/responses.py +0 -52
- schemathesis/types.py +0 -35
- schemathesis/utils.py +0 -169
- schemathesis-3.25.5.dist-info/METADATA +0 -356
- schemathesis-3.25.5.dist-info/RECORD +0 -134
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
- {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,80 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from dataclasses import dataclass
|
4
|
+
from typing import Sequence
|
5
|
+
|
6
|
+
from schemathesis import experimental
|
7
|
+
from schemathesis.checks import CHECKS, CheckFunction, ChecksConfig
|
8
|
+
|
9
|
+
|
10
|
+
@dataclass
|
11
|
+
class CheckArguments:
|
12
|
+
included_check_names: Sequence[str]
|
13
|
+
excluded_check_names: Sequence[str]
|
14
|
+
positive_data_acceptance_allowed_statuses: list[str] | None
|
15
|
+
missing_required_header_allowed_statuses: list[str] | None
|
16
|
+
negative_data_rejection_allowed_statuses: list[str] | None
|
17
|
+
max_response_time: float | None
|
18
|
+
|
19
|
+
__slots__ = (
|
20
|
+
"included_check_names",
|
21
|
+
"excluded_check_names",
|
22
|
+
"positive_data_acceptance_allowed_statuses",
|
23
|
+
"missing_required_header_allowed_statuses",
|
24
|
+
"negative_data_rejection_allowed_statuses",
|
25
|
+
"max_response_time",
|
26
|
+
)
|
27
|
+
|
28
|
+
def into(self) -> tuple[list[CheckFunction], ChecksConfig]:
|
29
|
+
# Determine selected checks
|
30
|
+
if "all" in self.included_check_names:
|
31
|
+
selected_checks = CHECKS.get_all()
|
32
|
+
else:
|
33
|
+
selected_checks = CHECKS.get_by_names(self.included_check_names or [])
|
34
|
+
|
35
|
+
# Prepare checks configuration
|
36
|
+
checks_config: ChecksConfig = {}
|
37
|
+
|
38
|
+
if experimental.POSITIVE_DATA_ACCEPTANCE.is_enabled:
|
39
|
+
from schemathesis.openapi.checks import PositiveDataAcceptanceConfig
|
40
|
+
from schemathesis.specs.openapi.checks import positive_data_acceptance
|
41
|
+
|
42
|
+
selected_checks.append(positive_data_acceptance)
|
43
|
+
if self.positive_data_acceptance_allowed_statuses:
|
44
|
+
checks_config[positive_data_acceptance] = PositiveDataAcceptanceConfig(
|
45
|
+
allowed_statuses=self.positive_data_acceptance_allowed_statuses
|
46
|
+
)
|
47
|
+
|
48
|
+
if self.missing_required_header_allowed_statuses:
|
49
|
+
from schemathesis.openapi.checks import MissingRequiredHeaderConfig
|
50
|
+
from schemathesis.specs.openapi.checks import missing_required_header
|
51
|
+
|
52
|
+
selected_checks.append(missing_required_header)
|
53
|
+
checks_config[missing_required_header] = MissingRequiredHeaderConfig(
|
54
|
+
allowed_statuses=self.missing_required_header_allowed_statuses
|
55
|
+
)
|
56
|
+
|
57
|
+
if self.negative_data_rejection_allowed_statuses:
|
58
|
+
from schemathesis.openapi.checks import NegativeDataRejectionConfig
|
59
|
+
from schemathesis.specs.openapi.checks import negative_data_rejection
|
60
|
+
|
61
|
+
checks_config[negative_data_rejection] = NegativeDataRejectionConfig(
|
62
|
+
allowed_statuses=self.negative_data_rejection_allowed_statuses
|
63
|
+
)
|
64
|
+
|
65
|
+
if self.max_response_time is not None:
|
66
|
+
from schemathesis.checks import max_response_time as _max_response_time
|
67
|
+
from schemathesis.core.failures import MaxResponseTimeConfig
|
68
|
+
|
69
|
+
checks_config[_max_response_time] = MaxResponseTimeConfig(self.max_response_time)
|
70
|
+
selected_checks.append(_max_response_time)
|
71
|
+
|
72
|
+
if experimental.COVERAGE_PHASE.is_enabled:
|
73
|
+
from schemathesis.specs.openapi.checks import unsupported_method
|
74
|
+
|
75
|
+
selected_checks.append(unsupported_method)
|
76
|
+
|
77
|
+
# Exclude checks based on their names
|
78
|
+
selected_checks = [check for check in selected_checks if check.__name__ not in self.excluded_check_names]
|
79
|
+
|
80
|
+
return selected_checks, checks_config
|
@@ -0,0 +1,117 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from dataclasses import dataclass, field
|
4
|
+
from typing import Generator
|
5
|
+
|
6
|
+
from schemathesis.core.failures import Failure
|
7
|
+
from schemathesis.core.output import OutputConfig
|
8
|
+
from schemathesis.core.transport import Response
|
9
|
+
from schemathesis.engine import Status, events
|
10
|
+
from schemathesis.engine.recorder import ScenarioRecorder
|
11
|
+
|
12
|
+
|
13
|
+
@dataclass
|
14
|
+
class Statistic:
|
15
|
+
"""Running statistics about test execution."""
|
16
|
+
|
17
|
+
outcomes: dict[Status, int]
|
18
|
+
failures: dict[str, dict[str, GroupedFailures]]
|
19
|
+
|
20
|
+
tested_operations: set[str]
|
21
|
+
|
22
|
+
total_cases: int
|
23
|
+
cases_with_failures: int
|
24
|
+
cases_without_checks: int
|
25
|
+
|
26
|
+
__slots__ = (
|
27
|
+
"outcomes",
|
28
|
+
"failures",
|
29
|
+
"tested_operations",
|
30
|
+
"total_cases",
|
31
|
+
"cases_with_failures",
|
32
|
+
"cases_without_checks",
|
33
|
+
)
|
34
|
+
|
35
|
+
def __init__(self) -> None:
|
36
|
+
self.outcomes = {}
|
37
|
+
self.failures = {}
|
38
|
+
self.tested_operations = set()
|
39
|
+
self.total_cases = 0
|
40
|
+
self.cases_with_failures = 0
|
41
|
+
self.cases_without_checks = 0
|
42
|
+
|
43
|
+
def record_checks(self, recorder: ScenarioRecorder) -> None:
|
44
|
+
"""Update statistics and store failures from a new batch of checks."""
|
45
|
+
failures = self.failures.get(recorder.label, {})
|
46
|
+
|
47
|
+
self.total_cases += len(recorder.cases)
|
48
|
+
|
49
|
+
for case_id, case in recorder.cases.items():
|
50
|
+
checks = recorder.checks.get(case_id, [])
|
51
|
+
|
52
|
+
if not checks:
|
53
|
+
self.cases_without_checks += 1
|
54
|
+
continue
|
55
|
+
|
56
|
+
self.tested_operations.add(case.value.operation.label)
|
57
|
+
has_failures = False
|
58
|
+
for check in checks:
|
59
|
+
response = recorder.interactions[case_id].response
|
60
|
+
|
61
|
+
# Collect failures
|
62
|
+
if check.failure_info is not None:
|
63
|
+
has_failures = True
|
64
|
+
if case_id not in failures:
|
65
|
+
failures[case_id] = GroupedFailures(
|
66
|
+
case_id=case_id,
|
67
|
+
code_sample=check.failure_info.code_sample,
|
68
|
+
failures=[],
|
69
|
+
response=response,
|
70
|
+
)
|
71
|
+
failures[case_id].failures.append(check.failure_info.failure)
|
72
|
+
if has_failures:
|
73
|
+
self.cases_with_failures += 1
|
74
|
+
if failures:
|
75
|
+
for group in failures.values():
|
76
|
+
group.failures = sorted(set(group.failures))
|
77
|
+
self.failures[recorder.label] = failures
|
78
|
+
|
79
|
+
|
80
|
+
@dataclass
|
81
|
+
class GroupedFailures:
|
82
|
+
"""Represents failures grouped by case ID."""
|
83
|
+
|
84
|
+
case_id: str
|
85
|
+
code_sample: str
|
86
|
+
failures: list[Failure]
|
87
|
+
response: Response | None
|
88
|
+
|
89
|
+
__slots__ = ("case_id", "code_sample", "failures", "response")
|
90
|
+
|
91
|
+
|
92
|
+
@dataclass
|
93
|
+
class ExecutionContext:
|
94
|
+
"""Storage for the current context of the execution."""
|
95
|
+
|
96
|
+
statistic: Statistic = field(default_factory=Statistic)
|
97
|
+
exit_code: int = 0
|
98
|
+
output_config: OutputConfig = field(default_factory=OutputConfig)
|
99
|
+
initialization_lines: list[str | Generator[str, None, None]] = field(default_factory=list)
|
100
|
+
summary_lines: list[str | Generator[str, None, None]] = field(default_factory=list)
|
101
|
+
seed: int | None = None
|
102
|
+
|
103
|
+
def add_initialization_line(self, line: str | Generator[str, None, None]) -> None:
|
104
|
+
self.initialization_lines.append(line)
|
105
|
+
|
106
|
+
def add_summary_line(self, line: str | Generator[str, None, None]) -> None:
|
107
|
+
self.summary_lines.append(line)
|
108
|
+
|
109
|
+
def on_event(self, event: events.EngineEvent) -> None:
|
110
|
+
if isinstance(event, events.ScenarioFinished):
|
111
|
+
self.statistic.record_checks(event.recorder)
|
112
|
+
elif isinstance(event, events.NonFatalError) or (
|
113
|
+
isinstance(event, events.PhaseFinished)
|
114
|
+
and event.phase.is_enabled
|
115
|
+
and event.status in (Status.FAILURE, Status.ERROR)
|
116
|
+
):
|
117
|
+
self.exit_code = 1
|
@@ -0,0 +1,35 @@
|
|
1
|
+
import time
|
2
|
+
import uuid
|
3
|
+
|
4
|
+
from schemathesis.core import Specification
|
5
|
+
from schemathesis.engine import events
|
6
|
+
from schemathesis.schemas import ApiOperationsCount
|
7
|
+
|
8
|
+
|
9
|
+
class LoadingStarted(events.EngineEvent):
|
10
|
+
__slots__ = ("id", "timestamp", "location")
|
11
|
+
|
12
|
+
def __init__(self, *, location: str) -> None:
|
13
|
+
self.id = uuid.uuid4()
|
14
|
+
self.timestamp = time.time()
|
15
|
+
self.location = location
|
16
|
+
|
17
|
+
|
18
|
+
class LoadingFinished(events.EngineEvent):
|
19
|
+
__slots__ = ("id", "timestamp", "location", "duration", "base_url", "specification", "operations_count")
|
20
|
+
|
21
|
+
def __init__(
|
22
|
+
self,
|
23
|
+
location: str,
|
24
|
+
start_time: float,
|
25
|
+
base_url: str,
|
26
|
+
specification: Specification,
|
27
|
+
operations_count: ApiOperationsCount,
|
28
|
+
) -> None:
|
29
|
+
self.id = uuid.uuid4()
|
30
|
+
self.timestamp = time.time()
|
31
|
+
self.location = location
|
32
|
+
self.duration = self.timestamp - start_time
|
33
|
+
self.base_url = base_url
|
34
|
+
self.specification = specification
|
35
|
+
self.operations_count = operations_count
|
@@ -0,0 +1,138 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import sys
|
4
|
+
from dataclasses import dataclass
|
5
|
+
from typing import Any, Callable
|
6
|
+
|
7
|
+
import click
|
8
|
+
|
9
|
+
from schemathesis.cli.commands.run.context import ExecutionContext
|
10
|
+
from schemathesis.cli.commands.run.events import LoadingFinished, LoadingStarted
|
11
|
+
from schemathesis.cli.commands.run.handlers import display_handler_error
|
12
|
+
from schemathesis.cli.commands.run.handlers.base import EventHandler
|
13
|
+
from schemathesis.cli.commands.run.handlers.cassettes import CassetteConfig, CassetteWriter
|
14
|
+
from schemathesis.cli.commands.run.handlers.junitxml import JunitXMLHandler
|
15
|
+
from schemathesis.cli.commands.run.handlers.output import OutputHandler
|
16
|
+
from schemathesis.cli.commands.run.loaders import AutodetectConfig, load_schema
|
17
|
+
from schemathesis.cli.ext.fs import open_file
|
18
|
+
from schemathesis.core.errors import LoaderError
|
19
|
+
from schemathesis.core.output import OutputConfig
|
20
|
+
from schemathesis.engine import from_schema
|
21
|
+
from schemathesis.engine.config import EngineConfig
|
22
|
+
from schemathesis.engine.events import EventGenerator, FatalError
|
23
|
+
from schemathesis.filters import FilterSet
|
24
|
+
|
25
|
+
CUSTOM_HANDLERS: list[type[EventHandler]] = []
|
26
|
+
|
27
|
+
|
28
|
+
def handler() -> Callable[[type], None]:
|
29
|
+
"""Register a new CLI event handler."""
|
30
|
+
|
31
|
+
def _wrapper(cls: type) -> None:
|
32
|
+
CUSTOM_HANDLERS.append(cls)
|
33
|
+
|
34
|
+
return _wrapper
|
35
|
+
|
36
|
+
|
37
|
+
@dataclass
|
38
|
+
class RunConfig:
|
39
|
+
location: str
|
40
|
+
base_url: str | None
|
41
|
+
filter_set: FilterSet
|
42
|
+
engine: EngineConfig
|
43
|
+
wait_for_schema: float | None
|
44
|
+
rate_limit: str | None
|
45
|
+
output: OutputConfig
|
46
|
+
junit_xml: click.utils.LazyFile | None
|
47
|
+
cassette: CassetteConfig | None
|
48
|
+
args: list[str]
|
49
|
+
params: dict[str, Any]
|
50
|
+
|
51
|
+
|
52
|
+
def execute(config: RunConfig) -> None:
|
53
|
+
event_stream = into_event_stream(config)
|
54
|
+
_execute(event_stream, config)
|
55
|
+
|
56
|
+
|
57
|
+
def into_event_stream(config: RunConfig) -> EventGenerator:
|
58
|
+
loader_config = AutodetectConfig(
|
59
|
+
location=config.location,
|
60
|
+
network=config.engine.network,
|
61
|
+
wait_for_schema=config.wait_for_schema,
|
62
|
+
base_url=config.base_url,
|
63
|
+
rate_limit=config.rate_limit,
|
64
|
+
output=config.output,
|
65
|
+
generation=config.engine.execution.generation,
|
66
|
+
)
|
67
|
+
loading_started = LoadingStarted(location=config.location)
|
68
|
+
yield loading_started
|
69
|
+
|
70
|
+
try:
|
71
|
+
schema = load_schema(loader_config)
|
72
|
+
schema.filter_set = config.filter_set
|
73
|
+
except LoaderError as exc:
|
74
|
+
yield FatalError(exception=exc)
|
75
|
+
return
|
76
|
+
|
77
|
+
yield LoadingFinished(
|
78
|
+
location=config.location,
|
79
|
+
start_time=loading_started.timestamp,
|
80
|
+
base_url=schema.get_base_url(),
|
81
|
+
specification=schema.specification,
|
82
|
+
operations_count=schema.count_operations(),
|
83
|
+
)
|
84
|
+
|
85
|
+
try:
|
86
|
+
yield from from_schema(schema, config=config.engine).execute()
|
87
|
+
except Exception as exc:
|
88
|
+
yield FatalError(exception=exc)
|
89
|
+
|
90
|
+
|
91
|
+
def _execute(event_stream: EventGenerator, config: RunConfig) -> None:
|
92
|
+
handlers: list[EventHandler] = []
|
93
|
+
if config.junit_xml is not None:
|
94
|
+
open_file(config.junit_xml)
|
95
|
+
handlers.append(JunitXMLHandler(config.junit_xml))
|
96
|
+
if config.cassette is not None:
|
97
|
+
open_file(config.cassette.path)
|
98
|
+
handlers.append(CassetteWriter(config=config.cassette))
|
99
|
+
for custom_handler in CUSTOM_HANDLERS:
|
100
|
+
handlers.append(custom_handler(*config.args, **config.params))
|
101
|
+
handlers.append(
|
102
|
+
OutputHandler(
|
103
|
+
workers_num=config.engine.execution.workers_num,
|
104
|
+
rate_limit=config.rate_limit,
|
105
|
+
wait_for_schema=config.wait_for_schema,
|
106
|
+
cassette_config=config.cassette,
|
107
|
+
junit_xml_file=config.junit_xml.name if config.junit_xml is not None else None,
|
108
|
+
)
|
109
|
+
)
|
110
|
+
|
111
|
+
ctx = ExecutionContext(output_config=config.output, seed=config.engine.execution.seed)
|
112
|
+
|
113
|
+
def shutdown() -> None:
|
114
|
+
for _handler in handlers:
|
115
|
+
_handler.shutdown()
|
116
|
+
|
117
|
+
for handler in handlers:
|
118
|
+
handler.start(ctx)
|
119
|
+
|
120
|
+
try:
|
121
|
+
for event in event_stream:
|
122
|
+
ctx.on_event(event)
|
123
|
+
for handler in handlers:
|
124
|
+
try:
|
125
|
+
handler.handle_event(ctx, event)
|
126
|
+
except Exception as exc:
|
127
|
+
# `Abort` is used for handled errors
|
128
|
+
if not isinstance(exc, click.Abort):
|
129
|
+
display_handler_error(handler, exc)
|
130
|
+
raise
|
131
|
+
except Exception as exc:
|
132
|
+
if isinstance(exc, click.Abort):
|
133
|
+
# To avoid showing "Aborted!" message, which is the default behavior in Click
|
134
|
+
sys.exit(1)
|
135
|
+
raise
|
136
|
+
finally:
|
137
|
+
shutdown()
|
138
|
+
sys.exit(ctx.exit_code)
|
@@ -0,0 +1,194 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from dataclasses import dataclass
|
4
|
+
from typing import Callable, Literal, Sequence
|
5
|
+
|
6
|
+
import click
|
7
|
+
|
8
|
+
from schemathesis.cli.ext.groups import grouped_option
|
9
|
+
from schemathesis.filters import FilterSet, expression_to_filter_function, is_deprecated
|
10
|
+
|
11
|
+
|
12
|
+
def _with_filter(*, by: str, mode: Literal["include", "exclude"], modifier: Literal["regex"] | None) -> Callable:
|
13
|
+
"""Generate a CLI option for filtering API operations."""
|
14
|
+
param = f"--{mode}-{by}"
|
15
|
+
action = "include in" if mode == "include" else "exclude from"
|
16
|
+
prop = {
|
17
|
+
"operation-id": "ID",
|
18
|
+
"name": "Operation name",
|
19
|
+
}.get(by, by.capitalize())
|
20
|
+
if modifier:
|
21
|
+
param += f"-{modifier}"
|
22
|
+
prop += " pattern"
|
23
|
+
help_text = f"{prop} to {action} testing."
|
24
|
+
return grouped_option(
|
25
|
+
param,
|
26
|
+
help=help_text,
|
27
|
+
type=str,
|
28
|
+
multiple=modifier is None,
|
29
|
+
)
|
30
|
+
|
31
|
+
|
32
|
+
_BY_VALUES = ("operation-id", "tag", "name", "method", "path")
|
33
|
+
|
34
|
+
|
35
|
+
def with_filters(command: Callable) -> Callable:
|
36
|
+
for by in _BY_VALUES:
|
37
|
+
for mode in ("exclude", "include"):
|
38
|
+
for modifier in ("regex", None):
|
39
|
+
command = _with_filter(by=by, mode=mode, modifier=modifier)(command) # type: ignore[arg-type]
|
40
|
+
return command
|
41
|
+
|
42
|
+
|
43
|
+
@dataclass
|
44
|
+
class FilterArguments:
|
45
|
+
include_path: Sequence[str]
|
46
|
+
include_method: Sequence[str]
|
47
|
+
include_name: Sequence[str]
|
48
|
+
include_tag: Sequence[str]
|
49
|
+
include_operation_id: Sequence[str]
|
50
|
+
include_path_regex: str | None
|
51
|
+
include_method_regex: str | None
|
52
|
+
include_name_regex: str | None
|
53
|
+
include_tag_regex: str | None
|
54
|
+
include_operation_id_regex: str | None
|
55
|
+
|
56
|
+
exclude_path: Sequence[str]
|
57
|
+
exclude_method: Sequence[str]
|
58
|
+
exclude_name: Sequence[str]
|
59
|
+
exclude_tag: Sequence[str]
|
60
|
+
exclude_operation_id: Sequence[str]
|
61
|
+
exclude_path_regex: str | None
|
62
|
+
exclude_method_regex: str | None
|
63
|
+
exclude_name_regex: str | None
|
64
|
+
exclude_tag_regex: str | None
|
65
|
+
exclude_operation_id_regex: str | None
|
66
|
+
|
67
|
+
include_by: str | None
|
68
|
+
exclude_by: str | None
|
69
|
+
exclude_deprecated: bool
|
70
|
+
|
71
|
+
__slots__ = (
|
72
|
+
"include_path",
|
73
|
+
"include_method",
|
74
|
+
"include_name",
|
75
|
+
"include_tag",
|
76
|
+
"include_operation_id",
|
77
|
+
"include_path_regex",
|
78
|
+
"include_method_regex",
|
79
|
+
"include_name_regex",
|
80
|
+
"include_tag_regex",
|
81
|
+
"include_operation_id_regex",
|
82
|
+
"exclude_path",
|
83
|
+
"exclude_method",
|
84
|
+
"exclude_name",
|
85
|
+
"exclude_tag",
|
86
|
+
"exclude_operation_id",
|
87
|
+
"exclude_path_regex",
|
88
|
+
"exclude_method_regex",
|
89
|
+
"exclude_name_regex",
|
90
|
+
"exclude_tag_regex",
|
91
|
+
"exclude_operation_id_regex",
|
92
|
+
"include_by",
|
93
|
+
"exclude_by",
|
94
|
+
"exclude_deprecated",
|
95
|
+
)
|
96
|
+
|
97
|
+
def into(self) -> FilterSet:
|
98
|
+
# Validate unique filter arguments
|
99
|
+
for values, arg_name in (
|
100
|
+
(self.include_path, "--include-path"),
|
101
|
+
(self.include_method, "--include-method"),
|
102
|
+
(self.include_name, "--include-name"),
|
103
|
+
(self.include_tag, "--include-tag"),
|
104
|
+
(self.include_operation_id, "--include-operation-id"),
|
105
|
+
(self.exclude_path, "--exclude-path"),
|
106
|
+
(self.exclude_method, "--exclude-method"),
|
107
|
+
(self.exclude_name, "--exclude-name"),
|
108
|
+
(self.exclude_tag, "--exclude-tag"),
|
109
|
+
(self.exclude_operation_id, "--exclude-operation-id"),
|
110
|
+
):
|
111
|
+
validate_unique_filter(values, arg_name)
|
112
|
+
|
113
|
+
# Convert include/exclude expressions to functions
|
114
|
+
include_by_function = _filter_by_expression_to_func(self.include_by, "--include-by")
|
115
|
+
exclude_by_function = _filter_by_expression_to_func(self.exclude_by, "--exclude-by")
|
116
|
+
|
117
|
+
filter_set = FilterSet()
|
118
|
+
|
119
|
+
# Apply include filters
|
120
|
+
if include_by_function:
|
121
|
+
filter_set.include(include_by_function)
|
122
|
+
for name_ in self.include_name:
|
123
|
+
filter_set.include(name=name_)
|
124
|
+
for method in self.include_method:
|
125
|
+
filter_set.include(method=method)
|
126
|
+
for path in self.include_path:
|
127
|
+
filter_set.include(path=path)
|
128
|
+
for tag in self.include_tag:
|
129
|
+
filter_set.include(tag=tag)
|
130
|
+
for operation_id in self.include_operation_id:
|
131
|
+
filter_set.include(operation_id=operation_id)
|
132
|
+
if (
|
133
|
+
self.include_name_regex
|
134
|
+
or self.include_method_regex
|
135
|
+
or self.include_path_regex
|
136
|
+
or self.include_tag_regex
|
137
|
+
or self.include_operation_id_regex
|
138
|
+
):
|
139
|
+
filter_set.include(
|
140
|
+
name_regex=self.include_name_regex,
|
141
|
+
method_regex=self.include_method_regex,
|
142
|
+
path_regex=self.include_path_regex,
|
143
|
+
tag_regex=self.include_tag_regex,
|
144
|
+
operation_id_regex=self.include_operation_id_regex,
|
145
|
+
)
|
146
|
+
|
147
|
+
# Apply exclude filters
|
148
|
+
if exclude_by_function:
|
149
|
+
filter_set.exclude(exclude_by_function)
|
150
|
+
for name_ in self.exclude_name:
|
151
|
+
filter_set.exclude(name=name_)
|
152
|
+
for method in self.exclude_method:
|
153
|
+
filter_set.exclude(method=method)
|
154
|
+
for path in self.exclude_path:
|
155
|
+
filter_set.exclude(path=path)
|
156
|
+
for tag in self.exclude_tag:
|
157
|
+
filter_set.exclude(tag=tag)
|
158
|
+
for operation_id in self.exclude_operation_id:
|
159
|
+
filter_set.exclude(operation_id=operation_id)
|
160
|
+
if (
|
161
|
+
self.exclude_name_regex
|
162
|
+
or self.exclude_method_regex
|
163
|
+
or self.exclude_path_regex
|
164
|
+
or self.exclude_tag_regex
|
165
|
+
or self.exclude_operation_id_regex
|
166
|
+
):
|
167
|
+
filter_set.exclude(
|
168
|
+
name_regex=self.exclude_name_regex,
|
169
|
+
method_regex=self.exclude_method_regex,
|
170
|
+
path_regex=self.exclude_path_regex,
|
171
|
+
tag_regex=self.exclude_tag_regex,
|
172
|
+
operation_id_regex=self.exclude_operation_id_regex,
|
173
|
+
)
|
174
|
+
|
175
|
+
# Exclude deprecated operations
|
176
|
+
if self.exclude_deprecated:
|
177
|
+
filter_set.exclude(is_deprecated)
|
178
|
+
|
179
|
+
return filter_set
|
180
|
+
|
181
|
+
|
182
|
+
def validate_unique_filter(values: Sequence[str], arg_name: str) -> None:
|
183
|
+
if len(values) != len(set(values)):
|
184
|
+
duplicates = ",".join(sorted({value for value in values if values.count(value) > 1}))
|
185
|
+
raise click.UsageError(f"Duplicate values are not allowed for `{arg_name}`: {duplicates}")
|
186
|
+
|
187
|
+
|
188
|
+
def _filter_by_expression_to_func(value: str | None, arg_name: str) -> Callable | None:
|
189
|
+
if value:
|
190
|
+
try:
|
191
|
+
return expression_to_filter_function(value)
|
192
|
+
except ValueError:
|
193
|
+
raise click.UsageError(f"Invalid expression for {arg_name}: {value}") from None
|
194
|
+
return None
|
@@ -0,0 +1,46 @@
|
|
1
|
+
import click
|
2
|
+
|
3
|
+
from schemathesis.cli.commands.run.handlers.base import EventHandler
|
4
|
+
from schemathesis.cli.commands.run.handlers.cassettes import CassetteWriter
|
5
|
+
from schemathesis.cli.commands.run.handlers.junitxml import JunitXMLHandler
|
6
|
+
from schemathesis.cli.commands.run.handlers.output import OutputHandler
|
7
|
+
from schemathesis.cli.constants import EXTENSIONS_DOCUMENTATION_URL, ISSUE_TRACKER_URL
|
8
|
+
from schemathesis.core.errors import format_exception
|
9
|
+
|
10
|
+
__all__ = [
|
11
|
+
"EventHandler",
|
12
|
+
"CassetteWriter",
|
13
|
+
"JunitXMLHandler",
|
14
|
+
"OutputHandler",
|
15
|
+
"display_handler_error",
|
16
|
+
]
|
17
|
+
|
18
|
+
|
19
|
+
def is_built_in_handler(handler: EventHandler) -> bool:
|
20
|
+
# Look for exact instances, not subclasses
|
21
|
+
return any(type(handler) is class_ for class_ in (CassetteWriter, JunitXMLHandler, OutputHandler))
|
22
|
+
|
23
|
+
|
24
|
+
def display_handler_error(handler: EventHandler, exc: Exception) -> None:
|
25
|
+
"""Display error that happened within."""
|
26
|
+
is_built_in = is_built_in_handler(handler)
|
27
|
+
if is_built_in:
|
28
|
+
click.secho("Internal Error", fg="red", bold=True)
|
29
|
+
click.secho("\nSchemathesis encountered an unexpected issue.")
|
30
|
+
message = format_exception(exc, with_traceback=True)
|
31
|
+
else:
|
32
|
+
click.secho("CLI Handler Error", fg="red", bold=True)
|
33
|
+
click.echo(
|
34
|
+
f"\nAn error occurred within your custom CLI handler `{click.style(handler.__class__.__name__, bold=True)}`."
|
35
|
+
)
|
36
|
+
message = format_exception(exc, with_traceback=True, skip_frames=1)
|
37
|
+
click.secho(f"\n{message}", fg="red")
|
38
|
+
if is_built_in:
|
39
|
+
click.echo(
|
40
|
+
f"\nWe apologize for the inconvenience. This appears to be an internal issue.\n"
|
41
|
+
f"Please consider reporting this error to our issue tracker:\n\n {ISSUE_TRACKER_URL}."
|
42
|
+
)
|
43
|
+
else:
|
44
|
+
click.echo(
|
45
|
+
f"\nFor more information on implementing extensions for Schemathesis CLI, visit {EXTENSIONS_DOCUMENTATION_URL}"
|
46
|
+
)
|
@@ -0,0 +1,18 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import TYPE_CHECKING, Any
|
4
|
+
|
5
|
+
if TYPE_CHECKING:
|
6
|
+
from schemathesis.cli.commands.run.context import ExecutionContext
|
7
|
+
from schemathesis.engine import events
|
8
|
+
|
9
|
+
|
10
|
+
class EventHandler:
|
11
|
+
def __init__(self, *args: Any, **params: Any) -> None: ...
|
12
|
+
|
13
|
+
def handle_event(self, ctx: ExecutionContext, event: events.EngineEvent) -> None:
|
14
|
+
raise NotImplementedError
|
15
|
+
|
16
|
+
def start(self, ctx: ExecutionContext) -> None: ...
|
17
|
+
|
18
|
+
def shutdown(self) -> None: ...
|