schemathesis 3.39.16__py3-none-any.whl → 4.0.0__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 +41 -79
- schemathesis/auths.py +111 -122
- schemathesis/checks.py +169 -60
- schemathesis/cli/__init__.py +15 -2117
- schemathesis/cli/commands/__init__.py +85 -0
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +590 -0
- schemathesis/cli/commands/run/context.py +204 -0
- schemathesis/cli/commands/run/events.py +60 -0
- schemathesis/cli/commands/run/executor.py +157 -0
- schemathesis/cli/commands/run/filters.py +53 -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 +474 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +55 -0
- schemathesis/cli/commands/run/handlers/output.py +1628 -0
- schemathesis/cli/commands/run/loaders.py +114 -0
- schemathesis/cli/commands/run/validation.py +246 -0
- schemathesis/cli/constants.py +5 -58
- schemathesis/cli/core.py +19 -0
- schemathesis/cli/ext/fs.py +16 -0
- schemathesis/cli/ext/groups.py +84 -0
- schemathesis/cli/{options.py → ext/options.py} +36 -34
- schemathesis/config/__init__.py +189 -0
- schemathesis/config/_auth.py +51 -0
- schemathesis/config/_checks.py +268 -0
- schemathesis/config/_diff_base.py +99 -0
- schemathesis/config/_env.py +21 -0
- schemathesis/config/_error.py +156 -0
- schemathesis/config/_generation.py +149 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +327 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +187 -0
- schemathesis/config/_projects.py +527 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +120 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/_warnings.py +25 -0
- schemathesis/config/schema.json +885 -0
- schemathesis/core/__init__.py +67 -0
- schemathesis/core/compat.py +32 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +58 -0
- schemathesis/core/deserialization.py +65 -0
- schemathesis/core/errors.py +459 -0
- schemathesis/core/failures.py +315 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/hooks.py +20 -0
- schemathesis/core/loaders.py +104 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/{transports/content_types.py → core/media_types.py} +14 -12
- schemathesis/core/output/__init__.py +46 -0
- schemathesis/core/output/sanitization.py +54 -0
- schemathesis/{throttling.py → core/rate_limit.py} +16 -17
- schemathesis/core/registries.py +31 -0
- schemathesis/core/transforms.py +113 -0
- schemathesis/core/transport.py +223 -0
- schemathesis/core/validation.py +54 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +28 -0
- schemathesis/engine/context.py +118 -0
- schemathesis/engine/control.py +36 -0
- schemathesis/engine/core.py +169 -0
- schemathesis/engine/errors.py +464 -0
- schemathesis/engine/events.py +258 -0
- schemathesis/engine/phases/__init__.py +88 -0
- schemathesis/{runner → engine/phases}/probes.py +52 -68
- schemathesis/engine/phases/stateful/__init__.py +68 -0
- schemathesis/engine/phases/stateful/_executor.py +356 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +212 -0
- schemathesis/engine/phases/unit/_executor.py +416 -0
- schemathesis/engine/phases/unit/_pool.py +82 -0
- schemathesis/engine/recorder.py +247 -0
- schemathesis/errors.py +43 -0
- schemathesis/filters.py +17 -98
- schemathesis/generation/__init__.py +5 -33
- schemathesis/generation/case.py +317 -0
- schemathesis/generation/coverage.py +282 -175
- schemathesis/generation/hypothesis/__init__.py +36 -0
- schemathesis/generation/hypothesis/builder.py +800 -0
- schemathesis/generation/{_hypothesis.py → hypothesis/examples.py} +2 -11
- 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/metrics.py +93 -0
- schemathesis/generation/modes.py +20 -0
- schemathesis/generation/overrides.py +116 -0
- schemathesis/generation/stateful/__init__.py +37 -0
- schemathesis/generation/stateful/state_machine.py +278 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +284 -0
- schemathesis/hooks.py +80 -101
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +455 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +72 -0
- schemathesis/openapi/loaders.py +313 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +281 -0
- schemathesis/pytest/loaders.py +36 -0
- schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +128 -108
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +537 -273
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/_cache.py +1 -2
- schemathesis/specs/graphql/scalars.py +42 -6
- schemathesis/specs/graphql/schemas.py +141 -137
- schemathesis/specs/graphql/validation.py +11 -17
- schemathesis/specs/openapi/__init__.py +6 -1
- schemathesis/specs/openapi/_cache.py +1 -2
- schemathesis/specs/openapi/_hypothesis.py +142 -156
- schemathesis/specs/openapi/checks.py +368 -257
- schemathesis/specs/openapi/converter.py +4 -4
- schemathesis/specs/openapi/definitions.py +1 -1
- schemathesis/specs/openapi/examples.py +23 -21
- schemathesis/specs/openapi/expressions/__init__.py +31 -19
- schemathesis/specs/openapi/expressions/extractors.py +1 -4
- schemathesis/specs/openapi/expressions/lexer.py +1 -1
- schemathesis/specs/openapi/expressions/nodes.py +36 -41
- schemathesis/specs/openapi/expressions/parser.py +1 -1
- schemathesis/specs/openapi/formats.py +35 -7
- schemathesis/specs/openapi/media_types.py +53 -12
- schemathesis/specs/openapi/negative/__init__.py +7 -4
- schemathesis/specs/openapi/negative/mutations.py +6 -5
- schemathesis/specs/openapi/parameters.py +7 -10
- schemathesis/specs/openapi/patterns.py +94 -31
- schemathesis/specs/openapi/references.py +12 -53
- schemathesis/specs/openapi/schemas.py +233 -307
- schemathesis/specs/openapi/security.py +1 -1
- schemathesis/specs/openapi/serialization.py +12 -6
- schemathesis/specs/openapi/stateful/__init__.py +268 -133
- schemathesis/specs/openapi/stateful/control.py +87 -0
- schemathesis/specs/openapi/stateful/links.py +209 -0
- schemathesis/transport/__init__.py +142 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +124 -0
- schemathesis/transport/requests.py +244 -0
- schemathesis/{_xml.py → transport/serialization.py} +69 -11
- schemathesis/transport/wsgi.py +171 -0
- schemathesis-4.0.0.dist-info/METADATA +204 -0
- schemathesis-4.0.0.dist-info/RECORD +164 -0
- {schemathesis-3.39.16.dist-info → schemathesis-4.0.0.dist-info}/entry_points.txt +1 -1
- {schemathesis-3.39.16.dist-info → schemathesis-4.0.0.dist-info}/licenses/LICENSE +1 -1
- schemathesis/_compat.py +0 -74
- schemathesis/_dependency_versions.py +0 -19
- schemathesis/_hypothesis.py +0 -717
- schemathesis/_override.py +0 -50
- schemathesis/_patches.py +0 -21
- schemathesis/_rate_limiter.py +0 -7
- schemathesis/cli/callbacks.py +0 -466
- schemathesis/cli/cassettes.py +0 -561
- schemathesis/cli/context.py +0 -75
- schemathesis/cli/debug.py +0 -27
- schemathesis/cli/handlers.py +0 -19
- schemathesis/cli/junitxml.py +0 -124
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -920
- schemathesis/cli/output/short.py +0 -59
- schemathesis/cli/reporting.py +0 -79
- schemathesis/cli/sanitization.py +0 -26
- schemathesis/code_samples.py +0 -151
- schemathesis/constants.py +0 -54
- schemathesis/contrib/__init__.py +0 -11
- schemathesis/contrib/openapi/__init__.py +0 -11
- schemathesis/contrib/openapi/fill_missing_examples.py +0 -24
- schemathesis/contrib/openapi/formats/__init__.py +0 -9
- schemathesis/contrib/openapi/formats/uuid.py +0 -16
- schemathesis/contrib/unique_data.py +0 -41
- schemathesis/exceptions.py +0 -571
- schemathesis/experimental/__init__.py +0 -109
- schemathesis/extra/_aiohttp.py +0 -28
- schemathesis/extra/_flask.py +0 -13
- schemathesis/extra/_server.py +0 -18
- schemathesis/failures.py +0 -284
- schemathesis/fixups/__init__.py +0 -37
- schemathesis/fixups/fast_api.py +0 -41
- schemathesis/fixups/utf8_bom.py +0 -28
- schemathesis/generation/_methods.py +0 -44
- schemathesis/graphql.py +0 -3
- schemathesis/internal/__init__.py +0 -7
- schemathesis/internal/checks.py +0 -86
- schemathesis/internal/copy.py +0 -32
- schemathesis/internal/datetime.py +0 -5
- schemathesis/internal/deprecation.py +0 -37
- schemathesis/internal/diff.py +0 -15
- schemathesis/internal/extensions.py +0 -27
- schemathesis/internal/jsonschema.py +0 -36
- schemathesis/internal/output.py +0 -68
- schemathesis/internal/transformation.py +0 -26
- schemathesis/internal/validation.py +0 -34
- schemathesis/lazy.py +0 -474
- schemathesis/loaders.py +0 -122
- schemathesis/models.py +0 -1341
- schemathesis/parameters.py +0 -90
- schemathesis/runner/__init__.py +0 -605
- schemathesis/runner/events.py +0 -389
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/context.py +0 -88
- schemathesis/runner/impl/core.py +0 -1280
- schemathesis/runner/impl/solo.py +0 -80
- schemathesis/runner/impl/threadpool.py +0 -391
- schemathesis/runner/serialization.py +0 -544
- schemathesis/sanitization.py +0 -252
- schemathesis/serializers.py +0 -328
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -11
- schemathesis/service/ci.py +0 -202
- schemathesis/service/client.py +0 -133
- schemathesis/service/constants.py +0 -38
- schemathesis/service/events.py +0 -61
- schemathesis/service/extensions.py +0 -224
- schemathesis/service/hosts.py +0 -111
- schemathesis/service/metadata.py +0 -71
- schemathesis/service/models.py +0 -258
- schemathesis/service/report.py +0 -255
- schemathesis/service/serialization.py +0 -173
- schemathesis/service/usage.py +0 -66
- schemathesis/specs/graphql/loaders.py +0 -364
- schemathesis/specs/openapi/expressions/context.py +0 -16
- schemathesis/specs/openapi/links.py +0 -389
- schemathesis/specs/openapi/loaders.py +0 -707
- schemathesis/specs/openapi/stateful/statistic.py +0 -198
- schemathesis/specs/openapi/stateful/types.py +0 -14
- schemathesis/specs/openapi/validation.py +0 -26
- schemathesis/stateful/__init__.py +0 -147
- schemathesis/stateful/config.py +0 -97
- schemathesis/stateful/context.py +0 -135
- schemathesis/stateful/events.py +0 -274
- schemathesis/stateful/runner.py +0 -309
- schemathesis/stateful/sink.py +0 -68
- schemathesis/stateful/state_machine.py +0 -328
- schemathesis/stateful/statistic.py +0 -22
- schemathesis/stateful/validation.py +0 -100
- schemathesis/targets.py +0 -77
- schemathesis/transports/__init__.py +0 -369
- schemathesis/transports/asgi.py +0 -7
- schemathesis/transports/auth.py +0 -38
- schemathesis/transports/headers.py +0 -36
- schemathesis/transports/responses.py +0 -57
- schemathesis/types.py +0 -44
- schemathesis/utils.py +0 -164
- schemathesis-3.39.16.dist-info/METADATA +0 -293
- schemathesis-3.39.16.dist-info/RECORD +0 -160
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
- /schemathesis/{_lazy_import.py → core/lazy_import.py} +0 -0
- /schemathesis/{internal → core}/result.py +0 -0
- {schemathesis-3.39.16.dist-info → schemathesis-4.0.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,204 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from dataclasses import dataclass, field
|
4
|
+
from typing import TYPE_CHECKING, Callable, Generator
|
5
|
+
|
6
|
+
from schemathesis.cli.commands.run.events import LoadingFinished
|
7
|
+
from schemathesis.config import ProjectConfig
|
8
|
+
from schemathesis.core.failures import Failure
|
9
|
+
from schemathesis.core.result import Err, Ok
|
10
|
+
from schemathesis.core.transforms import UNRESOLVABLE
|
11
|
+
from schemathesis.core.transport import Response
|
12
|
+
from schemathesis.engine import Status, events
|
13
|
+
from schemathesis.engine.recorder import CaseNode, ScenarioRecorder
|
14
|
+
from schemathesis.generation.case import Case
|
15
|
+
from schemathesis.schemas import APIOperation
|
16
|
+
|
17
|
+
if TYPE_CHECKING:
|
18
|
+
from schemathesis.generation.stateful.state_machine import ExtractionFailure
|
19
|
+
|
20
|
+
|
21
|
+
@dataclass
|
22
|
+
class Statistic:
|
23
|
+
"""Running statistics about test execution."""
|
24
|
+
|
25
|
+
failures: dict[str, dict[str, GroupedFailures]]
|
26
|
+
# Track first case_id where each unique failure was found
|
27
|
+
unique_failures_map: dict[Failure, str]
|
28
|
+
|
29
|
+
extraction_failures: set[ExtractionFailure]
|
30
|
+
|
31
|
+
tested_operations: set[str]
|
32
|
+
|
33
|
+
total_cases: int
|
34
|
+
cases_with_failures: int
|
35
|
+
cases_without_checks: int
|
36
|
+
|
37
|
+
__slots__ = (
|
38
|
+
"failures",
|
39
|
+
"unique_failures_map",
|
40
|
+
"extraction_failures",
|
41
|
+
"tested_operations",
|
42
|
+
"total_cases",
|
43
|
+
"cases_with_failures",
|
44
|
+
"cases_without_checks",
|
45
|
+
)
|
46
|
+
|
47
|
+
def __init__(self) -> None:
|
48
|
+
self.failures = {}
|
49
|
+
self.unique_failures_map = {}
|
50
|
+
self.extraction_failures = set()
|
51
|
+
self.tested_operations = set()
|
52
|
+
self.total_cases = 0
|
53
|
+
self.cases_with_failures = 0
|
54
|
+
self.cases_without_checks = 0
|
55
|
+
|
56
|
+
def on_scenario_finished(self, recorder: ScenarioRecorder) -> None:
|
57
|
+
"""Update statistics and store failures from a new batch of checks."""
|
58
|
+
from schemathesis.generation.stateful.state_machine import ExtractionFailure
|
59
|
+
|
60
|
+
failures = self.failures.get(recorder.label, {})
|
61
|
+
|
62
|
+
self.total_cases += len(recorder.cases)
|
63
|
+
|
64
|
+
extraction_failures = set()
|
65
|
+
|
66
|
+
def collect_history(node: CaseNode, response: Response) -> list[tuple[Case, Response]]:
|
67
|
+
history = [(node.value, response)]
|
68
|
+
current = node
|
69
|
+
while current.parent_id is not None:
|
70
|
+
current_response = recorder.find_response(case_id=current.parent_id)
|
71
|
+
# We need a response to get there, so it should be present
|
72
|
+
assert current_response is not None
|
73
|
+
current = recorder.cases[current.parent_id]
|
74
|
+
history.append((current.value, current_response))
|
75
|
+
return history
|
76
|
+
|
77
|
+
for case_id, case in recorder.cases.items():
|
78
|
+
checks = recorder.checks.get(case_id, [])
|
79
|
+
|
80
|
+
if not checks:
|
81
|
+
self.cases_without_checks += 1
|
82
|
+
continue
|
83
|
+
|
84
|
+
self.tested_operations.add(case.value.operation.label)
|
85
|
+
has_failures = False
|
86
|
+
current_case_failures = []
|
87
|
+
last_failure_info = None
|
88
|
+
|
89
|
+
for check in checks:
|
90
|
+
if check.failure_info is not None:
|
91
|
+
failure = check.failure_info.failure
|
92
|
+
|
93
|
+
# Check if this is a new unique failure
|
94
|
+
if failure not in self.unique_failures_map:
|
95
|
+
last_failure_info = check.failure_info
|
96
|
+
self.unique_failures_map[failure] = case_id
|
97
|
+
current_case_failures.append(failure)
|
98
|
+
has_failures = True
|
99
|
+
else:
|
100
|
+
# This failure was already seen - skip it
|
101
|
+
continue
|
102
|
+
|
103
|
+
if current_case_failures:
|
104
|
+
assert last_failure_info is not None
|
105
|
+
failures[case_id] = GroupedFailures(
|
106
|
+
case_id=case_id,
|
107
|
+
code_sample=last_failure_info.code_sample,
|
108
|
+
failures=current_case_failures,
|
109
|
+
response=recorder.interactions[case_id].response,
|
110
|
+
)
|
111
|
+
|
112
|
+
if has_failures:
|
113
|
+
self.cases_with_failures += 1
|
114
|
+
|
115
|
+
if case.transition is None:
|
116
|
+
continue
|
117
|
+
transition = case.transition
|
118
|
+
parent = recorder.cases[transition.parent_id]
|
119
|
+
response = recorder.find_response(case_id=parent.value.id)
|
120
|
+
# We need a response to get there, so it should be present
|
121
|
+
assert response is not None
|
122
|
+
|
123
|
+
for params in transition.parameters.values():
|
124
|
+
for parameter, extracted in params.items():
|
125
|
+
if isinstance(extracted.value, Ok) and extracted.value.ok() is UNRESOLVABLE:
|
126
|
+
history = collect_history(parent, response)
|
127
|
+
extraction_failures.add(
|
128
|
+
ExtractionFailure(
|
129
|
+
id=transition.id,
|
130
|
+
case_id=case_id,
|
131
|
+
source=parent.value.operation.label,
|
132
|
+
target=case.value.operation.label,
|
133
|
+
parameter_name=parameter,
|
134
|
+
expression=extracted.definition,
|
135
|
+
history=history,
|
136
|
+
response=response,
|
137
|
+
error=None,
|
138
|
+
)
|
139
|
+
)
|
140
|
+
elif isinstance(extracted.value, Err):
|
141
|
+
history = collect_history(parent, response)
|
142
|
+
extraction_failures.add(
|
143
|
+
ExtractionFailure(
|
144
|
+
id=transition.id,
|
145
|
+
case_id=case_id,
|
146
|
+
source=parent.value.operation.label,
|
147
|
+
target=case.value.operation.label,
|
148
|
+
parameter_name=parameter,
|
149
|
+
expression=extracted.definition,
|
150
|
+
history=history,
|
151
|
+
response=response,
|
152
|
+
error=extracted.value.err(),
|
153
|
+
)
|
154
|
+
)
|
155
|
+
|
156
|
+
if failures:
|
157
|
+
for group in failures.values():
|
158
|
+
group.failures = sorted(set(group.failures))
|
159
|
+
self.failures[recorder.label] = failures
|
160
|
+
|
161
|
+
if extraction_failures:
|
162
|
+
self.extraction_failures.update(extraction_failures)
|
163
|
+
|
164
|
+
|
165
|
+
@dataclass
|
166
|
+
class GroupedFailures:
|
167
|
+
"""Represents failures grouped by case ID."""
|
168
|
+
|
169
|
+
case_id: str
|
170
|
+
code_sample: str
|
171
|
+
failures: list[Failure]
|
172
|
+
response: Response | None
|
173
|
+
|
174
|
+
__slots__ = ("case_id", "code_sample", "failures", "response")
|
175
|
+
|
176
|
+
|
177
|
+
@dataclass
|
178
|
+
class ExecutionContext:
|
179
|
+
"""Storage for the current context of the execution."""
|
180
|
+
|
181
|
+
config: ProjectConfig
|
182
|
+
find_operation_by_label: Callable[[str], APIOperation | None] | None = None
|
183
|
+
statistic: Statistic = field(default_factory=Statistic)
|
184
|
+
exit_code: int = 0
|
185
|
+
initialization_lines: list[str | Generator[str, None, None]] = field(default_factory=list)
|
186
|
+
summary_lines: list[str | Generator[str, None, None]] = field(default_factory=list)
|
187
|
+
|
188
|
+
def add_initialization_line(self, line: str | Generator[str, None, None]) -> None:
|
189
|
+
self.initialization_lines.append(line)
|
190
|
+
|
191
|
+
def add_summary_line(self, line: str | Generator[str, None, None]) -> None:
|
192
|
+
self.summary_lines.append(line)
|
193
|
+
|
194
|
+
def on_event(self, event: events.EngineEvent) -> None:
|
195
|
+
if isinstance(event, LoadingFinished):
|
196
|
+
self.find_operation_by_label = event.find_operation_by_label
|
197
|
+
if isinstance(event, events.ScenarioFinished):
|
198
|
+
self.statistic.on_scenario_finished(event.recorder)
|
199
|
+
elif isinstance(event, events.NonFatalError) or (
|
200
|
+
isinstance(event, events.PhaseFinished)
|
201
|
+
and event.phase.is_enabled
|
202
|
+
and event.status in (Status.FAILURE, Status.ERROR)
|
203
|
+
):
|
204
|
+
self.exit_code = 1
|
@@ -0,0 +1,60 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import time
|
4
|
+
import uuid
|
5
|
+
from typing import Callable
|
6
|
+
|
7
|
+
from schemathesis.config import ProjectConfig
|
8
|
+
from schemathesis.core import Specification
|
9
|
+
from schemathesis.engine import events
|
10
|
+
from schemathesis.schemas import APIOperation, ApiStatistic
|
11
|
+
|
12
|
+
|
13
|
+
class LoadingStarted(events.EngineEvent):
|
14
|
+
__slots__ = ("id", "timestamp", "location")
|
15
|
+
|
16
|
+
def __init__(self, *, location: str) -> None:
|
17
|
+
self.id = uuid.uuid4()
|
18
|
+
self.timestamp = time.time()
|
19
|
+
self.location = location
|
20
|
+
|
21
|
+
|
22
|
+
class LoadingFinished(events.EngineEvent):
|
23
|
+
__slots__ = (
|
24
|
+
"id",
|
25
|
+
"timestamp",
|
26
|
+
"location",
|
27
|
+
"duration",
|
28
|
+
"base_url",
|
29
|
+
"base_path",
|
30
|
+
"specification",
|
31
|
+
"statistic",
|
32
|
+
"schema",
|
33
|
+
"config",
|
34
|
+
"find_operation_by_label",
|
35
|
+
)
|
36
|
+
|
37
|
+
def __init__(
|
38
|
+
self,
|
39
|
+
*,
|
40
|
+
location: str,
|
41
|
+
start_time: float,
|
42
|
+
base_url: str,
|
43
|
+
base_path: str,
|
44
|
+
specification: Specification,
|
45
|
+
statistic: ApiStatistic,
|
46
|
+
schema: dict,
|
47
|
+
config: ProjectConfig,
|
48
|
+
find_operation_by_label: Callable[[str], APIOperation | None],
|
49
|
+
) -> None:
|
50
|
+
self.id = uuid.uuid4()
|
51
|
+
self.timestamp = time.time()
|
52
|
+
self.location = location
|
53
|
+
self.duration = self.timestamp - start_time
|
54
|
+
self.base_url = base_url
|
55
|
+
self.specification = specification
|
56
|
+
self.statistic = statistic
|
57
|
+
self.schema = schema
|
58
|
+
self.base_path = base_path
|
59
|
+
self.config = config
|
60
|
+
self.find_operation_by_label = find_operation_by_label
|
@@ -0,0 +1,157 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import sys
|
4
|
+
from typing import Any, Callable
|
5
|
+
|
6
|
+
import click
|
7
|
+
|
8
|
+
from schemathesis.cli.commands.run.context import ExecutionContext
|
9
|
+
from schemathesis.cli.commands.run.events import LoadingFinished, LoadingStarted
|
10
|
+
from schemathesis.cli.commands.run.handlers import display_handler_error
|
11
|
+
from schemathesis.cli.commands.run.handlers.base import EventHandler
|
12
|
+
from schemathesis.cli.commands.run.handlers.cassettes import CassetteWriter
|
13
|
+
from schemathesis.cli.commands.run.handlers.junitxml import JunitXMLHandler
|
14
|
+
from schemathesis.cli.commands.run.handlers.output import OutputHandler
|
15
|
+
from schemathesis.cli.commands.run.loaders import load_schema
|
16
|
+
from schemathesis.cli.ext.fs import open_file
|
17
|
+
from schemathesis.config import ProjectConfig, ReportFormat
|
18
|
+
from schemathesis.core.errors import LoaderError
|
19
|
+
from schemathesis.core.fs import file_exists
|
20
|
+
from schemathesis.engine import from_schema
|
21
|
+
from schemathesis.engine.events import EventGenerator, FatalError, Interrupted
|
22
|
+
|
23
|
+
CUSTOM_HANDLERS: list[type[EventHandler]] = []
|
24
|
+
|
25
|
+
|
26
|
+
def handler() -> Callable[[type], None]:
|
27
|
+
"""Register a new CLI event handler."""
|
28
|
+
|
29
|
+
def _wrapper(cls: type) -> None:
|
30
|
+
CUSTOM_HANDLERS.append(cls)
|
31
|
+
|
32
|
+
return _wrapper
|
33
|
+
|
34
|
+
|
35
|
+
def execute(
|
36
|
+
*,
|
37
|
+
location: str,
|
38
|
+
config: ProjectConfig,
|
39
|
+
filter_set: dict[str, Any],
|
40
|
+
args: list[str],
|
41
|
+
params: dict[str, Any],
|
42
|
+
) -> None:
|
43
|
+
event_stream = into_event_stream(location=location, config=config, filter_set=filter_set)
|
44
|
+
_execute(event_stream, config=config, args=args, params=params)
|
45
|
+
|
46
|
+
|
47
|
+
MISSING_BASE_URL_MESSAGE = "The `--url` option is required when specifying a schema via a file."
|
48
|
+
|
49
|
+
|
50
|
+
def into_event_stream(*, location: str, config: ProjectConfig, filter_set: dict[str, Any]) -> EventGenerator:
|
51
|
+
# The whole engine idea is that it communicates with the outside via events, so handlers can react to them
|
52
|
+
# For this reason, even schema loading is done via a separate set of events.
|
53
|
+
loading_started = LoadingStarted(location=location)
|
54
|
+
yield loading_started
|
55
|
+
|
56
|
+
try:
|
57
|
+
schema = load_schema(location=location, config=config)
|
58
|
+
# Schemas don't (yet?) use configs for deciding what operations should be tested, so
|
59
|
+
# a separate FilterSet passed there. It combines both config file filters + CLI options
|
60
|
+
schema.filter_set = schema.config.operations.create_filter_set(**filter_set)
|
61
|
+
if file_exists(location) and schema.config.base_url is None:
|
62
|
+
raise click.UsageError(MISSING_BASE_URL_MESSAGE)
|
63
|
+
except KeyboardInterrupt:
|
64
|
+
yield Interrupted(phase=None)
|
65
|
+
return
|
66
|
+
except LoaderError as exc:
|
67
|
+
yield FatalError(exception=exc)
|
68
|
+
return
|
69
|
+
|
70
|
+
yield LoadingFinished(
|
71
|
+
location=location,
|
72
|
+
start_time=loading_started.timestamp,
|
73
|
+
base_url=schema.get_base_url(),
|
74
|
+
specification=schema.specification,
|
75
|
+
statistic=schema.statistic,
|
76
|
+
schema=schema.raw_schema,
|
77
|
+
config=schema.config,
|
78
|
+
base_path=schema.base_path,
|
79
|
+
find_operation_by_label=schema.find_operation_by_label,
|
80
|
+
)
|
81
|
+
|
82
|
+
try:
|
83
|
+
yield from from_schema(schema).execute()
|
84
|
+
except Exception as exc:
|
85
|
+
yield FatalError(exception=exc)
|
86
|
+
|
87
|
+
|
88
|
+
def initialize_handlers(
|
89
|
+
*,
|
90
|
+
config: ProjectConfig,
|
91
|
+
args: list[str],
|
92
|
+
params: dict[str, Any],
|
93
|
+
) -> list[EventHandler]:
|
94
|
+
"""Create event handlers based on run configuration."""
|
95
|
+
handlers: list[EventHandler] = []
|
96
|
+
|
97
|
+
if config.reports.junit.enabled:
|
98
|
+
path = config.reports.get_path(ReportFormat.JUNIT)
|
99
|
+
open_file(path)
|
100
|
+
handlers.append(JunitXMLHandler(path))
|
101
|
+
for format, report in (
|
102
|
+
(ReportFormat.VCR, config.reports.vcr),
|
103
|
+
(ReportFormat.HAR, config.reports.har),
|
104
|
+
):
|
105
|
+
if report.enabled:
|
106
|
+
path = config.reports.get_path(format)
|
107
|
+
open_file(path)
|
108
|
+
handlers.append(CassetteWriter(format=format, path=path, config=config))
|
109
|
+
|
110
|
+
for custom_handler in CUSTOM_HANDLERS:
|
111
|
+
handlers.append(custom_handler(*args, **params))
|
112
|
+
|
113
|
+
handlers.append(OutputHandler(config=config))
|
114
|
+
|
115
|
+
return handlers
|
116
|
+
|
117
|
+
|
118
|
+
def _execute(
|
119
|
+
event_stream: EventGenerator,
|
120
|
+
*,
|
121
|
+
config: ProjectConfig,
|
122
|
+
args: list[str],
|
123
|
+
params: dict[str, Any],
|
124
|
+
) -> None:
|
125
|
+
handlers: list[EventHandler] = []
|
126
|
+
ctx: ExecutionContext | None = None
|
127
|
+
|
128
|
+
def shutdown() -> None:
|
129
|
+
if ctx is not None:
|
130
|
+
for _handler in handlers:
|
131
|
+
_handler.shutdown(ctx)
|
132
|
+
|
133
|
+
try:
|
134
|
+
handlers = initialize_handlers(config=config, args=args, params=params)
|
135
|
+
ctx = ExecutionContext(config=config)
|
136
|
+
|
137
|
+
for handler in handlers:
|
138
|
+
handler.start(ctx)
|
139
|
+
|
140
|
+
for event in event_stream:
|
141
|
+
ctx.on_event(event)
|
142
|
+
for handler in handlers:
|
143
|
+
try:
|
144
|
+
handler.handle_event(ctx, event)
|
145
|
+
except Exception as exc:
|
146
|
+
# `Abort` is used for handled errors
|
147
|
+
if not isinstance(exc, click.Abort):
|
148
|
+
display_handler_error(handler, exc)
|
149
|
+
raise
|
150
|
+
except Exception as exc:
|
151
|
+
if isinstance(exc, click.Abort):
|
152
|
+
# To avoid showing "Aborted!" message, which is the default behavior in Click
|
153
|
+
sys.exit(1)
|
154
|
+
raise
|
155
|
+
finally:
|
156
|
+
shutdown()
|
157
|
+
sys.exit(ctx.exit_code)
|
@@ -0,0 +1,53 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from functools import partial
|
4
|
+
from typing import Callable, Literal
|
5
|
+
|
6
|
+
import click
|
7
|
+
|
8
|
+
from schemathesis.cli.ext.groups import grouped_option
|
9
|
+
|
10
|
+
|
11
|
+
def _with_filter(*, by: str, mode: Literal["include", "exclude"], modifier: Literal["regex"] | None) -> Callable:
|
12
|
+
"""Generate a CLI option for filtering API operations."""
|
13
|
+
param = f"--{mode}-{by}"
|
14
|
+
action = "include in" if mode == "include" else "exclude from"
|
15
|
+
prop = {
|
16
|
+
"operation-id": "ID",
|
17
|
+
"name": "Operation name",
|
18
|
+
}.get(by, by.capitalize())
|
19
|
+
callback = None
|
20
|
+
if modifier:
|
21
|
+
param += f"-{modifier}"
|
22
|
+
prop += " pattern"
|
23
|
+
else:
|
24
|
+
callback = partial(validate_filter, arg_name=param)
|
25
|
+
help_text = f"{prop} to {action} testing."
|
26
|
+
return grouped_option(
|
27
|
+
param,
|
28
|
+
help=help_text,
|
29
|
+
type=str,
|
30
|
+
multiple=modifier is None,
|
31
|
+
callback=callback,
|
32
|
+
hidden=True,
|
33
|
+
)
|
34
|
+
|
35
|
+
|
36
|
+
def validate_filter(
|
37
|
+
ctx: click.core.Context, param: click.core.Parameter, raw_value: list[str], arg_name: str
|
38
|
+
) -> list[str]:
|
39
|
+
if len(raw_value) != len(set(raw_value)):
|
40
|
+
duplicates = ",".join(sorted({value for value in raw_value if raw_value.count(value) > 1}))
|
41
|
+
raise click.UsageError(f"Duplicate values are not allowed for `{arg_name}`: {duplicates}")
|
42
|
+
return raw_value
|
43
|
+
|
44
|
+
|
45
|
+
_BY_VALUES = ("operation-id", "tag", "name", "method", "path")
|
46
|
+
|
47
|
+
|
48
|
+
def with_filters(command: Callable) -> Callable:
|
49
|
+
for by in _BY_VALUES:
|
50
|
+
for mode in ("exclude", "include"):
|
51
|
+
for modifier in ("regex", None):
|
52
|
+
command = _with_filter(by=by, mode=mode, modifier=modifier)(command) # type: ignore[arg-type]
|
53
|
+
return command
|
@@ -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, ctx: ExecutionContext) -> None: ...
|