schemathesis 4.1.4__py3-none-any.whl → 4.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- schemathesis/cli/commands/run/executor.py +1 -1
- schemathesis/cli/commands/run/handlers/base.py +28 -1
- schemathesis/cli/commands/run/handlers/cassettes.py +109 -137
- schemathesis/cli/commands/run/handlers/junitxml.py +5 -6
- schemathesis/cli/commands/run/handlers/output.py +7 -1
- schemathesis/cli/ext/fs.py +1 -1
- schemathesis/config/_diff_base.py +3 -1
- schemathesis/config/_operations.py +2 -0
- schemathesis/config/_phases.py +21 -4
- schemathesis/config/_projects.py +10 -2
- schemathesis/core/adapter.py +34 -0
- schemathesis/core/errors.py +29 -5
- schemathesis/core/jsonschema/__init__.py +13 -0
- schemathesis/core/jsonschema/bundler.py +163 -0
- schemathesis/{specs/openapi/constants.py → core/jsonschema/keywords.py} +0 -8
- schemathesis/core/jsonschema/references.py +122 -0
- schemathesis/core/jsonschema/types.py +41 -0
- schemathesis/core/media_types.py +6 -4
- schemathesis/core/parameters.py +37 -0
- schemathesis/core/transforms.py +25 -2
- schemathesis/core/validation.py +19 -0
- schemathesis/engine/context.py +1 -1
- schemathesis/engine/errors.py +11 -18
- schemathesis/engine/phases/stateful/_executor.py +1 -1
- schemathesis/engine/phases/unit/_executor.py +30 -13
- schemathesis/errors.py +4 -0
- schemathesis/filters.py +2 -2
- schemathesis/generation/coverage.py +87 -11
- schemathesis/generation/hypothesis/__init__.py +79 -2
- schemathesis/generation/hypothesis/builder.py +108 -70
- schemathesis/generation/meta.py +5 -14
- schemathesis/generation/overrides.py +17 -17
- schemathesis/pytest/lazy.py +1 -1
- schemathesis/pytest/plugin.py +1 -6
- schemathesis/schemas.py +22 -72
- schemathesis/specs/graphql/schemas.py +27 -16
- schemathesis/specs/openapi/_hypothesis.py +83 -68
- schemathesis/specs/openapi/adapter/__init__.py +10 -0
- schemathesis/specs/openapi/adapter/parameters.py +504 -0
- schemathesis/specs/openapi/adapter/protocol.py +57 -0
- schemathesis/specs/openapi/adapter/references.py +19 -0
- schemathesis/specs/openapi/adapter/responses.py +329 -0
- schemathesis/specs/openapi/adapter/security.py +141 -0
- schemathesis/specs/openapi/adapter/v2.py +28 -0
- schemathesis/specs/openapi/adapter/v3_0.py +28 -0
- schemathesis/specs/openapi/adapter/v3_1.py +28 -0
- schemathesis/specs/openapi/checks.py +99 -90
- schemathesis/specs/openapi/converter.py +114 -27
- schemathesis/specs/openapi/examples.py +210 -168
- schemathesis/specs/openapi/negative/__init__.py +12 -7
- schemathesis/specs/openapi/negative/mutations.py +68 -40
- schemathesis/specs/openapi/references.py +2 -175
- schemathesis/specs/openapi/schemas.py +142 -490
- schemathesis/specs/openapi/serialization.py +15 -7
- schemathesis/specs/openapi/stateful/__init__.py +17 -12
- schemathesis/specs/openapi/stateful/inference.py +13 -11
- schemathesis/specs/openapi/stateful/links.py +5 -20
- schemathesis/specs/openapi/types/__init__.py +3 -0
- schemathesis/specs/openapi/types/v3.py +68 -0
- schemathesis/specs/openapi/utils.py +1 -13
- schemathesis/transport/requests.py +3 -11
- schemathesis/transport/serialization.py +63 -27
- schemathesis/transport/wsgi.py +1 -8
- {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/METADATA +2 -2
- {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/RECORD +68 -53
- schemathesis/specs/openapi/parameters.py +0 -405
- schemathesis/specs/openapi/security.py +0 -162
- {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/WHEEL +0 -0
- {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -105,7 +105,7 @@ def initialize_handlers(
|
|
105
105
|
if report.enabled:
|
106
106
|
path = config.reports.get_path(format)
|
107
107
|
open_file(path)
|
108
|
-
handlers.append(CassetteWriter(format=format,
|
108
|
+
handlers.append(CassetteWriter(format=format, output=path, config=config))
|
109
109
|
|
110
110
|
for custom_handler in CUSTOM_HANDLERS:
|
111
111
|
handlers.append(custom_handler(*args, **params))
|
@@ -1,6 +1,9 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
from
|
3
|
+
from contextlib import contextmanager
|
4
|
+
from io import StringIO
|
5
|
+
from pathlib import Path
|
6
|
+
from typing import IO, TYPE_CHECKING, Any, Generator, Protocol, Union
|
4
7
|
|
5
8
|
if TYPE_CHECKING:
|
6
9
|
from schemathesis.cli.commands.run.context import ExecutionContext
|
@@ -16,3 +19,27 @@ class EventHandler:
|
|
16
19
|
def start(self, ctx: ExecutionContext) -> None: ...
|
17
20
|
|
18
21
|
def shutdown(self, ctx: ExecutionContext) -> None: ...
|
22
|
+
|
23
|
+
|
24
|
+
class WritableText(Protocol):
|
25
|
+
"""Protocol for text-writable file-like objects."""
|
26
|
+
|
27
|
+
def write(self, s: str) -> int: ... # pragma: no cover
|
28
|
+
def flush(self) -> None: ... # pragma: no cover
|
29
|
+
|
30
|
+
|
31
|
+
TextOutput = Union[IO[str], StringIO, Path]
|
32
|
+
|
33
|
+
|
34
|
+
@contextmanager
|
35
|
+
def open_text_output(output: TextOutput) -> Generator[IO[str]]:
|
36
|
+
"""Open a text output, handling both Path and file-like objects."""
|
37
|
+
if isinstance(output, Path):
|
38
|
+
f = open(output, "w", encoding="utf-8")
|
39
|
+
try:
|
40
|
+
yield f
|
41
|
+
finally:
|
42
|
+
f.close()
|
43
|
+
else:
|
44
|
+
# Assume it's already a file-like object
|
45
|
+
yield output # type: ignore[misc]
|
@@ -6,7 +6,6 @@ import sys
|
|
6
6
|
import threading
|
7
7
|
from dataclasses import dataclass
|
8
8
|
from http.cookies import SimpleCookie
|
9
|
-
from pathlib import Path
|
10
9
|
from queue import Queue
|
11
10
|
from typing import IO, Callable, Iterator
|
12
11
|
from urllib.parse import parse_qsl, urlparse
|
@@ -14,7 +13,7 @@ from urllib.parse import parse_qsl, urlparse
|
|
14
13
|
import harfile
|
15
14
|
|
16
15
|
from schemathesis.cli.commands.run.context import ExecutionContext
|
17
|
-
from schemathesis.cli.commands.run.handlers.base import EventHandler
|
16
|
+
from schemathesis.cli.commands.run.handlers.base import EventHandler, TextOutput, open_text_output
|
18
17
|
from schemathesis.config import ProjectConfig, ReportFormat, SchemathesisConfig
|
19
18
|
from schemathesis.core.output.sanitization import sanitize_url, sanitize_value
|
20
19
|
from schemathesis.core.transforms import deepclone
|
@@ -33,27 +32,27 @@ class CassetteWriter(EventHandler):
|
|
33
32
|
"""Write network interactions to a cassette."""
|
34
33
|
|
35
34
|
format: ReportFormat
|
36
|
-
|
35
|
+
output: TextOutput
|
37
36
|
config: ProjectConfig
|
38
37
|
queue: Queue
|
39
38
|
worker: threading.Thread
|
40
39
|
|
41
|
-
__slots__ = ("format", "
|
40
|
+
__slots__ = ("format", "output", "config", "queue", "worker")
|
42
41
|
|
43
42
|
def __init__(
|
44
43
|
self,
|
45
44
|
format: ReportFormat,
|
46
|
-
|
45
|
+
output: TextOutput,
|
47
46
|
config: ProjectConfig,
|
48
47
|
queue: Queue | None = None,
|
49
48
|
) -> None:
|
50
49
|
self.format = format
|
51
|
-
self.
|
50
|
+
self.output = output
|
52
51
|
self.config = config
|
53
52
|
self.queue = queue or Queue()
|
54
53
|
|
55
54
|
kwargs = {
|
56
|
-
"
|
55
|
+
"output": self.output,
|
57
56
|
"config": self.config,
|
58
57
|
"queue": self.queue,
|
59
58
|
}
|
@@ -119,103 +118,85 @@ def get_command_representation() -> str:
|
|
119
118
|
return f"st {args}"
|
120
119
|
|
121
120
|
|
122
|
-
def vcr_writer(
|
123
|
-
"""Write YAML to a file in an incremental manner.
|
124
|
-
|
125
|
-
This implementation doesn't use `pyyaml` package and composes YAML manually as string due to the following reasons:
|
126
|
-
- It is much faster. The string-based approach gives only ~2.5% time overhead when `yaml.CDumper` has ~11.2%;
|
127
|
-
- Implementation complexity. We have a quite simple format where almost all values are strings, and it is much
|
128
|
-
simpler to implement it with string composition rather than with adjusting `yaml.Serializer` to emit explicit
|
129
|
-
types. Another point is that with `pyyaml` we need to emit events and handle some low-level details like
|
130
|
-
providing tags, anchors to have incremental writing, with primitive types it is much simpler.
|
131
|
-
"""
|
121
|
+
def vcr_writer(output: TextOutput, config: ProjectConfig, queue: Queue) -> None:
|
122
|
+
"""Write YAML to a file in an incremental manner."""
|
132
123
|
current_id = 1
|
133
124
|
|
134
|
-
def
|
135
|
-
|
125
|
+
def write_header_values(stream: IO, values: list[str]) -> None:
|
126
|
+
for v in values:
|
127
|
+
stream.write(f" - {json.dumps(v)}\n")
|
136
128
|
|
137
129
|
if config.output.sanitization.enabled:
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
130
|
+
sanitization_keys = config.output.sanitization.keys_to_sanitize
|
131
|
+
sensitive_markers = config.output.sanitization.sensitive_markers
|
132
|
+
replacement = config.output.sanitization.replacement
|
133
|
+
|
134
|
+
def write_headers(stream: IO, headers: dict[str, list[str]]) -> None:
|
135
|
+
for name, values in headers.items():
|
136
|
+
lower_name = name.lower()
|
137
|
+
stream.write(f' "{name}":\n')
|
138
|
+
|
139
|
+
# Sanitize inline if needed
|
140
|
+
if lower_name in sanitization_keys or any(marker in lower_name for marker in sensitive_markers):
|
141
|
+
stream.write(f" - {json.dumps(replacement)}\n")
|
142
|
+
else:
|
143
|
+
write_header_values(stream, values)
|
144
144
|
else:
|
145
145
|
|
146
|
-
def
|
147
|
-
|
146
|
+
def write_headers(stream: IO, headers: dict[str, list[str]]) -> None:
|
147
|
+
for name, values in headers.items():
|
148
|
+
stream.write(f' "{name}":\n')
|
149
|
+
write_header_values(stream, values)
|
148
150
|
|
149
|
-
def
|
150
|
-
return "~" if message is None else f"{message!r}"
|
151
|
-
|
152
|
-
def format_checks(checks: list[CheckNode]) -> str:
|
151
|
+
def write_checks(stream: IO, checks: list[CheckNode]) -> None:
|
153
152
|
if not checks:
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
153
|
+
stream.write("\n checks: []")
|
154
|
+
return
|
155
|
+
|
156
|
+
stream.write("\n checks:\n")
|
157
|
+
for check in checks:
|
158
|
+
message = check.failure_info.failure.title if check.failure_info else None
|
159
|
+
message_str = "~" if message is None else repr(message)
|
160
|
+
stream.write(
|
161
|
+
f" - name: '{check.name}'\n"
|
162
|
+
f" status: '{check.status.name.upper()}'\n"
|
163
|
+
f" message: {message_str}\n"
|
164
|
+
)
|
162
165
|
|
163
166
|
if config.reports.preserve_bytes:
|
164
167
|
|
165
|
-
def
|
168
|
+
def write_request_body(stream: IO, request: Request) -> None:
|
166
169
|
if request.encoded_body is not None:
|
167
|
-
|
168
|
-
f"""
|
169
|
-
body:
|
170
|
-
encoding: 'utf-8'
|
171
|
-
base64_string: '{request.encoded_body}'"""
|
172
|
-
)
|
170
|
+
stream.write(f"\n body:\n encoding: 'utf-8'\n base64_string: '{request.encoded_body}'")
|
173
171
|
|
174
|
-
def
|
172
|
+
def write_response_body(stream: IO, response: Response) -> None:
|
175
173
|
if response.encoded_body is not None:
|
176
|
-
|
177
|
-
f"
|
178
|
-
encoding: '{response.encoding}'
|
179
|
-
base64_string: '{response.encoded_body}'"""
|
174
|
+
stream.write(
|
175
|
+
f" body:\n encoding: '{response.encoding}'\n base64_string: '{response.encoded_body}'"
|
180
176
|
)
|
181
|
-
|
182
177
|
else:
|
183
178
|
|
184
|
-
def
|
179
|
+
def write_request_body(stream: IO, request: Request) -> None:
|
185
180
|
if request.body is not None:
|
186
181
|
string = request.body.decode("utf8", "replace")
|
187
|
-
|
188
|
-
|
189
|
-
body:
|
190
|
-
encoding: 'utf-8'
|
191
|
-
string: """
|
192
|
-
)
|
193
|
-
write_double_quoted(output, string)
|
182
|
+
stream.write("\n body:\n encoding: 'utf-8'\n string: ")
|
183
|
+
write_double_quoted(stream, string)
|
194
184
|
|
195
|
-
def
|
185
|
+
def write_response_body(stream: IO, response: Response) -> None:
|
196
186
|
if response.content is not None:
|
197
187
|
encoding = response.encoding or "utf8"
|
198
188
|
string = response.content.decode(encoding, "replace")
|
199
|
-
|
200
|
-
|
201
|
-
encoding: '{encoding}'
|
202
|
-
string: """
|
203
|
-
)
|
204
|
-
write_double_quoted(output, string)
|
189
|
+
stream.write(f" body:\n encoding: '{encoding}'\n string: ")
|
190
|
+
write_double_quoted(stream, string)
|
205
191
|
|
206
|
-
with
|
192
|
+
with open_text_output(output) as stream:
|
207
193
|
while True:
|
208
194
|
item = queue.get()
|
209
|
-
if isinstance(item,
|
210
|
-
stream.write(
|
211
|
-
f"""command: '{get_command_representation()}'
|
212
|
-
recorded_with: 'Schemathesis {SCHEMATHESIS_VERSION}'
|
213
|
-
seed: {item.seed}
|
214
|
-
http_interactions:"""
|
215
|
-
)
|
216
|
-
elif isinstance(item, Process):
|
195
|
+
if isinstance(item, Process):
|
217
196
|
for case_id, interaction in item.recorder.interactions.items():
|
218
197
|
case = item.recorder.cases[case_id]
|
198
|
+
|
199
|
+
# Determine status and checks
|
219
200
|
if interaction.response is not None:
|
220
201
|
if case_id in item.recorder.checks:
|
221
202
|
checks = item.recorder.checks[case_id]
|
@@ -225,101 +206,93 @@ http_interactions:"""
|
|
225
206
|
status = check.status
|
226
207
|
break
|
227
208
|
else:
|
228
|
-
# NOTE: Checks recording could be skipped if Schemathesis start skipping just
|
229
|
-
# discovered failures in order to get past them and potentially discover more failures
|
230
209
|
checks = []
|
231
210
|
status = Status.SKIP
|
232
211
|
else:
|
233
212
|
checks = []
|
234
213
|
status = Status.ERROR
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
214
|
+
|
215
|
+
# Write interaction header
|
216
|
+
stream.write(f"\n- id: '{case_id}'\n status: '{status.name}'")
|
217
|
+
|
218
|
+
# Write metadata if present
|
240
219
|
meta = case.value.meta
|
241
220
|
if meta is not None:
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
# Write components
|
221
|
+
stream.write(
|
222
|
+
f"\n generation:\n"
|
223
|
+
f" time: {meta.generation.time}\n"
|
224
|
+
f" mode: {meta.generation.mode.value}\n"
|
225
|
+
f" components:"
|
226
|
+
)
|
227
|
+
|
250
228
|
for kind, info in meta.components.items():
|
251
|
-
stream.write(f""
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
stream.write("\n phase:")
|
256
|
-
stream.write(f"\n name: '{meta.phase.name.value}'")
|
257
|
-
stream.write("\n data: ")
|
258
|
-
|
259
|
-
# Write phase-specific data
|
229
|
+
stream.write(f"\n {kind.value}:\n mode: '{info.mode.value}'")
|
230
|
+
|
231
|
+
stream.write(f"\n phase:\n name: '{meta.phase.name.value}'\n data: ")
|
232
|
+
|
260
233
|
if isinstance(meta.phase.data, CoveragePhaseData):
|
261
|
-
stream.write(""
|
262
|
-
description: """)
|
234
|
+
stream.write("\n description: ")
|
263
235
|
write_double_quoted(stream, meta.phase.data.description)
|
264
|
-
stream.write(""
|
265
|
-
location: """)
|
236
|
+
stream.write("\n location: ")
|
266
237
|
write_double_quoted(stream, meta.phase.data.location)
|
267
|
-
stream.write(""
|
268
|
-
parameter: """)
|
238
|
+
stream.write("\n parameter: ")
|
269
239
|
if meta.phase.data.parameter is not None:
|
270
240
|
write_double_quoted(stream, meta.phase.data.parameter)
|
271
241
|
else:
|
272
242
|
stream.write("null")
|
273
|
-
stream.write(""
|
274
|
-
parameter_location: """)
|
243
|
+
stream.write("\n parameter_location: ")
|
275
244
|
if meta.phase.data.parameter_location is not None:
|
276
245
|
write_double_quoted(stream, meta.phase.data.parameter_location)
|
277
246
|
else:
|
278
247
|
stream.write("null")
|
279
248
|
else:
|
280
|
-
# Empty objects for these phases
|
281
249
|
stream.write("{}")
|
282
250
|
else:
|
283
|
-
stream.write("null")
|
251
|
+
stream.write("\n metadata: null")
|
284
252
|
|
253
|
+
# Sanitize URL if needed
|
285
254
|
if config.output.sanitization.enabled:
|
286
255
|
uri = sanitize_url(interaction.request.uri, config=config.output.sanitization)
|
287
256
|
else:
|
288
257
|
uri = interaction.request.uri
|
258
|
+
|
289
259
|
recorded_at = datetime.datetime.fromtimestamp(
|
290
260
|
interaction.timestamp, datetime.timezone.utc
|
291
261
|
).isoformat()
|
262
|
+
|
263
|
+
stream.write(f"\n recorded_at: '{recorded_at}'")
|
264
|
+
write_checks(stream, checks)
|
292
265
|
stream.write(
|
293
|
-
f""
|
294
|
-
recorded_at: '{recorded_at}'{format_checks(checks)}
|
295
|
-
request:
|
296
|
-
uri: '{uri}'
|
297
|
-
method: '{interaction.request.method}'
|
298
|
-
headers:
|
299
|
-
{format_headers(interaction.request.headers)}"""
|
266
|
+
f"\n request:\n uri: '{uri}'\n method: '{interaction.request.method}'\n headers:\n"
|
300
267
|
)
|
301
|
-
|
268
|
+
write_headers(stream, interaction.request.headers)
|
269
|
+
write_request_body(stream, interaction.request)
|
270
|
+
|
271
|
+
# Write response
|
302
272
|
if interaction.response is not None:
|
303
273
|
stream.write(
|
304
|
-
f""
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
headers:
|
311
|
-
{format_headers(interaction.response.headers)}
|
312
|
-
"""
|
313
|
-
)
|
314
|
-
format_response_body(stream, interaction.response)
|
315
|
-
stream.write(
|
316
|
-
f"""
|
317
|
-
http_version: '{interaction.response.http_version}'"""
|
274
|
+
f"\n response:\n"
|
275
|
+
f" status:\n"
|
276
|
+
f" code: '{interaction.response.status_code}'\n"
|
277
|
+
f" message: {json.dumps(interaction.response.message)}\n"
|
278
|
+
f" elapsed: '{interaction.response.elapsed}'\n"
|
279
|
+
f" headers:\n"
|
318
280
|
)
|
281
|
+
write_headers(stream, interaction.response.headers)
|
282
|
+
stream.write("\n")
|
283
|
+
write_response_body(stream, interaction.response)
|
284
|
+
stream.write(f"\n http_version: '{interaction.response.http_version}'")
|
319
285
|
else:
|
320
|
-
stream.write(""
|
321
|
-
|
286
|
+
stream.write("\n response: null")
|
287
|
+
|
322
288
|
current_id += 1
|
289
|
+
elif isinstance(item, Initialize):
|
290
|
+
stream.write(
|
291
|
+
f"command: '{get_command_representation()}'\n"
|
292
|
+
f"recorded_with: 'Schemathesis {SCHEMATHESIS_VERSION}'\n"
|
293
|
+
f"seed: {item.seed}\n"
|
294
|
+
f"http_interactions:"
|
295
|
+
)
|
323
296
|
else:
|
324
297
|
break
|
325
298
|
|
@@ -367,8 +340,8 @@ def write_double_quoted(stream: IO, text: str | None) -> None:
|
|
367
340
|
stream.write('"')
|
368
341
|
|
369
342
|
|
370
|
-
def har_writer(
|
371
|
-
with harfile.open(
|
343
|
+
def har_writer(output: TextOutput, config: SchemathesisConfig, queue: Queue) -> None:
|
344
|
+
with harfile.open(output) as har:
|
372
345
|
while True:
|
373
346
|
item = queue.get()
|
374
347
|
if isinstance(item, Process):
|
@@ -454,7 +427,6 @@ def har_writer(path: Path, config: SchemathesisConfig, queue: Queue) -> None:
|
|
454
427
|
)
|
455
428
|
elif isinstance(item, Finalize):
|
456
429
|
break
|
457
|
-
har.flush()
|
458
430
|
|
459
431
|
|
460
432
|
HARFILE_NO_RESPONSE = harfile.Response(
|
@@ -2,26 +2,25 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
import platform
|
4
4
|
from dataclasses import dataclass
|
5
|
-
from pathlib import Path
|
6
5
|
from typing import Iterable
|
7
6
|
|
8
7
|
from junit_xml import TestCase, TestSuite, to_xml_report_file
|
9
8
|
|
10
9
|
from schemathesis.cli.commands.run.context import ExecutionContext, GroupedFailures
|
11
|
-
from schemathesis.cli.commands.run.handlers.base import EventHandler
|
10
|
+
from schemathesis.cli.commands.run.handlers.base import EventHandler, TextOutput, open_text_output
|
12
11
|
from schemathesis.core.failures import format_failures
|
13
12
|
from schemathesis.engine import Status, events
|
14
13
|
|
15
14
|
|
16
15
|
@dataclass
|
17
16
|
class JunitXMLHandler(EventHandler):
|
18
|
-
|
17
|
+
output: TextOutput
|
19
18
|
test_cases: dict
|
20
19
|
|
21
20
|
__slots__ = ("path", "test_cases")
|
22
21
|
|
23
|
-
def __init__(self,
|
24
|
-
self.
|
22
|
+
def __init__(self, output: TextOutput, test_cases: dict | None = None) -> None:
|
23
|
+
self.output = output
|
25
24
|
self.test_cases = test_cases or {}
|
26
25
|
|
27
26
|
def handle_event(self, ctx: ExecutionContext, event: events.EngineEvent) -> None:
|
@@ -40,7 +39,7 @@ class JunitXMLHandler(EventHandler):
|
|
40
39
|
test_suites = [
|
41
40
|
TestSuite("schemathesis", test_cases=list(self.test_cases.values()), hostname=platform.node())
|
42
41
|
]
|
43
|
-
with
|
42
|
+
with open_text_output(self.output) as fd:
|
44
43
|
to_xml_report_file(file_descriptor=fd, test_suites=test_suites, prettyprint=True, encoding="utf-8")
|
45
44
|
|
46
45
|
def get_or_create_test_case(self, label: str) -> TestCase:
|
@@ -1053,6 +1053,8 @@ class OutputHandler(EventHandler):
|
|
1053
1053
|
self._check_stateful_warnings(ctx, event)
|
1054
1054
|
|
1055
1055
|
def _check_warnings(self, ctx: ExecutionContext, event: events.ScenarioFinished) -> None:
|
1056
|
+
from schemathesis.core.compat import RefResolutionError
|
1057
|
+
|
1056
1058
|
statistic = aggregate_status_codes(event.recorder.interactions.values())
|
1057
1059
|
|
1058
1060
|
if statistic.total == 0:
|
@@ -1060,7 +1062,11 @@ class OutputHandler(EventHandler):
|
|
1060
1062
|
|
1061
1063
|
assert ctx.find_operation_by_label is not None
|
1062
1064
|
assert event.label is not None
|
1063
|
-
|
1065
|
+
try:
|
1066
|
+
operation = ctx.find_operation_by_label(event.label)
|
1067
|
+
except RefResolutionError:
|
1068
|
+
# This error will be reported elsewhere anyway
|
1069
|
+
return None
|
1064
1070
|
|
1065
1071
|
warnings = self.config.warnings_for(operation=operation)
|
1066
1072
|
|
schemathesis/cli/ext/fs.py
CHANGED
@@ -12,5 +12,5 @@ def open_file(file: Path) -> None:
|
|
12
12
|
raise click.BadParameter(f"'{file.name}': {exc.strerror}") from exc
|
13
13
|
try:
|
14
14
|
file.open("w", encoding="utf-8")
|
15
|
-
except OSError as exc:
|
15
|
+
except (OSError, ValueError) as exc:
|
16
16
|
raise click.BadParameter(f"Could not open file {file.name}: {exc}") from exc
|
@@ -69,12 +69,14 @@ class DiffBase:
|
|
69
69
|
@classmethod
|
70
70
|
def from_hierarchy(cls, configs: list[T]) -> T:
|
71
71
|
# This config will accumulate "merged" config options
|
72
|
+
if len(configs) == 1:
|
73
|
+
return configs[0]
|
72
74
|
output = cls()
|
73
75
|
for option in cls.__slots__: # type: ignore
|
74
76
|
if option.startswith("_"):
|
75
77
|
continue
|
76
78
|
default = getattr(output, option)
|
77
|
-
if
|
79
|
+
if hasattr(default, "__dataclass_fields__"):
|
78
80
|
# Sub-configs require merging of nested config options
|
79
81
|
sub_configs = [getattr(config, option) for config in configs]
|
80
82
|
merged = type(default).from_hierarchy(sub_configs) # type: ignore[union-attr]
|
@@ -68,6 +68,8 @@ class OperationsConfig(DiffBase):
|
|
68
68
|
|
69
69
|
def get_for_operation(self, operation: APIOperation) -> OperationConfig:
|
70
70
|
configs = [config for config in self.operations if config._filter_set.applies_to(operation)]
|
71
|
+
if not configs:
|
72
|
+
return OperationConfig()
|
71
73
|
return OperationConfig.from_hierarchy(configs)
|
72
74
|
|
73
75
|
def create_filter_set(
|
schemathesis/config/_phases.py
CHANGED
@@ -17,7 +17,7 @@ class PhaseConfig(DiffBase):
|
|
17
17
|
generation: GenerationConfig
|
18
18
|
checks: ChecksConfig
|
19
19
|
|
20
|
-
__slots__ = ("enabled", "generation", "checks")
|
20
|
+
__slots__ = ("enabled", "generation", "checks", "_is_default")
|
21
21
|
|
22
22
|
def __init__(
|
23
23
|
self,
|
@@ -29,6 +29,7 @@ class PhaseConfig(DiffBase):
|
|
29
29
|
self.enabled = enabled
|
30
30
|
self.generation = generation or GenerationConfig()
|
31
31
|
self.checks = checks or ChecksConfig()
|
32
|
+
self._is_default = enabled and generation is None and checks is None
|
32
33
|
|
33
34
|
@classmethod
|
34
35
|
def from_dict(cls, data: dict[str, Any]) -> PhaseConfig:
|
@@ -46,7 +47,7 @@ class ExamplesPhaseConfig(DiffBase):
|
|
46
47
|
generation: GenerationConfig
|
47
48
|
checks: ChecksConfig
|
48
49
|
|
49
|
-
__slots__ = ("enabled", "fill_missing", "generation", "checks")
|
50
|
+
__slots__ = ("enabled", "fill_missing", "generation", "checks", "_is_default")
|
50
51
|
|
51
52
|
def __init__(
|
52
53
|
self,
|
@@ -60,6 +61,7 @@ class ExamplesPhaseConfig(DiffBase):
|
|
60
61
|
self.fill_missing = fill_missing
|
61
62
|
self.generation = generation or GenerationConfig()
|
62
63
|
self.checks = checks or ChecksConfig()
|
64
|
+
self._is_default = enabled and not fill_missing and generation is None and checks is None
|
63
65
|
|
64
66
|
@classmethod
|
65
67
|
def from_dict(cls, data: dict[str, Any]) -> ExamplesPhaseConfig:
|
@@ -79,7 +81,14 @@ class CoveragePhaseConfig(DiffBase):
|
|
79
81
|
checks: ChecksConfig
|
80
82
|
unexpected_methods: set[str]
|
81
83
|
|
82
|
-
__slots__ = (
|
84
|
+
__slots__ = (
|
85
|
+
"enabled",
|
86
|
+
"generate_duplicate_query_parameters",
|
87
|
+
"generation",
|
88
|
+
"checks",
|
89
|
+
"unexpected_methods",
|
90
|
+
"_is_default",
|
91
|
+
)
|
83
92
|
|
84
93
|
def __init__(
|
85
94
|
self,
|
@@ -95,6 +104,13 @@ class CoveragePhaseConfig(DiffBase):
|
|
95
104
|
self.unexpected_methods = unexpected_methods or DEFAULT_UNEXPECTED_METHODS
|
96
105
|
self.generation = generation or GenerationConfig()
|
97
106
|
self.checks = checks or ChecksConfig()
|
107
|
+
self._is_default = (
|
108
|
+
enabled
|
109
|
+
and not generate_duplicate_query_parameters
|
110
|
+
and generation is None
|
111
|
+
and checks is None
|
112
|
+
and unexpected_methods is None
|
113
|
+
)
|
98
114
|
|
99
115
|
@classmethod
|
100
116
|
def from_dict(cls, data: dict[str, Any]) -> CoveragePhaseConfig:
|
@@ -142,7 +158,7 @@ class StatefulPhaseConfig(DiffBase):
|
|
142
158
|
max_steps: int
|
143
159
|
inference: InferenceConfig
|
144
160
|
|
145
|
-
__slots__ = ("enabled", "generation", "checks", "max_steps", "inference")
|
161
|
+
__slots__ = ("enabled", "generation", "checks", "max_steps", "inference", "_is_default")
|
146
162
|
|
147
163
|
def __init__(
|
148
164
|
self,
|
@@ -158,6 +174,7 @@ class StatefulPhaseConfig(DiffBase):
|
|
158
174
|
self.generation = generation or GenerationConfig()
|
159
175
|
self.checks = checks or ChecksConfig()
|
160
176
|
self.inference = inference or InferenceConfig()
|
177
|
+
self._is_default = enabled and generation is None and checks is None and max_steps is None and inference is None
|
161
178
|
|
162
179
|
@classmethod
|
163
180
|
def from_dict(cls, data: dict[str, Any]) -> StatefulPhaseConfig:
|
schemathesis/config/_projects.py
CHANGED
@@ -347,6 +347,8 @@ class ProjectConfig(DiffBase):
|
|
347
347
|
for op in self.operations.operations:
|
348
348
|
if op._filter_set.applies_to(operation=operation):
|
349
349
|
configs.append(op.phases)
|
350
|
+
if not configs:
|
351
|
+
return self.phases
|
350
352
|
configs.append(self.phases)
|
351
353
|
return PhasesConfig.from_hierarchy(configs)
|
352
354
|
|
@@ -367,7 +369,10 @@ class ProjectConfig(DiffBase):
|
|
367
369
|
if phase is not None:
|
368
370
|
phases = self.phases_for(operation=operation)
|
369
371
|
phase_config = phases.get_by_name(name=phase)
|
370
|
-
|
372
|
+
if not phase_config._is_default:
|
373
|
+
configs.append(phase_config.generation)
|
374
|
+
if not configs:
|
375
|
+
return self.generation
|
371
376
|
configs.append(self.generation)
|
372
377
|
return GenerationConfig.from_hierarchy(configs)
|
373
378
|
|
@@ -388,7 +393,10 @@ class ProjectConfig(DiffBase):
|
|
388
393
|
if phase is not None:
|
389
394
|
phases = self.phases_for(operation=operation)
|
390
395
|
phase_config = phases.get_by_name(name=phase)
|
391
|
-
|
396
|
+
if not phase_config._is_default:
|
397
|
+
configs.append(phase_config.checks)
|
398
|
+
if not configs:
|
399
|
+
return self.checks
|
392
400
|
configs.append(self.checks)
|
393
401
|
return ChecksConfig.from_hierarchy(configs)
|
394
402
|
|