schemathesis 4.0.0a9__py3-none-any.whl → 4.0.0a11__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 +3 -7
- schemathesis/checks.py +17 -7
- schemathesis/cli/commands/__init__.py +51 -3
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +147 -260
- schemathesis/cli/commands/run/context.py +2 -3
- schemathesis/cli/commands/run/events.py +4 -0
- schemathesis/cli/commands/run/executor.py +60 -73
- schemathesis/cli/commands/run/filters.py +15 -165
- schemathesis/cli/commands/run/handlers/cassettes.py +105 -104
- schemathesis/cli/commands/run/handlers/junitxml.py +6 -5
- schemathesis/cli/commands/run/handlers/output.py +26 -47
- schemathesis/cli/commands/run/loaders.py +35 -50
- schemathesis/cli/commands/run/validation.py +36 -161
- schemathesis/cli/core.py +5 -3
- schemathesis/cli/ext/fs.py +7 -5
- schemathesis/cli/ext/options.py +0 -21
- schemathesis/config/__init__.py +188 -0
- schemathesis/config/_auth.py +51 -0
- schemathesis/config/_checks.py +268 -0
- schemathesis/config/_diff_base.py +99 -0
- schemathesis/config/_env.py +21 -0
- schemathesis/config/_error.py +156 -0
- schemathesis/config/_generation.py +150 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +313 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +151 -0
- schemathesis/config/_projects.py +495 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +116 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/schema.json +837 -0
- schemathesis/core/__init__.py +3 -0
- schemathesis/core/compat.py +16 -9
- schemathesis/core/errors.py +19 -2
- schemathesis/core/failures.py +6 -7
- schemathesis/core/hooks.py +20 -0
- schemathesis/core/output/__init__.py +14 -37
- schemathesis/core/output/sanitization.py +3 -146
- schemathesis/core/validation.py +16 -0
- schemathesis/engine/__init__.py +2 -4
- schemathesis/engine/context.py +41 -43
- schemathesis/engine/core.py +7 -5
- schemathesis/engine/phases/__init__.py +10 -0
- schemathesis/engine/phases/probes.py +8 -8
- schemathesis/engine/phases/stateful/_executor.py +68 -43
- schemathesis/engine/phases/unit/__init__.py +23 -15
- schemathesis/engine/phases/unit/_executor.py +77 -17
- schemathesis/engine/phases/unit/_pool.py +1 -1
- schemathesis/errors.py +2 -0
- schemathesis/filters.py +2 -3
- schemathesis/generation/__init__.py +6 -31
- schemathesis/generation/case.py +5 -3
- schemathesis/generation/coverage.py +174 -134
- schemathesis/generation/hypothesis/__init__.py +7 -1
- schemathesis/generation/hypothesis/builder.py +40 -14
- schemathesis/generation/meta.py +3 -3
- schemathesis/generation/overrides.py +37 -1
- schemathesis/generation/stateful/state_machine.py +8 -1
- schemathesis/graphql/loaders.py +21 -12
- schemathesis/openapi/checks.py +12 -8
- schemathesis/openapi/generation/filters.py +10 -8
- schemathesis/openapi/loaders.py +22 -13
- schemathesis/pytest/lazy.py +2 -5
- schemathesis/pytest/plugin.py +11 -2
- schemathesis/schemas.py +13 -61
- schemathesis/specs/graphql/schemas.py +11 -15
- schemathesis/specs/openapi/_hypothesis.py +12 -8
- schemathesis/specs/openapi/checks.py +16 -18
- schemathesis/specs/openapi/examples.py +4 -3
- schemathesis/specs/openapi/formats.py +2 -2
- schemathesis/specs/openapi/negative/__init__.py +2 -2
- schemathesis/specs/openapi/patterns.py +46 -16
- schemathesis/specs/openapi/references.py +2 -3
- schemathesis/specs/openapi/schemas.py +11 -20
- schemathesis/specs/openapi/stateful/__init__.py +10 -5
- schemathesis/transport/prepare.py +7 -6
- schemathesis/transport/requests.py +3 -1
- schemathesis/transport/wsgi.py +3 -4
- {schemathesis-4.0.0a9.dist-info → schemathesis-4.0.0a11.dist-info}/METADATA +7 -8
- schemathesis-4.0.0a11.dist-info/RECORD +166 -0
- schemathesis/cli/commands/run/checks.py +0 -79
- schemathesis/cli/commands/run/hypothesis.py +0 -78
- schemathesis/cli/commands/run/reports.py +0 -72
- schemathesis/cli/hooks.py +0 -36
- schemathesis/engine/config.py +0 -59
- schemathesis/experimental/__init__.py +0 -72
- schemathesis-4.0.0a9.dist-info/RECORD +0 -153
- {schemathesis-4.0.0a9.dist-info → schemathesis-4.0.0a11.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.0a9.dist-info → schemathesis-4.0.0a11.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.0.0a9.dist-info → schemathesis-4.0.0a11.dist-info}/licenses/LICENSE +0 -0
@@ -6,16 +6,16 @@ import sys
|
|
6
6
|
import threading
|
7
7
|
from dataclasses import dataclass, field
|
8
8
|
from http.cookies import SimpleCookie
|
9
|
+
from pathlib import Path
|
9
10
|
from queue import Queue
|
10
11
|
from typing import IO, Callable, Iterator
|
11
12
|
from urllib.parse import parse_qsl, urlparse
|
12
13
|
|
13
14
|
import harfile
|
14
|
-
from click.utils import LazyFile
|
15
15
|
|
16
16
|
from schemathesis.cli.commands.run.context import ExecutionContext
|
17
17
|
from schemathesis.cli.commands.run.handlers.base import EventHandler
|
18
|
-
from schemathesis.
|
18
|
+
from schemathesis.config import ProjectConfig, ReportFormat, SchemathesisConfig
|
19
19
|
from schemathesis.core.output.sanitization import sanitize_url, sanitize_value
|
20
20
|
from schemathesis.core.transforms import deepclone
|
21
21
|
from schemathesis.core.transport import Response
|
@@ -33,17 +33,15 @@ class CassetteWriter(EventHandler):
|
|
33
33
|
"""Write network interactions to a cassette."""
|
34
34
|
|
35
35
|
format: ReportFormat
|
36
|
-
path:
|
37
|
-
|
38
|
-
preserve_bytes: bool = False
|
36
|
+
path: Path
|
37
|
+
config: ProjectConfig
|
39
38
|
queue: Queue = field(default_factory=Queue)
|
40
39
|
worker: threading.Thread = field(init=False)
|
41
40
|
|
42
41
|
def __post_init__(self) -> None:
|
43
42
|
kwargs = {
|
44
43
|
"path": self.path,
|
45
|
-
"
|
46
|
-
"preserve_bytes": self.preserve_bytes,
|
44
|
+
"config": self.config,
|
47
45
|
"queue": self.queue,
|
48
46
|
}
|
49
47
|
writer: Callable
|
@@ -55,7 +53,7 @@ class CassetteWriter(EventHandler):
|
|
55
53
|
self.worker.start()
|
56
54
|
|
57
55
|
def start(self, ctx: ExecutionContext) -> None:
|
58
|
-
self.queue.put(Initialize(seed=ctx.seed))
|
56
|
+
self.queue.put(Initialize(seed=ctx.config.seed))
|
59
57
|
|
60
58
|
def handle_event(self, ctx: ExecutionContext, event: events.EngineEvent) -> None:
|
61
59
|
if isinstance(event, events.ScenarioFinished):
|
@@ -103,7 +101,7 @@ def get_command_representation() -> str:
|
|
103
101
|
return f"st {args}"
|
104
102
|
|
105
103
|
|
106
|
-
def vcr_writer(path:
|
104
|
+
def vcr_writer(path: Path, config: ProjectConfig, queue: Queue) -> None:
|
107
105
|
"""Write YAML to a file in an incremental manner.
|
108
106
|
|
109
107
|
This implementation doesn't use `pyyaml` package and composes YAML manually as string due to the following reasons:
|
@@ -114,16 +112,15 @@ def vcr_writer(path: LazyFile, sanitize_output: bool, preserve_bytes: bool, queu
|
|
114
112
|
providing tags, anchors to have incremental writing, with primitive types it is much simpler.
|
115
113
|
"""
|
116
114
|
current_id = 1
|
117
|
-
stream = path.open()
|
118
115
|
|
119
116
|
def format_header_values(values: list[str]) -> str:
|
120
117
|
return "\n".join(f" - {json.dumps(v)}" for v in values)
|
121
118
|
|
122
|
-
if
|
119
|
+
if config.output.sanitization.enabled:
|
123
120
|
|
124
121
|
def format_headers(headers: dict[str, list[str]]) -> str:
|
125
122
|
headers = deepclone(headers)
|
126
|
-
sanitize_value(headers)
|
123
|
+
sanitize_value(headers, config=config.output.sanitization)
|
127
124
|
return "\n".join(f' "{name}":\n{format_header_values(values)}' for name, values in headers.items())
|
128
125
|
|
129
126
|
else:
|
@@ -145,7 +142,7 @@ def vcr_writer(path: LazyFile, sanitize_output: bool, preserve_bytes: bool, queu
|
|
145
142
|
checks:
|
146
143
|
{items}"""
|
147
144
|
|
148
|
-
if preserve_bytes:
|
145
|
+
if config.reports.preserve_bytes:
|
149
146
|
|
150
147
|
def format_request_body(output: IO, request: Request) -> None:
|
151
148
|
if request.encoded_body is not None:
|
@@ -188,102 +185,105 @@ def vcr_writer(path: LazyFile, sanitize_output: bool, preserve_bytes: bool, queu
|
|
188
185
|
)
|
189
186
|
write_double_quoted(output, string)
|
190
187
|
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
188
|
+
with open(path, "w", encoding="utf-8") as stream:
|
189
|
+
while True:
|
190
|
+
item = queue.get()
|
191
|
+
if isinstance(item, Initialize):
|
192
|
+
stream.write(
|
193
|
+
f"""command: '{get_command_representation()}'
|
196
194
|
recorded_with: 'Schemathesis {SCHEMATHESIS_VERSION}'
|
197
195
|
seed: {item.seed}
|
198
196
|
http_interactions:"""
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
197
|
+
)
|
198
|
+
elif isinstance(item, Process):
|
199
|
+
for case_id, interaction in item.recorder.interactions.items():
|
200
|
+
case = item.recorder.cases[case_id]
|
201
|
+
if interaction.response is not None:
|
202
|
+
if case_id in item.recorder.checks:
|
203
|
+
checks = item.recorder.checks[case_id]
|
204
|
+
status = Status.SUCCESS
|
205
|
+
for check in checks:
|
206
|
+
if check.status == Status.FAILURE:
|
207
|
+
status = check.status
|
208
|
+
break
|
209
|
+
else:
|
210
|
+
# NOTE: Checks recording could be skipped if Schemathesis start skipping just
|
211
|
+
# discovered failures in order to get past them and potentially discover more failures
|
212
|
+
checks = []
|
213
|
+
status = Status.SKIP
|
211
214
|
else:
|
212
|
-
# NOTE: Checks recording could be skipped if Schemathesis start skipping just
|
213
|
-
# discovered failures in order to get past them and potentially discover more failures
|
214
215
|
checks = []
|
215
|
-
status = Status.
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
# Body payloads are handled via separate `stream.write` calls to avoid some allocations
|
220
|
-
stream.write(
|
221
|
-
f"""\n- id: '{case_id}'
|
216
|
+
status = Status.ERROR
|
217
|
+
# Body payloads are handled via separate `stream.write` calls to avoid some allocations
|
218
|
+
stream.write(
|
219
|
+
f"""\n- id: '{case_id}'
|
222
220
|
status: '{status.name}'"""
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
221
|
+
)
|
222
|
+
meta = case.value.meta
|
223
|
+
if meta is not None:
|
224
|
+
# Start metadata block
|
225
|
+
stream.write(f"""
|
228
226
|
generation:
|
229
227
|
time: {meta.generation.time}
|
230
228
|
mode: {meta.generation.mode.value}
|
231
229
|
components:""")
|
232
230
|
|
233
|
-
|
234
|
-
|
235
|
-
|
231
|
+
# Write components
|
232
|
+
for kind, info in meta.components.items():
|
233
|
+
stream.write(f"""
|
236
234
|
{kind.value}:
|
237
235
|
mode: '{info.mode.value}'""")
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
236
|
+
# Write phase info
|
237
|
+
stream.write("\n phase:")
|
238
|
+
stream.write(f"\n name: '{meta.phase.name.value}'")
|
239
|
+
stream.write("\n data: ")
|
240
|
+
|
241
|
+
# Write phase-specific data
|
242
|
+
if isinstance(meta.phase.data, CoveragePhaseData):
|
243
|
+
stream.write("""
|
246
244
|
description: """)
|
247
|
-
|
248
|
-
|
245
|
+
write_double_quoted(stream, meta.phase.data.description)
|
246
|
+
stream.write("""
|
249
247
|
location: """)
|
250
|
-
|
251
|
-
|
248
|
+
write_double_quoted(stream, meta.phase.data.location)
|
249
|
+
stream.write("""
|
252
250
|
parameter: """)
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
251
|
+
if meta.phase.data.parameter is not None:
|
252
|
+
write_double_quoted(stream, meta.phase.data.parameter)
|
253
|
+
else:
|
254
|
+
stream.write("null")
|
255
|
+
stream.write("""
|
258
256
|
parameter_location: """)
|
259
|
-
|
260
|
-
|
257
|
+
if meta.phase.data.parameter_location is not None:
|
258
|
+
write_double_quoted(stream, meta.phase.data.parameter_location)
|
259
|
+
else:
|
260
|
+
stream.write("null")
|
261
261
|
else:
|
262
|
-
|
262
|
+
# Empty objects for these phases
|
263
|
+
stream.write("{}")
|
263
264
|
else:
|
264
|
-
|
265
|
-
stream.write("{}")
|
266
|
-
else:
|
267
|
-
stream.write("null")
|
265
|
+
stream.write("null")
|
268
266
|
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
267
|
+
if config.output.sanitization.enabled:
|
268
|
+
uri = sanitize_url(interaction.request.uri, config=config.output.sanitization)
|
269
|
+
else:
|
270
|
+
uri = interaction.request.uri
|
271
|
+
recorded_at = datetime.datetime.fromtimestamp(
|
272
|
+
interaction.timestamp, datetime.timezone.utc
|
273
|
+
).isoformat()
|
274
|
+
stream.write(
|
275
|
+
f"""
|
276
276
|
recorded_at: '{recorded_at}'{format_checks(checks)}
|
277
277
|
request:
|
278
278
|
uri: '{uri}'
|
279
279
|
method: '{interaction.request.method}'
|
280
280
|
headers:
|
281
281
|
{format_headers(interaction.request.headers)}"""
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
282
|
+
)
|
283
|
+
format_request_body(stream, interaction.request)
|
284
|
+
if interaction.response is not None:
|
285
|
+
stream.write(
|
286
|
+
f"""
|
287
287
|
response:
|
288
288
|
status:
|
289
289
|
code: '{interaction.response.status_code}'
|
@@ -292,19 +292,18 @@ http_interactions:"""
|
|
292
292
|
headers:
|
293
293
|
{format_headers(interaction.response.headers)}
|
294
294
|
"""
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
295
|
+
)
|
296
|
+
format_response_body(stream, interaction.response)
|
297
|
+
stream.write(
|
298
|
+
f"""
|
299
299
|
http_version: '{interaction.response.http_version}'"""
|
300
|
-
|
301
|
-
|
302
|
-
|
300
|
+
)
|
301
|
+
else:
|
302
|
+
stream.write("""
|
303
303
|
response: null""")
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
path.close()
|
304
|
+
current_id += 1
|
305
|
+
else:
|
306
|
+
break
|
308
307
|
|
309
308
|
|
310
309
|
def write_double_quoted(stream: IO, text: str | None) -> None:
|
@@ -350,14 +349,14 @@ def write_double_quoted(stream: IO, text: str | None) -> None:
|
|
350
349
|
stream.write('"')
|
351
350
|
|
352
351
|
|
353
|
-
def har_writer(path:
|
352
|
+
def har_writer(path: Path, config: SchemathesisConfig, queue: Queue) -> None:
|
354
353
|
with harfile.open(path) as har:
|
355
354
|
while True:
|
356
355
|
item = queue.get()
|
357
356
|
if isinstance(item, Process):
|
358
357
|
for interaction in item.recorder.interactions.values():
|
359
|
-
if
|
360
|
-
uri = sanitize_url(interaction.request.uri)
|
358
|
+
if config.output.sanitization.enabled:
|
359
|
+
uri = sanitize_url(interaction.request.uri, config=config.output.sanitization)
|
361
360
|
else:
|
362
361
|
uri = interaction.request.uri
|
363
362
|
query_params = urlparse(uri).query
|
@@ -365,7 +364,7 @@ def har_writer(path: LazyFile, sanitize_output: bool, preserve_bytes: bool, queu
|
|
365
364
|
post_data = harfile.PostData(
|
366
365
|
mimeType=interaction.request.headers.get("Content-Type", [""])[0],
|
367
366
|
text=interaction.request.encoded_body
|
368
|
-
if preserve_bytes
|
367
|
+
if config.reports.preserve_bytes
|
369
368
|
else interaction.request.body.decode("utf-8", "replace"),
|
370
369
|
)
|
371
370
|
else:
|
@@ -376,16 +375,18 @@ def har_writer(path: LazyFile, sanitize_output: bool, preserve_bytes: bool, queu
|
|
376
375
|
size=interaction.response.body_size or 0,
|
377
376
|
mimeType=content_type,
|
378
377
|
text=interaction.response.encoded_body
|
379
|
-
if preserve_bytes
|
378
|
+
if config.reports.preserve_bytes
|
380
379
|
else interaction.response.content.decode("utf-8", "replace")
|
381
380
|
if interaction.response.content is not None
|
382
381
|
else None,
|
383
|
-
encoding="base64"
|
382
|
+
encoding="base64"
|
383
|
+
if interaction.response.content is not None and config.reports.preserve_bytes
|
384
|
+
else None,
|
384
385
|
)
|
385
386
|
http_version = f"HTTP/{interaction.response.http_version}"
|
386
|
-
if
|
387
|
+
if config.output.sanitization.enabled:
|
387
388
|
headers = deepclone(interaction.response.headers)
|
388
|
-
sanitize_value(headers)
|
389
|
+
sanitize_value(headers, config=config.output.sanitization)
|
389
390
|
else:
|
390
391
|
headers = interaction.response.headers
|
391
392
|
response = harfile.Response(
|
@@ -405,9 +406,9 @@ def har_writer(path: LazyFile, sanitize_output: bool, preserve_bytes: bool, queu
|
|
405
406
|
time = 0
|
406
407
|
http_version = ""
|
407
408
|
|
408
|
-
if
|
409
|
+
if config.output.sanitization.enabled:
|
409
410
|
headers = deepclone(interaction.request.headers)
|
410
|
-
sanitize_value(headers)
|
411
|
+
sanitize_value(headers, config=config.output.sanitization)
|
411
412
|
else:
|
412
413
|
headers = interaction.request.headers
|
413
414
|
started_datetime = datetime.datetime.fromtimestamp(
|
@@ -2,9 +2,9 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
import platform
|
4
4
|
from dataclasses import dataclass, field
|
5
|
+
from pathlib import Path
|
5
6
|
from typing import Iterable
|
6
7
|
|
7
|
-
from click.utils import LazyFile
|
8
8
|
from junit_xml import TestCase, TestSuite, to_xml_report_file
|
9
9
|
|
10
10
|
from schemathesis.cli.commands.run.context import ExecutionContext, GroupedFailures
|
@@ -15,7 +15,7 @@ from schemathesis.engine import Status, events
|
|
15
15
|
|
16
16
|
@dataclass
|
17
17
|
class JunitXMLHandler(EventHandler):
|
18
|
-
|
18
|
+
path: Path
|
19
19
|
test_cases: dict = field(default_factory=dict)
|
20
20
|
|
21
21
|
def handle_event(self, ctx: ExecutionContext, event: events.EngineEvent) -> None:
|
@@ -23,7 +23,7 @@ class JunitXMLHandler(EventHandler):
|
|
23
23
|
label = event.recorder.label
|
24
24
|
test_case = self.get_or_create_test_case(label)
|
25
25
|
test_case.elapsed_sec += event.elapsed_time
|
26
|
-
if event.status == Status.FAILURE:
|
26
|
+
if event.status == Status.FAILURE and label in ctx.statistic.failures:
|
27
27
|
add_failure(test_case, ctx.statistic.failures[label].values(), ctx)
|
28
28
|
elif event.status == Status.SKIP and event.skip_reason is not None:
|
29
29
|
test_case.add_skipped_info(output=event.skip_reason)
|
@@ -34,7 +34,8 @@ class JunitXMLHandler(EventHandler):
|
|
34
34
|
test_suites = [
|
35
35
|
TestSuite("schemathesis", test_cases=list(self.test_cases.values()), hostname=platform.node())
|
36
36
|
]
|
37
|
-
|
37
|
+
with open(self.path, "w") as fd:
|
38
|
+
to_xml_report_file(file_descriptor=fd, test_suites=test_suites, prettyprint=True)
|
38
39
|
|
39
40
|
def get_or_create_test_case(self, label: str) -> TestCase:
|
40
41
|
return self.test_cases.setdefault(label, TestCase(label, elapsed_sec=0.0, allow_multiple_subelements=True))
|
@@ -47,7 +48,7 @@ def add_failure(test_case: TestCase, checks: Iterable[GroupedFailures], context:
|
|
47
48
|
response=group.response,
|
48
49
|
failures=group.failures,
|
49
50
|
curl=group.code_sample,
|
50
|
-
config=context.
|
51
|
+
config=context.config.output,
|
51
52
|
)
|
52
53
|
for idx, group in enumerate(checks, 1)
|
53
54
|
]
|
@@ -13,21 +13,19 @@ import click
|
|
13
13
|
from schemathesis.cli.commands.run.context import ExecutionContext, GroupedFailures
|
14
14
|
from schemathesis.cli.commands.run.events import LoadingFinished, LoadingStarted
|
15
15
|
from schemathesis.cli.commands.run.handlers.base import EventHandler
|
16
|
-
from schemathesis.cli.commands.run.reports import ReportConfig, ReportFormat
|
17
16
|
from schemathesis.cli.constants import ISSUE_TRACKER_URL
|
18
17
|
from schemathesis.cli.core import get_terminal_width
|
18
|
+
from schemathesis.config import ProjectConfig, ReportFormat
|
19
19
|
from schemathesis.core.errors import LoaderError, LoaderErrorKind, format_exception, split_traceback
|
20
20
|
from schemathesis.core.failures import MessageBlock, Severity, format_failures
|
21
21
|
from schemathesis.core.output import prepare_response_payload
|
22
22
|
from schemathesis.core.result import Err, Ok
|
23
23
|
from schemathesis.core.version import SCHEMATHESIS_VERSION
|
24
24
|
from schemathesis.engine import Status, events
|
25
|
-
from schemathesis.engine.config import EngineConfig
|
26
25
|
from schemathesis.engine.errors import EngineErrorInfo
|
27
26
|
from schemathesis.engine.phases import PhaseName, PhaseSkipReason
|
28
27
|
from schemathesis.engine.phases.probes import ProbeOutcome
|
29
28
|
from schemathesis.engine.recorder import Interaction, ScenarioRecorder
|
30
|
-
from schemathesis.experimental import GLOBAL_EXPERIMENTS
|
31
29
|
from schemathesis.generation.modes import GenerationMode
|
32
30
|
from schemathesis.schemas import ApiStatistic
|
33
31
|
|
@@ -100,7 +98,7 @@ def display_failures_for_single_test(ctx: ExecutionContext, label: str, checks:
|
|
100
98
|
failures=group.failures,
|
101
99
|
curl=group.code_sample,
|
102
100
|
formatter=failure_formatter,
|
103
|
-
config=ctx.
|
101
|
+
config=ctx.config.output,
|
104
102
|
)
|
105
103
|
)
|
106
104
|
click.echo()
|
@@ -770,12 +768,7 @@ def format_duration(duration_ms: int) -> str:
|
|
770
768
|
|
771
769
|
@dataclass
|
772
770
|
class OutputHandler(EventHandler):
|
773
|
-
|
774
|
-
# Seed can be absent in the deterministic mode
|
775
|
-
seed: int | None
|
776
|
-
rate_limit: str | None
|
777
|
-
wait_for_schema: float | None
|
778
|
-
engine_config: EngineConfig
|
771
|
+
config: ProjectConfig
|
779
772
|
|
780
773
|
loading_manager: LoadingProgressManager | None = None
|
781
774
|
probing_manager: ProbingProgressManager | None = None
|
@@ -784,7 +777,6 @@ class OutputHandler(EventHandler):
|
|
784
777
|
|
785
778
|
statistic: ApiStatistic | None = None
|
786
779
|
skip_reasons: list[str] = field(default_factory=list)
|
787
|
-
report_config: ReportConfig | None = None
|
788
780
|
warnings: WarningData = field(default_factory=WarningData)
|
789
781
|
errors: set[events.NonFatalError] = field(default_factory=set)
|
790
782
|
phases: dict[PhaseName, tuple[Status, PhaseSkipReason | None]] = field(
|
@@ -836,6 +828,8 @@ class OutputHandler(EventHandler):
|
|
836
828
|
from rich.style import Style
|
837
829
|
from rich.table import Table
|
838
830
|
|
831
|
+
self.config = event.config
|
832
|
+
|
839
833
|
assert self.loading_manager is not None
|
840
834
|
self.loading_manager.stop()
|
841
835
|
|
@@ -1068,7 +1062,7 @@ class OutputHandler(EventHandler):
|
|
1068
1062
|
|
1069
1063
|
if (
|
1070
1064
|
event.status == Status.SUCCESS
|
1071
|
-
and GenerationMode.POSITIVE in self.
|
1065
|
+
and GenerationMode.POSITIVE in self.config.generation_for(operation=None, phase=event.phase.name).modes
|
1072
1066
|
and all_positive_are_rejected(event.recorder)
|
1073
1067
|
and statistic.should_warn_about_only_4xx()
|
1074
1068
|
):
|
@@ -1097,6 +1091,7 @@ class OutputHandler(EventHandler):
|
|
1097
1091
|
)
|
1098
1092
|
self.console.print(message)
|
1099
1093
|
self.console.print()
|
1094
|
+
self.probing_manager = None
|
1100
1095
|
|
1101
1096
|
def _on_fatal_error(self, ctx: ExecutionContext, event: events.FatalError) -> None:
|
1102
1097
|
from rich.padding import Padding
|
@@ -1116,7 +1111,9 @@ class OutputHandler(EventHandler):
|
|
1116
1111
|
self.console.print(Padding(Text(extra), (0, 0, 0, 5)))
|
1117
1112
|
self.console.print()
|
1118
1113
|
|
1119
|
-
if not (
|
1114
|
+
if not (
|
1115
|
+
event.exception.kind == LoaderErrorKind.CONNECTION_OTHER and self.config.wait_for_schema is not None
|
1116
|
+
):
|
1120
1117
|
suggestion = LOADER_ERROR_SUGGESTIONS.get(event.exception.kind)
|
1121
1118
|
if suggestion is not None:
|
1122
1119
|
click.echo(_style(f"{click.style('Tip:', bold=True, fg='green')} {suggestion}"))
|
@@ -1134,7 +1131,7 @@ class OutputHandler(EventHandler):
|
|
1134
1131
|
if not (
|
1135
1132
|
isinstance(event.exception, LoaderError)
|
1136
1133
|
and event.exception.kind == LoaderErrorKind.CONNECTION_OTHER
|
1137
|
-
and self.wait_for_schema is not None
|
1134
|
+
and self.config.wait_for_schema is not None
|
1138
1135
|
):
|
1139
1136
|
click.echo(_style(f"\n{click.style('Tip:', bold=True, fg='green')} {suggestion}"))
|
1140
1137
|
|
@@ -1195,25 +1192,6 @@ class OutputHandler(EventHandler):
|
|
1195
1192
|
click.echo(_style("Check base URL or adjust data generation settings", fg="yellow"))
|
1196
1193
|
click.echo()
|
1197
1194
|
|
1198
|
-
def display_experiments(self) -> None:
|
1199
|
-
display_section_name("EXPERIMENTS")
|
1200
|
-
|
1201
|
-
click.echo()
|
1202
|
-
for experiment in sorted(GLOBAL_EXPERIMENTS.enabled, key=lambda e: e.name):
|
1203
|
-
click.echo(_style(f"🧪 {experiment.name}: ", bold=True), nl=False)
|
1204
|
-
click.echo(_style(experiment.description))
|
1205
|
-
click.echo(_style(f" Feedback: {experiment.discussion_url}"))
|
1206
|
-
click.echo()
|
1207
|
-
|
1208
|
-
click.echo(
|
1209
|
-
_style(
|
1210
|
-
"Your feedback is crucial for experimental features. "
|
1211
|
-
"Please visit the provided URL(s) to share your thoughts.",
|
1212
|
-
dim=True,
|
1213
|
-
)
|
1214
|
-
)
|
1215
|
-
click.echo()
|
1216
|
-
|
1217
1195
|
def display_stateful_failures(self, ctx: ExecutionContext) -> None:
|
1218
1196
|
display_section_name("Stateful tests")
|
1219
1197
|
|
@@ -1255,7 +1233,7 @@ class OutputHandler(EventHandler):
|
|
1255
1233
|
click.echo(f"\n{indent}<EMPTY>")
|
1256
1234
|
else:
|
1257
1235
|
try:
|
1258
|
-
payload = prepare_response_payload(response.text, config=ctx.
|
1236
|
+
payload = prepare_response_payload(response.text, config=ctx.config.output)
|
1259
1237
|
click.echo(textwrap.indent(f"\n{payload}", prefix=indent))
|
1260
1238
|
except UnicodeDecodeError:
|
1261
1239
|
click.echo(f"\n{indent}<BINARY>")
|
@@ -1410,24 +1388,27 @@ class OutputHandler(EventHandler):
|
|
1410
1388
|
display_section_name(message, fg=color)
|
1411
1389
|
|
1412
1390
|
def display_reports(self) -> None:
|
1413
|
-
|
1414
|
-
|
1415
|
-
(format.value.upper(), self.report_config.get_path(format).name)
|
1416
|
-
for format in ReportFormat
|
1417
|
-
if format in self.report_config.formats
|
1418
|
-
]
|
1419
|
-
|
1391
|
+
reports = self.config.reports
|
1392
|
+
if reports.vcr.enabled or reports.har.enabled or reports.junit.enabled:
|
1420
1393
|
click.echo(_style("Reports:", bold=True))
|
1421
|
-
for
|
1422
|
-
|
1394
|
+
for format, report in (
|
1395
|
+
(ReportFormat.JUNIT, reports.junit),
|
1396
|
+
(ReportFormat.VCR, reports.vcr),
|
1397
|
+
(ReportFormat.HAR, reports.har),
|
1398
|
+
):
|
1399
|
+
if report.enabled:
|
1400
|
+
path = reports.get_path(format)
|
1401
|
+
click.echo(_style(f" - {format.value.upper()}: {path}"))
|
1423
1402
|
click.echo()
|
1424
1403
|
|
1425
1404
|
def display_seed(self) -> None:
|
1426
1405
|
click.echo(_style("Seed: ", bold=True), nl=False)
|
1427
|
-
if
|
1406
|
+
# Deterministic mode can be applied to a subset of tests, but we only care if it is enabled everywhere
|
1407
|
+
# If not everywhere, then the seed matter and should be displayed
|
1408
|
+
if self.config.seed is None or self.config.generation.deterministic:
|
1428
1409
|
click.echo("not used in the deterministic mode")
|
1429
1410
|
else:
|
1430
|
-
click.echo(str(self.seed))
|
1411
|
+
click.echo(str(self.config.seed))
|
1431
1412
|
click.echo()
|
1432
1413
|
|
1433
1414
|
def _on_engine_finished(self, ctx: ExecutionContext, event: events.EngineFinished) -> None:
|
@@ -1450,8 +1431,6 @@ class OutputHandler(EventHandler):
|
|
1450
1431
|
display_failures(ctx)
|
1451
1432
|
if self.warnings.missing_auth or self.warnings.only_4xx_responses:
|
1452
1433
|
self.display_warnings()
|
1453
|
-
if GLOBAL_EXPERIMENTS.enabled:
|
1454
|
-
self.display_experiments()
|
1455
1434
|
if ctx.statistic.extraction_failures:
|
1456
1435
|
self.display_stateful_failures(ctx)
|
1457
1436
|
display_section_name("SUMMARY")
|