schemathesis 3.39.7__py3-none-any.whl → 4.0.0a2__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 +26 -68
- schemathesis/checks.py +130 -60
- schemathesis/cli/__init__.py +5 -2105
- 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 +30 -0
- schemathesis/cli/commands/run/executor.py +141 -0
- schemathesis/cli/commands/run/filters.py +202 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +18 -0
- schemathesis/cli/{cassettes.py → commands/run/handlers/cassettes.py} +178 -247
- schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
- schemathesis/cli/commands/run/handlers/output.py +1368 -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} +59 -175
- schemathesis/cli/constants.py +5 -58
- 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} +37 -16
- 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 -7
- 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 +315 -0
- schemathesis/core/fs.py +19 -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/{internal/output.py → core/output/__init__.py} +1 -0
- schemathesis/core/output/sanitization.py +197 -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 +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 +243 -0
- schemathesis/engine/phases/__init__.py +66 -0
- schemathesis/{runner → engine/phases}/probes.py +49 -68
- schemathesis/engine/phases/stateful/__init__.py +66 -0
- schemathesis/engine/phases/stateful/_executor.py +301 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +175 -0
- schemathesis/engine/phases/unit/_executor.py +322 -0
- schemathesis/engine/phases/unit/_pool.py +74 -0
- schemathesis/engine/recorder.py +246 -0
- schemathesis/errors.py +31 -0
- schemathesis/experimental/__init__.py +9 -40
- schemathesis/filters.py +7 -95
- schemathesis/generation/__init__.py +3 -3
- schemathesis/generation/case.py +190 -0
- schemathesis/generation/coverage.py +22 -22
- schemathesis/{_patches.py → generation/hypothesis/__init__.py} +15 -6
- schemathesis/generation/hypothesis/builder.py +585 -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/modes.py +28 -0
- schemathesis/generation/overrides.py +96 -0
- schemathesis/generation/stateful/__init__.py +20 -0
- schemathesis/{stateful → generation/stateful}/state_machine.py +84 -109
- schemathesis/generation/targets.py +69 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +131 -0
- schemathesis/hooks.py +17 -62
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +387 -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} +94 -107
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +456 -228
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/_cache.py +1 -2
- schemathesis/specs/graphql/scalars.py +5 -3
- schemathesis/specs/graphql/schemas.py +122 -123
- 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 +97 -134
- schemathesis/specs/openapi/checks.py +238 -219
- schemathesis/specs/openapi/converter.py +4 -4
- schemathesis/specs/openapi/definitions.py +1 -1
- schemathesis/specs/openapi/examples.py +22 -20
- schemathesis/specs/openapi/expressions/__init__.py +11 -15
- schemathesis/specs/openapi/expressions/extractors.py +1 -4
- schemathesis/specs/openapi/expressions/nodes.py +33 -32
- schemathesis/specs/openapi/formats.py +3 -2
- schemathesis/specs/openapi/links.py +123 -299
- schemathesis/specs/openapi/media_types.py +10 -12
- schemathesis/specs/openapi/negative/__init__.py +2 -1
- schemathesis/specs/openapi/negative/mutations.py +3 -2
- schemathesis/specs/openapi/parameters.py +8 -6
- schemathesis/specs/openapi/patterns.py +1 -1
- schemathesis/specs/openapi/references.py +11 -51
- schemathesis/specs/openapi/schemas.py +177 -191
- schemathesis/specs/openapi/security.py +1 -1
- schemathesis/specs/openapi/serialization.py +10 -6
- schemathesis/specs/openapi/stateful/__init__.py +97 -91
- 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} +69 -7
- schemathesis/transport/wsgi.py +165 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/METADATA +18 -14
- schemathesis-4.0.0a2.dist-info/RECORD +151 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/entry_points.txt +1 -1
- schemathesis/_compat.py +0 -74
- schemathesis/_dependency_versions.py +0 -19
- schemathesis/_hypothesis.py +0 -559
- schemathesis/_override.py +0 -50
- schemathesis/_rate_limiter.py +0 -7
- 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 -936
- 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 -56
- 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/extra/_aiohttp.py +0 -28
- schemathesis/extra/_flask.py +0 -13
- schemathesis/extra/_server.py +0 -18
- schemathesis/failures.py +0 -277
- 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 -84
- schemathesis/internal/copy.py +0 -32
- schemathesis/internal/datetime.py +0 -5
- schemathesis/internal/deprecation.py +0 -38
- schemathesis/internal/diff.py +0 -15
- schemathesis/internal/extensions.py +0 -27
- schemathesis/internal/jsonschema.py +0 -36
- 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 -104
- schemathesis/runner/impl/core.py +0 -1246
- 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/loaders.py +0 -708
- 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/statistic.py +0 -22
- schemathesis/stateful/validation.py +0 -100
- schemathesis/targets.py +0 -77
- schemathesis/transports/__init__.py +0 -359
- 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.7.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.7.dist-info → schemathesis-4.0.0a2.dist-info}/WHEEL +0 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/licenses/LICENSE +0 -0
@@ -1,31 +1,28 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
import
|
3
|
+
import datetime
|
4
4
|
import enum
|
5
5
|
import json
|
6
|
-
import re
|
7
6
|
import sys
|
8
7
|
import threading
|
9
8
|
from dataclasses import dataclass, field
|
10
9
|
from http.cookies import SimpleCookie
|
11
10
|
from queue import Queue
|
12
|
-
from typing import IO,
|
11
|
+
from typing import IO, Callable, Iterator
|
13
12
|
from urllib.parse import parse_qsl, urlparse
|
14
13
|
|
14
|
+
import click
|
15
15
|
import harfile
|
16
16
|
|
17
|
-
from
|
18
|
-
from
|
19
|
-
from .
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
from ..runner.serialization import SerializedCheck, SerializedInteraction
|
27
|
-
from ..types import RequestCert
|
28
|
-
from .context import ExecutionContext
|
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
|
29
26
|
|
30
27
|
# Wait until the worker terminates
|
31
28
|
WRITER_WORKER_JOIN_TIMEOUT = 1
|
@@ -49,58 +46,39 @@ class CassetteFormat(str, enum.Enum):
|
|
49
46
|
|
50
47
|
|
51
48
|
@dataclass
|
52
|
-
class
|
53
|
-
|
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
54
|
|
55
|
-
A low-level interface is used to write data to YAML file during the test run and reduce the delay at
|
56
|
-
the end of the test run.
|
57
|
-
"""
|
58
55
|
|
59
|
-
|
60
|
-
|
61
|
-
|
56
|
+
@dataclass
|
57
|
+
class CassetteWriter(EventHandler):
|
58
|
+
"""Write network interactions to a cassette."""
|
59
|
+
|
60
|
+
config: CassetteConfig
|
62
61
|
queue: Queue = field(default_factory=Queue)
|
63
62
|
worker: threading.Thread = field(init=False)
|
64
63
|
|
65
64
|
def __post_init__(self) -> None:
|
66
|
-
kwargs = {
|
67
|
-
"file_handle": self.file_handle,
|
68
|
-
"queue": self.queue,
|
69
|
-
"preserve_exact_body_bytes": self.preserve_exact_body_bytes,
|
70
|
-
}
|
65
|
+
kwargs = {"config": self.config, "queue": self.queue}
|
71
66
|
writer: Callable
|
72
|
-
if self.format == CassetteFormat.HAR:
|
67
|
+
if self.config.format == CassetteFormat.HAR:
|
73
68
|
writer = har_writer
|
74
69
|
else:
|
75
70
|
writer = vcr_writer
|
76
71
|
self.worker = threading.Thread(name="SchemathesisCassetteWriter", target=writer, kwargs=kwargs)
|
77
72
|
self.worker.start()
|
78
73
|
|
79
|
-
def
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
self.queue.put(
|
85
|
-
Process(
|
86
|
-
correlation_id=event.correlation_id,
|
87
|
-
thread_id=event.thread_id,
|
88
|
-
interactions=event.result.interactions,
|
89
|
-
)
|
90
|
-
)
|
91
|
-
elif isinstance(event, events.AfterStatefulExecution):
|
92
|
-
self.queue.put(
|
93
|
-
Process(
|
94
|
-
# Correlation ID is not used in stateful testing
|
95
|
-
correlation_id="",
|
96
|
-
thread_id=event.thread_id,
|
97
|
-
interactions=event.result.interactions,
|
98
|
-
)
|
99
|
-
)
|
100
|
-
elif isinstance(event, events.Finished):
|
101
|
-
self.shutdown()
|
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))
|
102
80
|
|
103
|
-
def shutdown(self) -> None:
|
81
|
+
def shutdown(self, ctx: ExecutionContext) -> None:
|
104
82
|
self.queue.put(Finalize())
|
105
83
|
self._stop_worker()
|
106
84
|
|
@@ -114,20 +92,24 @@ class Initialize:
|
|
114
92
|
|
115
93
|
seed: int | None
|
116
94
|
|
95
|
+
__slots__ = ("seed",)
|
96
|
+
|
117
97
|
|
118
98
|
@dataclass
|
119
99
|
class Process:
|
120
100
|
"""A new chunk of data should be processed."""
|
121
101
|
|
122
|
-
|
123
|
-
|
124
|
-
|
102
|
+
recorder: ScenarioRecorder
|
103
|
+
|
104
|
+
__slots__ = ("recorder",)
|
125
105
|
|
126
106
|
|
127
107
|
@dataclass
|
128
108
|
class Finalize:
|
129
109
|
"""The work is done and there will be no more messages to process."""
|
130
110
|
|
111
|
+
__slots__ = ()
|
112
|
+
|
131
113
|
|
132
114
|
def get_command_representation() -> str:
|
133
115
|
"""Get how Schemathesis was run."""
|
@@ -138,7 +120,7 @@ def get_command_representation() -> str:
|
|
138
120
|
return f"st {args}"
|
139
121
|
|
140
122
|
|
141
|
-
def vcr_writer(
|
123
|
+
def vcr_writer(config: CassetteConfig, queue: Queue) -> None:
|
142
124
|
"""Write YAML to a file in an incremental manner.
|
143
125
|
|
144
126
|
This implementation doesn't use `pyyaml` package and composes YAML manually as string due to the following reasons:
|
@@ -149,52 +131,61 @@ def vcr_writer(file_handle: click.utils.LazyFile, preserve_exact_body_bytes: boo
|
|
149
131
|
providing tags, anchors to have incremental writing, with primitive types it is much simpler.
|
150
132
|
"""
|
151
133
|
current_id = 1
|
152
|
-
stream =
|
134
|
+
stream = config.path.open()
|
153
135
|
|
154
136
|
def format_header_values(values: list[str]) -> str:
|
155
137
|
return "\n".join(f" - {json.dumps(v)}" for v in values)
|
156
138
|
|
157
|
-
|
158
|
-
|
139
|
+
if config.sanitize_output:
|
140
|
+
|
141
|
+
def format_headers(headers: dict[str, list[str]]) -> str:
|
142
|
+
headers = deepclone(headers)
|
143
|
+
sanitize_value(headers)
|
144
|
+
return "\n".join(f' "{name}":\n{format_header_values(values)}' for name, values in headers.items())
|
145
|
+
|
146
|
+
else:
|
147
|
+
|
148
|
+
def format_headers(headers: dict[str, list[str]]) -> str:
|
149
|
+
return "\n".join(f' "{name}":\n{format_header_values(values)}' for name, values in headers.items())
|
159
150
|
|
160
151
|
def format_check_message(message: str | None) -> str:
|
161
152
|
return "~" if message is None else f"{message!r}"
|
162
153
|
|
163
|
-
def format_checks(checks: list[
|
154
|
+
def format_checks(checks: list[CheckNode]) -> str:
|
164
155
|
if not checks:
|
165
|
-
return " checks: []"
|
156
|
+
return "\n checks: []"
|
166
157
|
items = "\n".join(
|
167
|
-
f" - name: '{check.name}'\n status: '{check.
|
158
|
+
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)}"
|
168
159
|
for check in checks
|
169
160
|
)
|
170
161
|
return f"""
|
171
162
|
checks:
|
172
163
|
{items}"""
|
173
164
|
|
174
|
-
if preserve_exact_body_bytes:
|
165
|
+
if config.preserve_exact_body_bytes:
|
175
166
|
|
176
167
|
def format_request_body(output: IO, request: Request) -> None:
|
177
|
-
if request.
|
168
|
+
if request.encoded_body is not None:
|
178
169
|
output.write(
|
179
170
|
f"""
|
180
171
|
body:
|
181
172
|
encoding: 'utf-8'
|
182
|
-
base64_string: '{request.
|
173
|
+
base64_string: '{request.encoded_body}'"""
|
183
174
|
)
|
184
175
|
|
185
176
|
def format_response_body(output: IO, response: Response) -> None:
|
186
|
-
if response.
|
177
|
+
if response.encoded_body is not None:
|
187
178
|
output.write(
|
188
179
|
f""" body:
|
189
180
|
encoding: '{response.encoding}'
|
190
|
-
base64_string: '{response.
|
181
|
+
base64_string: '{response.encoded_body}'"""
|
191
182
|
)
|
192
183
|
|
193
184
|
else:
|
194
185
|
|
195
186
|
def format_request_body(output: IO, request: Request) -> None:
|
196
187
|
if request.body is not None:
|
197
|
-
string =
|
188
|
+
string = request.body.decode("utf8", "replace")
|
198
189
|
output.write(
|
199
190
|
"""
|
200
191
|
body:
|
@@ -204,9 +195,9 @@ def vcr_writer(file_handle: click.utils.LazyFile, preserve_exact_body_bytes: boo
|
|
204
195
|
write_double_quoted(output, string)
|
205
196
|
|
206
197
|
def format_response_body(output: IO, response: Response) -> None:
|
207
|
-
if response.
|
198
|
+
if response.content is not None:
|
208
199
|
encoding = response.encoding or "utf8"
|
209
|
-
string =
|
200
|
+
string = response.content.decode(encoding, "replace")
|
210
201
|
output.write(
|
211
202
|
f""" body:
|
212
203
|
encoding: '{encoding}'
|
@@ -214,62 +205,94 @@ def vcr_writer(file_handle: click.utils.LazyFile, preserve_exact_body_bytes: boo
|
|
214
205
|
)
|
215
206
|
write_double_quoted(output, string)
|
216
207
|
|
217
|
-
seed = "null"
|
218
208
|
while True:
|
219
209
|
item = queue.get()
|
220
210
|
if isinstance(item, Initialize):
|
221
|
-
seed = f"'{item.seed}'"
|
222
211
|
stream.write(
|
223
212
|
f"""command: '{get_command_representation()}'
|
224
213
|
recorded_with: 'Schemathesis {SCHEMATHESIS_VERSION}'
|
214
|
+
seed: {item.seed}
|
225
215
|
http_interactions:"""
|
226
216
|
)
|
227
217
|
elif isinstance(item, Process):
|
228
|
-
for interaction in item.interactions:
|
229
|
-
|
218
|
+
for case_id, interaction in item.recorder.interactions.items():
|
219
|
+
case = item.recorder.cases[case_id]
|
220
|
+
if interaction.response is not None:
|
221
|
+
if case_id in item.recorder.checks:
|
222
|
+
checks = item.recorder.checks[case_id]
|
223
|
+
status = Status.SUCCESS
|
224
|
+
for check in checks:
|
225
|
+
if check.status == Status.FAILURE:
|
226
|
+
status = check.status
|
227
|
+
break
|
228
|
+
else:
|
229
|
+
# NOTE: Checks recording could be skipped if Schemathesis start skipping just
|
230
|
+
# discovered failures in order to get past them and potentially discover more failures
|
231
|
+
checks = []
|
232
|
+
status = Status.SKIP
|
233
|
+
else:
|
234
|
+
checks = []
|
235
|
+
status = Status.ERROR
|
230
236
|
# Body payloads are handled via separate `stream.write` calls to avoid some allocations
|
231
|
-
phase = f"'{interaction.phase.value}'" if interaction.phase is not None else "null"
|
232
237
|
stream.write(
|
233
|
-
f"""\n- id: '{
|
234
|
-
status: '{status}'
|
235
|
-
seed: {seed}
|
236
|
-
thread_id: {item.thread_id}
|
237
|
-
correlation_id: '{item.correlation_id}'
|
238
|
-
data_generation_method: '{interaction.data_generation_method.value}'
|
239
|
-
meta:
|
240
|
-
description: """
|
238
|
+
f"""\n- id: '{case_id}'
|
239
|
+
status: '{status.name}'"""
|
241
240
|
)
|
242
|
-
|
243
|
-
if
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
241
|
+
meta = case.value.meta
|
242
|
+
if meta is not None:
|
243
|
+
# Start metadata block
|
244
|
+
stream.write(f"""
|
245
|
+
generation:
|
246
|
+
time: {meta.generation.time}
|
247
|
+
mode: {meta.generation.mode.value}
|
248
|
+
components:""")
|
249
|
+
|
250
|
+
# Write components
|
251
|
+
for kind, info in meta.components.items():
|
252
|
+
stream.write(f"""
|
253
|
+
{kind.value}:
|
254
|
+
mode: '{info.mode.value}'""")
|
255
|
+
# Write phase info
|
256
|
+
stream.write("\n phase:")
|
257
|
+
stream.write(f"\n name: '{meta.phase.name.value}'")
|
258
|
+
stream.write("\n data: ")
|
259
|
+
|
260
|
+
# Write phase-specific data
|
261
|
+
if isinstance(meta.phase.data, CoveragePhaseData):
|
262
|
+
stream.write("""
|
263
|
+
description: """)
|
264
|
+
write_double_quoted(stream, meta.phase.data.description)
|
265
|
+
stream.write("""
|
266
|
+
location: """)
|
267
|
+
write_double_quoted(stream, meta.phase.data.location)
|
268
|
+
stream.write("""
|
269
|
+
parameter: """)
|
270
|
+
if meta.phase.data.parameter is not None:
|
271
|
+
write_double_quoted(stream, meta.phase.data.parameter)
|
272
|
+
else:
|
273
|
+
stream.write("null")
|
274
|
+
stream.write("""
|
275
|
+
parameter_location: """)
|
276
|
+
if meta.phase.data.parameter_location is not None:
|
277
|
+
write_double_quoted(stream, meta.phase.data.parameter_location)
|
278
|
+
else:
|
279
|
+
stream.write("null")
|
280
|
+
else:
|
281
|
+
# Empty objects for these phases
|
282
|
+
stream.write("{}")
|
257
283
|
else:
|
258
284
|
stream.write("null")
|
259
285
|
|
260
|
-
|
261
|
-
|
262
|
-
write_double_quoted(stream, interaction.parameter_location)
|
286
|
+
if config.sanitize_output:
|
287
|
+
uri = sanitize_url(interaction.request.uri)
|
263
288
|
else:
|
264
|
-
|
289
|
+
uri = interaction.request.uri
|
290
|
+
recorded_at = datetime.datetime.fromtimestamp(interaction.timestamp, datetime.timezone.utc).isoformat()
|
265
291
|
stream.write(
|
266
292
|
f"""
|
267
|
-
|
268
|
-
elapsed: '{interaction.response.elapsed if interaction.response else 0}'
|
269
|
-
recorded_at: '{interaction.recorded_at}'
|
270
|
-
{format_checks(interaction.checks)}
|
293
|
+
recorded_at: '{recorded_at}'{format_checks(checks)}
|
271
294
|
request:
|
272
|
-
uri: '{
|
295
|
+
uri: '{uri}'
|
273
296
|
method: '{interaction.request.method}'
|
274
297
|
headers:
|
275
298
|
{format_headers(interaction.request.headers)}"""
|
@@ -282,6 +305,7 @@ http_interactions:"""
|
|
282
305
|
status:
|
283
306
|
code: '{interaction.response.status_code}'
|
284
307
|
message: {json.dumps(interaction.response.message)}
|
308
|
+
elapsed: '{interaction.response.elapsed}'
|
285
309
|
headers:
|
286
310
|
{format_headers(interaction.response.headers)}
|
287
311
|
"""
|
@@ -293,23 +317,21 @@ http_interactions:"""
|
|
293
317
|
)
|
294
318
|
else:
|
295
319
|
stream.write("""
|
296
|
-
response: null
|
297
|
-
""")
|
320
|
+
response: null""")
|
298
321
|
current_id += 1
|
299
322
|
else:
|
300
323
|
break
|
301
|
-
|
302
|
-
|
324
|
+
config.path.close()
|
303
325
|
|
304
|
-
def _safe_decode(value: str, encoding: str) -> str:
|
305
|
-
"""Decode base64-encoded body bytes as a string."""
|
306
|
-
return base64.b64decode(value).decode(encoding, "replace")
|
307
326
|
|
308
|
-
|
309
|
-
def write_double_quoted(stream: IO, text: str) -> None:
|
327
|
+
def write_double_quoted(stream: IO, text: str | None) -> None:
|
310
328
|
"""Writes a valid YAML string enclosed in double quotes."""
|
311
329
|
from yaml.emitter import Emitter
|
312
330
|
|
331
|
+
if text is None:
|
332
|
+
stream.write("null")
|
333
|
+
return
|
334
|
+
|
313
335
|
# Adapted from `yaml.Emitter.write_double_quoted`:
|
314
336
|
# - Doesn't split the string, therefore doesn't track the current column
|
315
337
|
# - Doesn't encode the input
|
@@ -345,26 +367,23 @@ def write_double_quoted(stream: IO, text: str) -> None:
|
|
345
367
|
stream.write('"')
|
346
368
|
|
347
369
|
|
348
|
-
def har_writer(
|
349
|
-
|
350
|
-
|
351
|
-
def get_body(body: str) -> str:
|
352
|
-
return body
|
353
|
-
else:
|
354
|
-
|
355
|
-
def get_body(body: str) -> str:
|
356
|
-
return base64.b64decode(body).decode("utf-8", errors="replace")
|
357
|
-
|
358
|
-
with harfile.open(file_handle) as har:
|
370
|
+
def har_writer(config: CassetteConfig, queue: Queue) -> None:
|
371
|
+
with harfile.open(config.path) as har:
|
359
372
|
while True:
|
360
373
|
item = queue.get()
|
361
374
|
if isinstance(item, Process):
|
362
|
-
for interaction in item.interactions:
|
363
|
-
|
375
|
+
for interaction in item.recorder.interactions.values():
|
376
|
+
if config.sanitize_output:
|
377
|
+
uri = sanitize_url(interaction.request.uri)
|
378
|
+
else:
|
379
|
+
uri = interaction.request.uri
|
380
|
+
query_params = urlparse(uri).query
|
364
381
|
if interaction.request.body is not None:
|
365
382
|
post_data = harfile.PostData(
|
366
383
|
mimeType=interaction.request.headers.get("Content-Type", [""])[0],
|
367
|
-
text=
|
384
|
+
text=interaction.request.encoded_body
|
385
|
+
if config.preserve_exact_body_bytes
|
386
|
+
else interaction.request.body.decode("utf-8", "replace"),
|
368
387
|
)
|
369
388
|
else:
|
370
389
|
post_data = None
|
@@ -373,25 +392,31 @@ def har_writer(file_handle: click.utils.LazyFile, preserve_exact_body_bytes: boo
|
|
373
392
|
content = harfile.Content(
|
374
393
|
size=interaction.response.body_size or 0,
|
375
394
|
mimeType=content_type,
|
376
|
-
text=
|
395
|
+
text=interaction.response.encoded_body
|
396
|
+
if config.preserve_exact_body_bytes
|
397
|
+
else interaction.response.content.decode("utf-8", "replace")
|
398
|
+
if interaction.response.content is not None
|
399
|
+
else None,
|
377
400
|
encoding="base64"
|
378
|
-
if interaction.response.
|
401
|
+
if interaction.response.content is not None and config.preserve_exact_body_bytes
|
379
402
|
else None,
|
380
403
|
)
|
381
404
|
http_version = f"HTTP/{interaction.response.http_version}"
|
405
|
+
if config.sanitize_output:
|
406
|
+
headers = deepclone(interaction.response.headers)
|
407
|
+
sanitize_value(headers)
|
408
|
+
else:
|
409
|
+
headers = interaction.response.headers
|
382
410
|
response = harfile.Response(
|
383
411
|
status=interaction.response.status_code,
|
384
412
|
httpVersion=http_version,
|
385
413
|
statusText=interaction.response.message,
|
386
|
-
headers=[
|
387
|
-
|
388
|
-
for name, values in interaction.response.headers.items()
|
389
|
-
],
|
390
|
-
cookies=_extract_cookies(interaction.response.headers.get("Set-Cookie", [])),
|
414
|
+
headers=[harfile.Record(name=name, value=values[0]) for name, values in headers.items()],
|
415
|
+
cookies=_extract_cookies(headers.get("Set-Cookie", [])),
|
391
416
|
content=content,
|
392
|
-
headersSize=_headers_size(
|
417
|
+
headersSize=_headers_size(headers),
|
393
418
|
bodySize=interaction.response.body_size or 0,
|
394
|
-
redirectURL=
|
419
|
+
redirectURL=headers.get("Location", [""])[0],
|
395
420
|
)
|
396
421
|
time = round(interaction.response.elapsed * 1000, 2)
|
397
422
|
else:
|
@@ -399,23 +424,28 @@ def har_writer(file_handle: click.utils.LazyFile, preserve_exact_body_bytes: boo
|
|
399
424
|
time = 0
|
400
425
|
http_version = ""
|
401
426
|
|
427
|
+
if config.sanitize_output:
|
428
|
+
headers = deepclone(interaction.request.headers)
|
429
|
+
sanitize_value(headers)
|
430
|
+
else:
|
431
|
+
headers = interaction.request.headers
|
432
|
+
started_datetime = datetime.datetime.fromtimestamp(
|
433
|
+
interaction.timestamp, datetime.timezone.utc
|
434
|
+
).isoformat()
|
402
435
|
har.add_entry(
|
403
|
-
startedDateTime=
|
436
|
+
startedDateTime=started_datetime,
|
404
437
|
time=time,
|
405
438
|
request=harfile.Request(
|
406
439
|
method=interaction.request.method.upper(),
|
407
|
-
url=
|
440
|
+
url=uri,
|
408
441
|
httpVersion=http_version,
|
409
|
-
headers=[
|
410
|
-
harfile.Record(name=name, value=values[0])
|
411
|
-
for name, values in interaction.request.headers.items()
|
412
|
-
],
|
442
|
+
headers=[harfile.Record(name=name, value=values[0]) for name, values in headers.items()],
|
413
443
|
queryString=[
|
414
444
|
harfile.Record(name=name, value=value)
|
415
445
|
for name, value in parse_qsl(query_params, keep_blank_values=True)
|
416
446
|
],
|
417
|
-
cookies=_extract_cookies(
|
418
|
-
headersSize=_headers_size(
|
447
|
+
cookies=_extract_cookies(headers.get("Cookie", [])),
|
448
|
+
headersSize=_headers_size(headers),
|
419
449
|
bodySize=interaction.request.body_size or 0,
|
420
450
|
postData=post_data,
|
421
451
|
),
|
@@ -460,102 +490,3 @@ def _cookie_to_har(cookie: str) -> Iterator[harfile.Cookie]:
|
|
460
490
|
httpOnly=data["httponly"] or None,
|
461
491
|
secure=data["secure"] or None,
|
462
492
|
)
|
463
|
-
|
464
|
-
|
465
|
-
@dataclass
|
466
|
-
class Replayed:
|
467
|
-
interaction: dict[str, Any]
|
468
|
-
response: requests.Response
|
469
|
-
|
470
|
-
|
471
|
-
def replay(
|
472
|
-
cassette: dict[str, Any],
|
473
|
-
id_: str | None = None,
|
474
|
-
status: str | None = None,
|
475
|
-
uri: str | None = None,
|
476
|
-
method: str | None = None,
|
477
|
-
request_tls_verify: bool = True,
|
478
|
-
request_cert: RequestCert | None = None,
|
479
|
-
request_proxy: str | None = None,
|
480
|
-
) -> Generator[Replayed, None, None]:
|
481
|
-
"""Replay saved interactions."""
|
482
|
-
import requests
|
483
|
-
|
484
|
-
session = requests.Session()
|
485
|
-
session.verify = request_tls_verify
|
486
|
-
session.cert = request_cert
|
487
|
-
kwargs = {}
|
488
|
-
if request_proxy is not None:
|
489
|
-
kwargs["proxies"] = {"all": request_proxy}
|
490
|
-
for interaction in filter_cassette(cassette["http_interactions"], id_, status, uri, method):
|
491
|
-
request = get_prepared_request(interaction["request"])
|
492
|
-
response = session.send(request, **kwargs) # type: ignore
|
493
|
-
yield Replayed(interaction, response)
|
494
|
-
|
495
|
-
|
496
|
-
def filter_cassette(
|
497
|
-
interactions: list[dict[str, Any]],
|
498
|
-
id_: str | None = None,
|
499
|
-
status: str | None = None,
|
500
|
-
uri: str | None = None,
|
501
|
-
method: str | None = None,
|
502
|
-
) -> Iterator[dict[str, Any]]:
|
503
|
-
filters = []
|
504
|
-
|
505
|
-
def id_filter(item: dict[str, Any]) -> bool:
|
506
|
-
return item["id"] == id_
|
507
|
-
|
508
|
-
def status_filter(item: dict[str, Any]) -> bool:
|
509
|
-
status_ = cast(str, status)
|
510
|
-
return item["status"].upper() == status_.upper()
|
511
|
-
|
512
|
-
def uri_filter(item: dict[str, Any]) -> bool:
|
513
|
-
uri_ = cast(str, uri)
|
514
|
-
return bool(re.search(uri_, item["request"]["uri"]))
|
515
|
-
|
516
|
-
def method_filter(item: dict[str, Any]) -> bool:
|
517
|
-
method_ = cast(str, method)
|
518
|
-
return bool(re.search(method_, item["request"]["method"]))
|
519
|
-
|
520
|
-
if id_ is not None:
|
521
|
-
filters.append(id_filter)
|
522
|
-
|
523
|
-
if status is not None:
|
524
|
-
filters.append(status_filter)
|
525
|
-
|
526
|
-
if uri is not None:
|
527
|
-
filters.append(uri_filter)
|
528
|
-
|
529
|
-
if method is not None:
|
530
|
-
filters.append(method_filter)
|
531
|
-
|
532
|
-
def is_match(interaction: dict[str, Any]) -> bool:
|
533
|
-
return all(filter_(interaction) for filter_ in filters)
|
534
|
-
|
535
|
-
return filter(is_match, interactions)
|
536
|
-
|
537
|
-
|
538
|
-
def get_prepared_request(data: dict[str, Any]) -> requests.PreparedRequest:
|
539
|
-
"""Create a `requests.PreparedRequest` from a serialized one."""
|
540
|
-
import requests
|
541
|
-
from requests.cookies import RequestsCookieJar
|
542
|
-
from requests.structures import CaseInsensitiveDict
|
543
|
-
|
544
|
-
prepared = requests.PreparedRequest()
|
545
|
-
prepared.method = data["method"]
|
546
|
-
prepared.url = data["uri"]
|
547
|
-
prepared._cookies = RequestsCookieJar() # type: ignore
|
548
|
-
if "body" in data:
|
549
|
-
body = data["body"]
|
550
|
-
if "base64_string" in body:
|
551
|
-
content = body["base64_string"]
|
552
|
-
if content:
|
553
|
-
prepared.body = base64.b64decode(content)
|
554
|
-
else:
|
555
|
-
content = body["string"]
|
556
|
-
if content:
|
557
|
-
prepared.body = content.encode("utf8")
|
558
|
-
# There is always 1 value in a request
|
559
|
-
headers = [(key, value[0]) for key, value in data["headers"].items()]
|
560
|
-
prepared.headers = CaseInsensitiveDict(headers)
|
561
|
-
return prepared
|