schemathesis 3.25.6__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 -1760
- 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/{runner → engine/phases}/probes.py +50 -67
- 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 +139 -23
- 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 +478 -369
- 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.6.dist-info → schemathesis-4.0.0a1.dist-info}/WHEEL +1 -1
- {schemathesis-3.25.6.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 -58
- 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 -790
- schemathesis/cli/output/short.py +0 -44
- 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 -1234
- schemathesis/parameters.py +0 -86
- schemathesis/runner/__init__.py +0 -570
- schemathesis/runner/events.py +0 -329
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/core.py +0 -1035
- 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 -323
- 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 -199
- 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.6.dist-info/METADATA +0 -356
- schemathesis-3.25.6.dist-info/RECORD +0 -134
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
- {schemathesis-3.25.6.dist-info → schemathesis-4.0.0a1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,494 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import datetime
|
4
|
+
import enum
|
5
|
+
import json
|
6
|
+
import sys
|
7
|
+
import threading
|
8
|
+
from dataclasses import dataclass, field
|
9
|
+
from http.cookies import SimpleCookie
|
10
|
+
from queue import Queue
|
11
|
+
from typing import IO, Callable, Iterator
|
12
|
+
from urllib.parse import parse_qsl, urlparse
|
13
|
+
|
14
|
+
import click
|
15
|
+
import harfile
|
16
|
+
|
17
|
+
from schemathesis.cli.commands.run.context import ExecutionContext
|
18
|
+
from schemathesis.cli.commands.run.handlers.base import EventHandler
|
19
|
+
from schemathesis.core.output.sanitization import sanitize_url, sanitize_value
|
20
|
+
from schemathesis.core.transforms import deepclone
|
21
|
+
from schemathesis.core.transport import Response
|
22
|
+
from schemathesis.core.version import SCHEMATHESIS_VERSION
|
23
|
+
from schemathesis.engine import Status, events
|
24
|
+
from schemathesis.engine.recorder import CheckNode, Request, ScenarioRecorder
|
25
|
+
from schemathesis.generation.meta import CoveragePhaseData
|
26
|
+
|
27
|
+
# Wait until the worker terminates
|
28
|
+
WRITER_WORKER_JOIN_TIMEOUT = 1
|
29
|
+
|
30
|
+
|
31
|
+
class CassetteFormat(str, enum.Enum):
|
32
|
+
"""Type of the cassette."""
|
33
|
+
|
34
|
+
VCR = "vcr"
|
35
|
+
HAR = "har"
|
36
|
+
|
37
|
+
@classmethod
|
38
|
+
def from_str(cls, value: str) -> CassetteFormat:
|
39
|
+
try:
|
40
|
+
return cls[value.upper()]
|
41
|
+
except KeyError:
|
42
|
+
available_formats = ", ".join(cls)
|
43
|
+
raise ValueError(
|
44
|
+
f"Invalid value for cassette format: {value}. Available formats: {available_formats}"
|
45
|
+
) from None
|
46
|
+
|
47
|
+
|
48
|
+
@dataclass
|
49
|
+
class CassetteConfig:
|
50
|
+
path: click.utils.LazyFile
|
51
|
+
format: CassetteFormat = CassetteFormat.VCR
|
52
|
+
preserve_exact_body_bytes: bool = False
|
53
|
+
sanitize_output: bool = True
|
54
|
+
|
55
|
+
|
56
|
+
@dataclass
|
57
|
+
class CassetteWriter(EventHandler):
|
58
|
+
"""Write network interactions to a cassette."""
|
59
|
+
|
60
|
+
config: CassetteConfig
|
61
|
+
queue: Queue = field(default_factory=Queue)
|
62
|
+
worker: threading.Thread = field(init=False)
|
63
|
+
|
64
|
+
def __post_init__(self) -> None:
|
65
|
+
kwargs = {"config": self.config, "queue": self.queue}
|
66
|
+
writer: Callable
|
67
|
+
if self.config.format == CassetteFormat.HAR:
|
68
|
+
writer = har_writer
|
69
|
+
else:
|
70
|
+
writer = vcr_writer
|
71
|
+
self.worker = threading.Thread(name="SchemathesisCassetteWriter", target=writer, kwargs=kwargs)
|
72
|
+
self.worker.start()
|
73
|
+
|
74
|
+
def start(self, ctx: ExecutionContext) -> None:
|
75
|
+
self.queue.put(Initialize(seed=ctx.seed))
|
76
|
+
|
77
|
+
def handle_event(self, ctx: ExecutionContext, event: events.EngineEvent) -> None:
|
78
|
+
if isinstance(event, events.ScenarioFinished):
|
79
|
+
self.queue.put(Process(recorder=event.recorder))
|
80
|
+
elif isinstance(event, events.EngineFinished):
|
81
|
+
self.shutdown()
|
82
|
+
|
83
|
+
def shutdown(self) -> None:
|
84
|
+
self.queue.put(Finalize())
|
85
|
+
self._stop_worker()
|
86
|
+
|
87
|
+
def _stop_worker(self) -> None:
|
88
|
+
self.worker.join(WRITER_WORKER_JOIN_TIMEOUT)
|
89
|
+
|
90
|
+
|
91
|
+
@dataclass
|
92
|
+
class Initialize:
|
93
|
+
"""Start up, the first message to make preparations before proceeding the input data."""
|
94
|
+
|
95
|
+
seed: int | None
|
96
|
+
|
97
|
+
__slots__ = ("seed",)
|
98
|
+
|
99
|
+
|
100
|
+
@dataclass
|
101
|
+
class Process:
|
102
|
+
"""A new chunk of data should be processed."""
|
103
|
+
|
104
|
+
recorder: ScenarioRecorder
|
105
|
+
|
106
|
+
__slots__ = ("recorder",)
|
107
|
+
|
108
|
+
|
109
|
+
@dataclass
|
110
|
+
class Finalize:
|
111
|
+
"""The work is done and there will be no more messages to process."""
|
112
|
+
|
113
|
+
__slots__ = ()
|
114
|
+
|
115
|
+
|
116
|
+
def get_command_representation() -> str:
|
117
|
+
"""Get how Schemathesis was run."""
|
118
|
+
# It is supposed to be executed from Schemathesis CLI, not via Click's `command.invoke`
|
119
|
+
if not sys.argv[0].endswith(("schemathesis", "st")):
|
120
|
+
return "<unknown entrypoint>"
|
121
|
+
args = " ".join(sys.argv[1:])
|
122
|
+
return f"st {args}"
|
123
|
+
|
124
|
+
|
125
|
+
def vcr_writer(config: CassetteConfig, queue: Queue) -> None:
|
126
|
+
"""Write YAML to a file in an incremental manner.
|
127
|
+
|
128
|
+
This implementation doesn't use `pyyaml` package and composes YAML manually as string due to the following reasons:
|
129
|
+
- It is much faster. The string-based approach gives only ~2.5% time overhead when `yaml.CDumper` has ~11.2%;
|
130
|
+
- Implementation complexity. We have a quite simple format where almost all values are strings, and it is much
|
131
|
+
simpler to implement it with string composition rather than with adjusting `yaml.Serializer` to emit explicit
|
132
|
+
types. Another point is that with `pyyaml` we need to emit events and handle some low-level details like
|
133
|
+
providing tags, anchors to have incremental writing, with primitive types it is much simpler.
|
134
|
+
"""
|
135
|
+
current_id = 1
|
136
|
+
stream = config.path.open()
|
137
|
+
|
138
|
+
def format_header_values(values: list[str]) -> str:
|
139
|
+
return "\n".join(f" - {json.dumps(v)}" for v in values)
|
140
|
+
|
141
|
+
if config.sanitize_output:
|
142
|
+
|
143
|
+
def format_headers(headers: dict[str, list[str]]) -> str:
|
144
|
+
headers = deepclone(headers)
|
145
|
+
sanitize_value(headers)
|
146
|
+
return "\n".join(f' "{name}":\n{format_header_values(values)}' for name, values in headers.items())
|
147
|
+
|
148
|
+
else:
|
149
|
+
|
150
|
+
def format_headers(headers: dict[str, list[str]]) -> str:
|
151
|
+
return "\n".join(f' "{name}":\n{format_header_values(values)}' for name, values in headers.items())
|
152
|
+
|
153
|
+
def format_check_message(message: str | None) -> str:
|
154
|
+
return "~" if message is None else f"{message!r}"
|
155
|
+
|
156
|
+
def format_checks(checks: list[CheckNode]) -> str:
|
157
|
+
if not checks:
|
158
|
+
return "\n checks: []"
|
159
|
+
items = "\n".join(
|
160
|
+
f" - name: '{check.name}'\n status: '{check.status.name.upper()}'\n message: {format_check_message(check.failure_info.failure.title if check.failure_info else None)}"
|
161
|
+
for check in checks
|
162
|
+
)
|
163
|
+
return f"""
|
164
|
+
checks:
|
165
|
+
{items}"""
|
166
|
+
|
167
|
+
if config.preserve_exact_body_bytes:
|
168
|
+
|
169
|
+
def format_request_body(output: IO, request: Request) -> None:
|
170
|
+
if request.encoded_body is not None:
|
171
|
+
output.write(
|
172
|
+
f"""
|
173
|
+
body:
|
174
|
+
encoding: 'utf-8'
|
175
|
+
base64_string: '{request.encoded_body}'"""
|
176
|
+
)
|
177
|
+
|
178
|
+
def format_response_body(output: IO, response: Response) -> None:
|
179
|
+
if response.encoded_body is not None:
|
180
|
+
output.write(
|
181
|
+
f""" body:
|
182
|
+
encoding: '{response.encoding}'
|
183
|
+
base64_string: '{response.encoded_body}'"""
|
184
|
+
)
|
185
|
+
|
186
|
+
else:
|
187
|
+
|
188
|
+
def format_request_body(output: IO, request: Request) -> None:
|
189
|
+
if request.body is not None:
|
190
|
+
string = request.body.decode("utf8", "replace")
|
191
|
+
output.write(
|
192
|
+
"""
|
193
|
+
body:
|
194
|
+
encoding: 'utf-8'
|
195
|
+
string: """
|
196
|
+
)
|
197
|
+
write_double_quoted(output, string)
|
198
|
+
|
199
|
+
def format_response_body(output: IO, response: Response) -> None:
|
200
|
+
if response.content is not None:
|
201
|
+
encoding = response.encoding or "utf8"
|
202
|
+
string = response.content.decode(encoding, "replace")
|
203
|
+
output.write(
|
204
|
+
f""" body:
|
205
|
+
encoding: '{encoding}'
|
206
|
+
string: """
|
207
|
+
)
|
208
|
+
write_double_quoted(output, string)
|
209
|
+
|
210
|
+
while True:
|
211
|
+
item = queue.get()
|
212
|
+
if isinstance(item, Initialize):
|
213
|
+
stream.write(
|
214
|
+
f"""command: '{get_command_representation()}'
|
215
|
+
recorded_with: 'Schemathesis {SCHEMATHESIS_VERSION}'
|
216
|
+
seed: {item.seed}
|
217
|
+
http_interactions:"""
|
218
|
+
)
|
219
|
+
elif isinstance(item, Process):
|
220
|
+
for case_id, interaction in item.recorder.interactions.items():
|
221
|
+
case = item.recorder.cases[case_id]
|
222
|
+
if interaction.response is not None:
|
223
|
+
if case_id in item.recorder.checks:
|
224
|
+
checks = item.recorder.checks[case_id]
|
225
|
+
status = Status.SUCCESS
|
226
|
+
for check in checks:
|
227
|
+
if check.status == Status.FAILURE:
|
228
|
+
status = check.status
|
229
|
+
break
|
230
|
+
else:
|
231
|
+
# NOTE: Checks recording could be skipped if Schemathesis start skipping just
|
232
|
+
# discovered failures in order to get past them and potentially discover more failures
|
233
|
+
checks = []
|
234
|
+
status = Status.SKIP
|
235
|
+
else:
|
236
|
+
checks = []
|
237
|
+
status = Status.ERROR
|
238
|
+
# Body payloads are handled via separate `stream.write` calls to avoid some allocations
|
239
|
+
stream.write(
|
240
|
+
f"""\n- id: '{case_id}'
|
241
|
+
status: '{status.name}'"""
|
242
|
+
)
|
243
|
+
meta = case.value.meta
|
244
|
+
if meta is not None:
|
245
|
+
# Start metadata block
|
246
|
+
stream.write(f"""
|
247
|
+
generation:
|
248
|
+
time: {meta.generation.time}
|
249
|
+
mode: {meta.generation.mode.value}
|
250
|
+
components:""")
|
251
|
+
|
252
|
+
# Write components
|
253
|
+
for kind, info in meta.components.items():
|
254
|
+
stream.write(f"""
|
255
|
+
{kind.value}:
|
256
|
+
mode: '{info.mode.value}'""")
|
257
|
+
# Write phase info
|
258
|
+
stream.write("\n phase:")
|
259
|
+
stream.write(f"\n name: '{meta.phase.name.value}'")
|
260
|
+
stream.write("\n data: ")
|
261
|
+
|
262
|
+
# Write phase-specific data
|
263
|
+
if isinstance(meta.phase.data, CoveragePhaseData):
|
264
|
+
stream.write("""
|
265
|
+
description: """)
|
266
|
+
write_double_quoted(stream, meta.phase.data.description)
|
267
|
+
stream.write("""
|
268
|
+
location: """)
|
269
|
+
write_double_quoted(stream, meta.phase.data.location)
|
270
|
+
stream.write("""
|
271
|
+
parameter: """)
|
272
|
+
if meta.phase.data.parameter is not None:
|
273
|
+
write_double_quoted(stream, meta.phase.data.parameter)
|
274
|
+
else:
|
275
|
+
stream.write("null")
|
276
|
+
stream.write("""
|
277
|
+
parameter_location: """)
|
278
|
+
if meta.phase.data.parameter_location is not None:
|
279
|
+
write_double_quoted(stream, meta.phase.data.parameter_location)
|
280
|
+
else:
|
281
|
+
stream.write("null")
|
282
|
+
else:
|
283
|
+
# Empty objects for these phases
|
284
|
+
stream.write("{}")
|
285
|
+
else:
|
286
|
+
stream.write("null")
|
287
|
+
|
288
|
+
if config.sanitize_output:
|
289
|
+
uri = sanitize_url(interaction.request.uri)
|
290
|
+
else:
|
291
|
+
uri = interaction.request.uri
|
292
|
+
recorded_at = datetime.datetime.fromtimestamp(interaction.timestamp, datetime.timezone.utc).isoformat()
|
293
|
+
stream.write(
|
294
|
+
f"""
|
295
|
+
recorded_at: '{recorded_at}'{format_checks(checks)}
|
296
|
+
request:
|
297
|
+
uri: '{uri}'
|
298
|
+
method: '{interaction.request.method}'
|
299
|
+
headers:
|
300
|
+
{format_headers(interaction.request.headers)}"""
|
301
|
+
)
|
302
|
+
format_request_body(stream, interaction.request)
|
303
|
+
if interaction.response is not None:
|
304
|
+
stream.write(
|
305
|
+
f"""
|
306
|
+
response:
|
307
|
+
status:
|
308
|
+
code: '{interaction.response.status_code}'
|
309
|
+
message: {json.dumps(interaction.response.message)}
|
310
|
+
elapsed: '{interaction.response.elapsed}'
|
311
|
+
headers:
|
312
|
+
{format_headers(interaction.response.headers)}
|
313
|
+
"""
|
314
|
+
)
|
315
|
+
format_response_body(stream, interaction.response)
|
316
|
+
stream.write(
|
317
|
+
f"""
|
318
|
+
http_version: '{interaction.response.http_version}'"""
|
319
|
+
)
|
320
|
+
else:
|
321
|
+
stream.write("""
|
322
|
+
response: null""")
|
323
|
+
current_id += 1
|
324
|
+
else:
|
325
|
+
break
|
326
|
+
config.path.close()
|
327
|
+
|
328
|
+
|
329
|
+
def write_double_quoted(stream: IO, text: str | None) -> None:
|
330
|
+
"""Writes a valid YAML string enclosed in double quotes."""
|
331
|
+
from yaml.emitter import Emitter
|
332
|
+
|
333
|
+
if text is None:
|
334
|
+
stream.write("null")
|
335
|
+
return
|
336
|
+
|
337
|
+
# Adapted from `yaml.Emitter.write_double_quoted`:
|
338
|
+
# - Doesn't split the string, therefore doesn't track the current column
|
339
|
+
# - Doesn't encode the input
|
340
|
+
# - Allows Unicode unconditionally
|
341
|
+
stream.write('"')
|
342
|
+
start = end = 0
|
343
|
+
length = len(text)
|
344
|
+
while end <= length:
|
345
|
+
ch = None
|
346
|
+
if end < length:
|
347
|
+
ch = text[end]
|
348
|
+
if (
|
349
|
+
ch is None
|
350
|
+
or ch in '"\\\x85\u2028\u2029\ufeff'
|
351
|
+
or not ("\x20" <= ch <= "\x7e" or ("\xa0" <= ch <= "\ud7ff" or "\ue000" <= ch <= "\ufffd"))
|
352
|
+
):
|
353
|
+
if start < end:
|
354
|
+
stream.write(text[start:end])
|
355
|
+
start = end
|
356
|
+
if ch is not None:
|
357
|
+
# Escape character
|
358
|
+
if ch in Emitter.ESCAPE_REPLACEMENTS:
|
359
|
+
data = "\\" + Emitter.ESCAPE_REPLACEMENTS[ch]
|
360
|
+
elif ch <= "\xff":
|
361
|
+
data = f"\\x{ord(ch):02X}"
|
362
|
+
elif ch <= "\uffff":
|
363
|
+
data = f"\\u{ord(ch):04X}"
|
364
|
+
else:
|
365
|
+
data = f"\\U{ord(ch):08X}"
|
366
|
+
stream.write(data)
|
367
|
+
start = end + 1
|
368
|
+
end += 1
|
369
|
+
stream.write('"')
|
370
|
+
|
371
|
+
|
372
|
+
def har_writer(config: CassetteConfig, queue: Queue) -> None:
|
373
|
+
with harfile.open(config.path) as har:
|
374
|
+
while True:
|
375
|
+
item = queue.get()
|
376
|
+
if isinstance(item, Process):
|
377
|
+
for interaction in item.recorder.interactions.values():
|
378
|
+
if config.sanitize_output:
|
379
|
+
uri = sanitize_url(interaction.request.uri)
|
380
|
+
else:
|
381
|
+
uri = interaction.request.uri
|
382
|
+
query_params = urlparse(uri).query
|
383
|
+
if interaction.request.body is not None:
|
384
|
+
post_data = harfile.PostData(
|
385
|
+
mimeType=interaction.request.headers.get("Content-Type", [""])[0],
|
386
|
+
text=interaction.request.encoded_body
|
387
|
+
if config.preserve_exact_body_bytes
|
388
|
+
else interaction.request.body.decode("utf-8", "replace"),
|
389
|
+
)
|
390
|
+
else:
|
391
|
+
post_data = None
|
392
|
+
if interaction.response is not None:
|
393
|
+
content_type = interaction.response.headers.get("Content-Type", [""])[0]
|
394
|
+
content = harfile.Content(
|
395
|
+
size=interaction.response.body_size or 0,
|
396
|
+
mimeType=content_type,
|
397
|
+
text=interaction.response.encoded_body
|
398
|
+
if config.preserve_exact_body_bytes
|
399
|
+
else interaction.response.content.decode("utf-8", "replace")
|
400
|
+
if interaction.response.content is not None
|
401
|
+
else None,
|
402
|
+
encoding="base64"
|
403
|
+
if interaction.response.content is not None and config.preserve_exact_body_bytes
|
404
|
+
else None,
|
405
|
+
)
|
406
|
+
http_version = f"HTTP/{interaction.response.http_version}"
|
407
|
+
if config.sanitize_output:
|
408
|
+
headers = deepclone(interaction.response.headers)
|
409
|
+
sanitize_value(headers)
|
410
|
+
else:
|
411
|
+
headers = interaction.response.headers
|
412
|
+
response = harfile.Response(
|
413
|
+
status=interaction.response.status_code,
|
414
|
+
httpVersion=http_version,
|
415
|
+
statusText=interaction.response.message,
|
416
|
+
headers=[harfile.Record(name=name, value=values[0]) for name, values in headers.items()],
|
417
|
+
cookies=_extract_cookies(headers.get("Set-Cookie", [])),
|
418
|
+
content=content,
|
419
|
+
headersSize=_headers_size(headers),
|
420
|
+
bodySize=interaction.response.body_size or 0,
|
421
|
+
redirectURL=headers.get("Location", [""])[0],
|
422
|
+
)
|
423
|
+
time = round(interaction.response.elapsed * 1000, 2)
|
424
|
+
else:
|
425
|
+
response = HARFILE_NO_RESPONSE
|
426
|
+
time = 0
|
427
|
+
http_version = ""
|
428
|
+
|
429
|
+
if config.sanitize_output:
|
430
|
+
headers = deepclone(interaction.request.headers)
|
431
|
+
sanitize_value(headers)
|
432
|
+
else:
|
433
|
+
headers = interaction.request.headers
|
434
|
+
started_datetime = datetime.datetime.fromtimestamp(
|
435
|
+
interaction.timestamp, datetime.timezone.utc
|
436
|
+
).isoformat()
|
437
|
+
har.add_entry(
|
438
|
+
startedDateTime=started_datetime,
|
439
|
+
time=time,
|
440
|
+
request=harfile.Request(
|
441
|
+
method=interaction.request.method.upper(),
|
442
|
+
url=uri,
|
443
|
+
httpVersion=http_version,
|
444
|
+
headers=[harfile.Record(name=name, value=values[0]) for name, values in headers.items()],
|
445
|
+
queryString=[
|
446
|
+
harfile.Record(name=name, value=value)
|
447
|
+
for name, value in parse_qsl(query_params, keep_blank_values=True)
|
448
|
+
],
|
449
|
+
cookies=_extract_cookies(headers.get("Cookie", [])),
|
450
|
+
headersSize=_headers_size(headers),
|
451
|
+
bodySize=interaction.request.body_size or 0,
|
452
|
+
postData=post_data,
|
453
|
+
),
|
454
|
+
response=response,
|
455
|
+
timings=harfile.Timings(send=0, wait=0, receive=time, blocked=0, dns=0, connect=0, ssl=0),
|
456
|
+
)
|
457
|
+
elif isinstance(item, Finalize):
|
458
|
+
break
|
459
|
+
|
460
|
+
|
461
|
+
HARFILE_NO_RESPONSE = harfile.Response(
|
462
|
+
status=0,
|
463
|
+
httpVersion="",
|
464
|
+
statusText="",
|
465
|
+
headers=[],
|
466
|
+
cookies=[],
|
467
|
+
content=harfile.Content(),
|
468
|
+
)
|
469
|
+
|
470
|
+
|
471
|
+
def _headers_size(headers: dict[str, list[str]]) -> int:
|
472
|
+
size = 0
|
473
|
+
for name, values in headers.items():
|
474
|
+
# 4 is for ": " and "\r\n"
|
475
|
+
size += len(name) + 4 + len(values[0])
|
476
|
+
return size
|
477
|
+
|
478
|
+
|
479
|
+
def _extract_cookies(headers: list[str]) -> list[harfile.Cookie]:
|
480
|
+
return [cookie for items in headers for item in items for cookie in _cookie_to_har(item)]
|
481
|
+
|
482
|
+
|
483
|
+
def _cookie_to_har(cookie: str) -> Iterator[harfile.Cookie]:
|
484
|
+
parsed = SimpleCookie(cookie)
|
485
|
+
for name, data in parsed.items():
|
486
|
+
yield harfile.Cookie(
|
487
|
+
name=name,
|
488
|
+
value=data.value,
|
489
|
+
path=data["path"] or None,
|
490
|
+
domain=data["domain"] or None,
|
491
|
+
expires=data["expires"] or None,
|
492
|
+
httpOnly=data["httponly"] or None,
|
493
|
+
secure=data["secure"] or None,
|
494
|
+
)
|
@@ -0,0 +1,54 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import platform
|
4
|
+
from dataclasses import dataclass, field
|
5
|
+
from typing import Iterable
|
6
|
+
|
7
|
+
from click.utils import LazyFile
|
8
|
+
from junit_xml import TestCase, TestSuite, to_xml_report_file
|
9
|
+
|
10
|
+
from schemathesis.cli.commands.run.context import ExecutionContext, GroupedFailures
|
11
|
+
from schemathesis.cli.commands.run.handlers.base import EventHandler
|
12
|
+
from schemathesis.core.failures import format_failures
|
13
|
+
from schemathesis.engine import Status, events
|
14
|
+
|
15
|
+
|
16
|
+
@dataclass
|
17
|
+
class JunitXMLHandler(EventHandler):
|
18
|
+
file_handle: LazyFile
|
19
|
+
test_cases: dict = field(default_factory=dict)
|
20
|
+
|
21
|
+
def handle_event(self, ctx: ExecutionContext, event: events.EngineEvent) -> None:
|
22
|
+
if isinstance(event, events.ScenarioFinished):
|
23
|
+
label = event.recorder.label
|
24
|
+
test_case = self.get_or_create_test_case(label)
|
25
|
+
test_case.elapsed_sec += event.elapsed_time
|
26
|
+
if event.status == Status.FAILURE:
|
27
|
+
add_failure(test_case, ctx.statistic.failures[label].values(), ctx)
|
28
|
+
elif event.status == Status.SKIP:
|
29
|
+
test_case.add_skipped_info(output=event.skip_reason)
|
30
|
+
elif isinstance(event, events.NonFatalError):
|
31
|
+
test_case = self.get_or_create_test_case(event.label)
|
32
|
+
test_case.add_error_info(output=event.info.format())
|
33
|
+
elif isinstance(event, events.EngineFinished):
|
34
|
+
test_suites = [
|
35
|
+
TestSuite("schemathesis", test_cases=list(self.test_cases.values()), hostname=platform.node())
|
36
|
+
]
|
37
|
+
to_xml_report_file(file_descriptor=self.file_handle, test_suites=test_suites, prettyprint=True)
|
38
|
+
|
39
|
+
def get_or_create_test_case(self, label: str) -> TestCase:
|
40
|
+
return self.test_cases.setdefault(label, TestCase(label, elapsed_sec=0.0, allow_multiple_subelements=True))
|
41
|
+
|
42
|
+
|
43
|
+
def add_failure(test_case: TestCase, checks: Iterable[GroupedFailures], context: ExecutionContext) -> None:
|
44
|
+
messages = [
|
45
|
+
format_failures(
|
46
|
+
case_id=f"{idx}. Test Case ID: {group.case_id}",
|
47
|
+
response=group.response,
|
48
|
+
failures=group.failures,
|
49
|
+
curl=group.code_sample,
|
50
|
+
config=context.output_config,
|
51
|
+
)
|
52
|
+
for idx, group in enumerate(checks, 1)
|
53
|
+
]
|
54
|
+
test_case.add_failure_info(message="\n\n".join(messages))
|