schemathesis 3.25.5__py3-none-any.whl → 3.39.7__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 +6 -6
- schemathesis/_compat.py +2 -2
- schemathesis/_dependency_versions.py +4 -2
- schemathesis/_hypothesis.py +369 -56
- schemathesis/_lazy_import.py +1 -0
- schemathesis/_override.py +5 -4
- schemathesis/_patches.py +21 -0
- schemathesis/_rate_limiter.py +7 -0
- schemathesis/_xml.py +75 -22
- schemathesis/auths.py +78 -16
- schemathesis/checks.py +21 -9
- schemathesis/cli/__init__.py +793 -448
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/callbacks.py +58 -13
- schemathesis/cli/cassettes.py +233 -47
- schemathesis/cli/constants.py +8 -2
- schemathesis/cli/context.py +24 -4
- schemathesis/cli/debug.py +2 -1
- schemathesis/cli/handlers.py +4 -1
- schemathesis/cli/junitxml.py +103 -22
- schemathesis/cli/options.py +15 -4
- schemathesis/cli/output/default.py +286 -115
- schemathesis/cli/output/short.py +25 -6
- schemathesis/cli/reporting.py +79 -0
- schemathesis/cli/sanitization.py +6 -0
- schemathesis/code_samples.py +5 -3
- schemathesis/constants.py +1 -0
- schemathesis/contrib/openapi/__init__.py +1 -1
- schemathesis/contrib/openapi/fill_missing_examples.py +3 -1
- schemathesis/contrib/openapi/formats/uuid.py +2 -1
- schemathesis/contrib/unique_data.py +3 -3
- schemathesis/exceptions.py +76 -65
- schemathesis/experimental/__init__.py +35 -0
- schemathesis/extra/_aiohttp.py +1 -0
- schemathesis/extra/_flask.py +4 -1
- schemathesis/extra/_server.py +1 -0
- schemathesis/extra/pytest_plugin.py +17 -25
- schemathesis/failures.py +77 -9
- schemathesis/filters.py +185 -8
- schemathesis/fixups/__init__.py +1 -0
- schemathesis/fixups/fast_api.py +2 -2
- schemathesis/fixups/utf8_bom.py +1 -2
- schemathesis/generation/__init__.py +20 -36
- schemathesis/generation/_hypothesis.py +59 -0
- schemathesis/generation/_methods.py +44 -0
- schemathesis/generation/coverage.py +931 -0
- schemathesis/graphql.py +0 -1
- schemathesis/hooks.py +89 -12
- schemathesis/internal/checks.py +84 -0
- schemathesis/internal/copy.py +22 -3
- schemathesis/internal/deprecation.py +6 -2
- schemathesis/internal/diff.py +15 -0
- schemathesis/internal/extensions.py +27 -0
- schemathesis/internal/jsonschema.py +2 -1
- schemathesis/internal/output.py +68 -0
- schemathesis/internal/result.py +1 -1
- schemathesis/internal/transformation.py +11 -0
- schemathesis/lazy.py +138 -25
- schemathesis/loaders.py +7 -5
- schemathesis/models.py +323 -213
- schemathesis/parameters.py +4 -0
- schemathesis/runner/__init__.py +72 -22
- schemathesis/runner/events.py +86 -6
- schemathesis/runner/impl/context.py +104 -0
- schemathesis/runner/impl/core.py +447 -187
- schemathesis/runner/impl/solo.py +19 -29
- schemathesis/runner/impl/threadpool.py +70 -79
- schemathesis/{cli → runner}/probes.py +37 -25
- schemathesis/runner/serialization.py +150 -17
- schemathesis/sanitization.py +5 -1
- schemathesis/schemas.py +170 -102
- schemathesis/serializers.py +17 -4
- schemathesis/service/ci.py +1 -0
- schemathesis/service/client.py +39 -6
- schemathesis/service/events.py +5 -1
- schemathesis/service/extensions.py +224 -0
- schemathesis/service/hosts.py +6 -2
- schemathesis/service/metadata.py +25 -0
- schemathesis/service/models.py +211 -2
- schemathesis/service/report.py +6 -6
- schemathesis/service/serialization.py +60 -71
- schemathesis/service/usage.py +1 -0
- schemathesis/specs/graphql/_cache.py +26 -0
- schemathesis/specs/graphql/loaders.py +25 -5
- schemathesis/specs/graphql/nodes.py +1 -0
- schemathesis/specs/graphql/scalars.py +2 -2
- schemathesis/specs/graphql/schemas.py +130 -100
- schemathesis/specs/graphql/validation.py +1 -2
- schemathesis/specs/openapi/__init__.py +1 -0
- schemathesis/specs/openapi/_cache.py +123 -0
- schemathesis/specs/openapi/_hypothesis.py +79 -61
- schemathesis/specs/openapi/checks.py +504 -25
- schemathesis/specs/openapi/converter.py +31 -4
- schemathesis/specs/openapi/definitions.py +10 -17
- schemathesis/specs/openapi/examples.py +143 -31
- schemathesis/specs/openapi/expressions/__init__.py +37 -2
- schemathesis/specs/openapi/expressions/context.py +1 -1
- schemathesis/specs/openapi/expressions/extractors.py +26 -0
- schemathesis/specs/openapi/expressions/lexer.py +20 -18
- schemathesis/specs/openapi/expressions/nodes.py +29 -6
- schemathesis/specs/openapi/expressions/parser.py +26 -5
- schemathesis/specs/openapi/formats.py +44 -0
- schemathesis/specs/openapi/links.py +125 -42
- schemathesis/specs/openapi/loaders.py +77 -36
- schemathesis/specs/openapi/media_types.py +34 -0
- schemathesis/specs/openapi/negative/__init__.py +6 -3
- schemathesis/specs/openapi/negative/mutations.py +21 -6
- schemathesis/specs/openapi/parameters.py +39 -25
- schemathesis/specs/openapi/patterns.py +137 -0
- schemathesis/specs/openapi/references.py +37 -7
- schemathesis/specs/openapi/schemas.py +368 -242
- schemathesis/specs/openapi/security.py +25 -7
- schemathesis/specs/openapi/serialization.py +1 -0
- schemathesis/specs/openapi/stateful/__init__.py +198 -70
- schemathesis/specs/openapi/stateful/statistic.py +198 -0
- schemathesis/specs/openapi/stateful/types.py +14 -0
- schemathesis/specs/openapi/utils.py +6 -1
- schemathesis/specs/openapi/validation.py +1 -0
- schemathesis/stateful/__init__.py +35 -21
- schemathesis/stateful/config.py +97 -0
- schemathesis/stateful/context.py +135 -0
- schemathesis/stateful/events.py +274 -0
- schemathesis/stateful/runner.py +309 -0
- schemathesis/stateful/sink.py +68 -0
- schemathesis/stateful/state_machine.py +67 -38
- schemathesis/stateful/statistic.py +22 -0
- schemathesis/stateful/validation.py +100 -0
- schemathesis/targets.py +33 -1
- schemathesis/throttling.py +25 -5
- schemathesis/transports/__init__.py +354 -0
- schemathesis/transports/asgi.py +7 -0
- schemathesis/transports/auth.py +25 -2
- schemathesis/transports/content_types.py +3 -1
- schemathesis/transports/headers.py +2 -1
- schemathesis/transports/responses.py +9 -4
- schemathesis/types.py +9 -0
- schemathesis/utils.py +11 -16
- schemathesis-3.39.7.dist-info/METADATA +293 -0
- schemathesis-3.39.7.dist-info/RECORD +160 -0
- {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/WHEEL +1 -1
- schemathesis/specs/openapi/filters.py +0 -49
- schemathesis/specs/openapi/stateful/links.py +0 -92
- schemathesis-3.25.5.dist-info/METADATA +0 -356
- schemathesis-3.25.5.dist-info/RECORD +0 -134
- {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/licenses/LICENSE +0 -0
schemathesis/cli/callbacks.py
CHANGED
|
@@ -2,33 +2,36 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import codecs
|
|
4
4
|
import enum
|
|
5
|
+
import operator
|
|
5
6
|
import os
|
|
6
7
|
import re
|
|
7
8
|
import traceback
|
|
8
9
|
from contextlib import contextmanager
|
|
9
|
-
from functools import partial
|
|
10
|
-
from typing import
|
|
10
|
+
from functools import partial, reduce
|
|
11
|
+
from typing import TYPE_CHECKING, Callable, Generator
|
|
11
12
|
from urllib.parse import urlparse
|
|
12
13
|
|
|
13
14
|
import click
|
|
14
15
|
|
|
15
|
-
from click.types import LazyFile # type: ignore
|
|
16
|
-
|
|
17
16
|
from .. import exceptions, experimental, throttling
|
|
18
17
|
from ..code_samples import CodeSampleStyle
|
|
18
|
+
from ..constants import TRUE_VALUES
|
|
19
19
|
from ..exceptions import extract_nth_traceback
|
|
20
20
|
from ..generation import DataGenerationMethod
|
|
21
|
-
from ..
|
|
21
|
+
from ..internal.transformation import convert_boolean_string as _convert_boolean_string
|
|
22
22
|
from ..internal.validation import file_exists, is_filename, is_illegal_surrogate
|
|
23
23
|
from ..loaders import load_app
|
|
24
24
|
from ..service.hosts import get_temporary_hosts_file
|
|
25
|
+
from ..stateful import Stateful
|
|
25
26
|
from ..transports.headers import has_invalid_characters, is_latin_1_encodable
|
|
26
|
-
from
|
|
27
|
+
from .cassettes import CassetteFormat
|
|
27
28
|
from .constants import DEFAULT_WORKERS
|
|
28
|
-
from ..stateful import Stateful
|
|
29
29
|
|
|
30
30
|
if TYPE_CHECKING:
|
|
31
31
|
import hypothesis
|
|
32
|
+
from click.types import LazyFile # type: ignore[attr-defined]
|
|
33
|
+
|
|
34
|
+
from ..types import PathLike
|
|
32
35
|
|
|
33
36
|
INVALID_DERANDOMIZE_MESSAGE = (
|
|
34
37
|
"`--hypothesis-derandomize` implies no database, so passing `--hypothesis-database` too is invalid."
|
|
@@ -338,13 +341,59 @@ def convert_experimental(
|
|
|
338
341
|
|
|
339
342
|
|
|
340
343
|
def convert_checks(ctx: click.core.Context, param: click.core.Parameter, value: tuple[list[str]]) -> list[str]:
|
|
341
|
-
return
|
|
344
|
+
return reduce(operator.iadd, value, [])
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def convert_status_codes(
|
|
348
|
+
ctx: click.core.Context, param: click.core.Parameter, value: list[str] | None
|
|
349
|
+
) -> list[str] | None:
|
|
350
|
+
if not value:
|
|
351
|
+
return value
|
|
352
|
+
|
|
353
|
+
invalid = []
|
|
354
|
+
|
|
355
|
+
for code in value:
|
|
356
|
+
if len(code) != 3:
|
|
357
|
+
invalid.append(code)
|
|
358
|
+
continue
|
|
359
|
+
|
|
360
|
+
if code[0] not in {"1", "2", "3", "4", "5"}:
|
|
361
|
+
invalid.append(code)
|
|
362
|
+
continue
|
|
363
|
+
|
|
364
|
+
upper_code = code.upper()
|
|
365
|
+
|
|
366
|
+
if "X" in upper_code:
|
|
367
|
+
if (
|
|
368
|
+
upper_code[1:] == "XX"
|
|
369
|
+
or (upper_code[1] == "X" and upper_code[2].isdigit())
|
|
370
|
+
or (upper_code[1].isdigit() and upper_code[2] == "X")
|
|
371
|
+
):
|
|
372
|
+
continue
|
|
373
|
+
else:
|
|
374
|
+
invalid.append(code)
|
|
375
|
+
continue
|
|
376
|
+
|
|
377
|
+
if not code.isnumeric():
|
|
378
|
+
invalid.append(code)
|
|
379
|
+
|
|
380
|
+
if invalid:
|
|
381
|
+
raise click.UsageError(
|
|
382
|
+
f"Invalid status code(s): {', '.join(invalid)}. "
|
|
383
|
+
"Use valid 3-digit codes between 100 and 599, "
|
|
384
|
+
"or wildcards (e.g., 2XX, 2X0, 20X), where X is a wildcard digit."
|
|
385
|
+
)
|
|
386
|
+
return value
|
|
342
387
|
|
|
343
388
|
|
|
344
389
|
def convert_code_sample_style(ctx: click.core.Context, param: click.core.Parameter, value: str) -> CodeSampleStyle:
|
|
345
390
|
return CodeSampleStyle.from_str(value)
|
|
346
391
|
|
|
347
392
|
|
|
393
|
+
def convert_cassette_format(ctx: click.core.Context, param: click.core.Parameter, value: str) -> CassetteFormat:
|
|
394
|
+
return CassetteFormat.from_str(value)
|
|
395
|
+
|
|
396
|
+
|
|
348
397
|
def convert_data_generation_method(
|
|
349
398
|
ctx: click.core.Context, param: click.core.Parameter, value: str
|
|
350
399
|
) -> list[DataGenerationMethod]:
|
|
@@ -374,11 +423,7 @@ def convert_hosts_file(ctx: click.core.Context, param: click.core.Parameter, val
|
|
|
374
423
|
|
|
375
424
|
|
|
376
425
|
def convert_boolean_string(ctx: click.core.Context, param: click.core.Parameter, value: str) -> str | bool:
|
|
377
|
-
|
|
378
|
-
return True
|
|
379
|
-
if value.lower() in FALSE_VALUES:
|
|
380
|
-
return False
|
|
381
|
-
return value
|
|
426
|
+
return _convert_boolean_string(value)
|
|
382
427
|
|
|
383
428
|
|
|
384
429
|
def convert_report(ctx: click.core.Context, param: click.core.Option, value: LazyFile) -> LazyFile:
|
schemathesis/cli/cassettes.py
CHANGED
|
@@ -1,30 +1,53 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
import base64
|
|
4
|
+
import enum
|
|
3
5
|
import json
|
|
4
6
|
import re
|
|
5
7
|
import sys
|
|
6
8
|
import threading
|
|
7
9
|
from dataclasses import dataclass, field
|
|
10
|
+
from http.cookies import SimpleCookie
|
|
8
11
|
from queue import Queue
|
|
9
|
-
from typing import IO, Any, Generator, Iterator, cast
|
|
12
|
+
from typing import IO, TYPE_CHECKING, Any, Callable, Generator, Iterator, cast
|
|
13
|
+
from urllib.parse import parse_qsl, urlparse
|
|
14
|
+
|
|
15
|
+
import harfile
|
|
10
16
|
|
|
11
17
|
from ..constants import SCHEMATHESIS_VERSION
|
|
12
18
|
from ..runner import events
|
|
13
|
-
from ..types import RequestCert
|
|
14
19
|
from .handlers import EventHandler
|
|
15
20
|
|
|
16
21
|
if TYPE_CHECKING:
|
|
17
22
|
import click
|
|
18
23
|
import requests
|
|
24
|
+
|
|
19
25
|
from ..models import Request, Response
|
|
20
26
|
from ..runner.serialization import SerializedCheck, SerializedInteraction
|
|
27
|
+
from ..types import RequestCert
|
|
21
28
|
from .context import ExecutionContext
|
|
22
|
-
from ..generation import DataGenerationMethod
|
|
23
29
|
|
|
24
30
|
# Wait until the worker terminates
|
|
25
31
|
WRITER_WORKER_JOIN_TIMEOUT = 1
|
|
26
32
|
|
|
27
33
|
|
|
34
|
+
class CassetteFormat(str, enum.Enum):
|
|
35
|
+
"""Type of the cassette."""
|
|
36
|
+
|
|
37
|
+
VCR = "vcr"
|
|
38
|
+
HAR = "har"
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_str(cls, value: str) -> CassetteFormat:
|
|
42
|
+
try:
|
|
43
|
+
return cls[value.upper()]
|
|
44
|
+
except KeyError:
|
|
45
|
+
available_formats = ", ".join(cls)
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"Invalid value for cassette format: {value}. Available formats: {available_formats}"
|
|
48
|
+
) from None
|
|
49
|
+
|
|
50
|
+
|
|
28
51
|
@dataclass
|
|
29
52
|
class CassetteWriter(EventHandler):
|
|
30
53
|
"""Write interactions in a YAML cassette.
|
|
@@ -34,42 +57,47 @@ class CassetteWriter(EventHandler):
|
|
|
34
57
|
"""
|
|
35
58
|
|
|
36
59
|
file_handle: click.utils.LazyFile
|
|
60
|
+
format: CassetteFormat
|
|
37
61
|
preserve_exact_body_bytes: bool
|
|
38
62
|
queue: Queue = field(default_factory=Queue)
|
|
39
63
|
worker: threading.Thread = field(init=False)
|
|
40
64
|
|
|
41
65
|
def __post_init__(self) -> None:
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
66
|
+
kwargs = {
|
|
67
|
+
"file_handle": self.file_handle,
|
|
68
|
+
"queue": self.queue,
|
|
69
|
+
"preserve_exact_body_bytes": self.preserve_exact_body_bytes,
|
|
70
|
+
}
|
|
71
|
+
writer: Callable
|
|
72
|
+
if self.format == CassetteFormat.HAR:
|
|
73
|
+
writer = har_writer
|
|
74
|
+
else:
|
|
75
|
+
writer = vcr_writer
|
|
76
|
+
self.worker = threading.Thread(name="SchemathesisCassetteWriter", target=writer, kwargs=kwargs)
|
|
50
77
|
self.worker.start()
|
|
51
78
|
|
|
52
79
|
def handle_event(self, context: ExecutionContext, event: events.ExecutionEvent) -> None:
|
|
53
80
|
if isinstance(event, events.Initialized):
|
|
54
81
|
# In the beginning we write metadata and start `http_interactions` list
|
|
55
|
-
self.queue.put(Initialize())
|
|
56
|
-
|
|
57
|
-
# Seed is always present at this point, the original Optional[int] type is there because `TestResult`
|
|
58
|
-
# instance is created before `seed` is generated on the hypothesis side
|
|
59
|
-
seed = cast(int, event.result.seed)
|
|
82
|
+
self.queue.put(Initialize(seed=event.seed))
|
|
83
|
+
elif isinstance(event, events.AfterExecution):
|
|
60
84
|
self.queue.put(
|
|
61
85
|
Process(
|
|
62
|
-
seed=seed,
|
|
63
86
|
correlation_id=event.correlation_id,
|
|
64
87
|
thread_id=event.thread_id,
|
|
65
|
-
# NOTE: For backward compatibility reasons AfterExecution stores a list of data generation methods
|
|
66
|
-
# The list always contains one element - the method that was actually used for generation
|
|
67
|
-
# This will change in the future
|
|
68
|
-
data_generation_method=event.data_generation_method[0],
|
|
69
88
|
interactions=event.result.interactions,
|
|
70
89
|
)
|
|
71
90
|
)
|
|
72
|
-
|
|
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):
|
|
73
101
|
self.shutdown()
|
|
74
102
|
|
|
75
103
|
def shutdown(self) -> None:
|
|
@@ -84,15 +112,15 @@ class CassetteWriter(EventHandler):
|
|
|
84
112
|
class Initialize:
|
|
85
113
|
"""Start up, the first message to make preparations before proceeding the input data."""
|
|
86
114
|
|
|
115
|
+
seed: int | None
|
|
116
|
+
|
|
87
117
|
|
|
88
118
|
@dataclass
|
|
89
119
|
class Process:
|
|
90
120
|
"""A new chunk of data should be processed."""
|
|
91
121
|
|
|
92
|
-
seed: int
|
|
93
122
|
correlation_id: str
|
|
94
123
|
thread_id: int
|
|
95
|
-
data_generation_method: DataGenerationMethod
|
|
96
124
|
interactions: list[SerializedInteraction]
|
|
97
125
|
|
|
98
126
|
|
|
@@ -110,7 +138,7 @@ def get_command_representation() -> str:
|
|
|
110
138
|
return f"st {args}"
|
|
111
139
|
|
|
112
140
|
|
|
113
|
-
def
|
|
141
|
+
def vcr_writer(file_handle: click.utils.LazyFile, preserve_exact_body_bytes: bool, queue: Queue) -> None:
|
|
114
142
|
"""Write YAML to a file in an incremental manner.
|
|
115
143
|
|
|
116
144
|
This implementation doesn't use `pyyaml` package and composes YAML manually as string due to the following reasons:
|
|
@@ -130,13 +158,18 @@ def worker(file_handle: click.utils.LazyFile, preserve_exact_body_bytes: bool, q
|
|
|
130
158
|
return "\n".join(f' "{name}":\n{format_header_values(values)}' for name, values in headers.items())
|
|
131
159
|
|
|
132
160
|
def format_check_message(message: str | None) -> str:
|
|
133
|
-
return "~" if message is None else f"{
|
|
161
|
+
return "~" if message is None else f"{message!r}"
|
|
134
162
|
|
|
135
163
|
def format_checks(checks: list[SerializedCheck]) -> str:
|
|
136
|
-
|
|
164
|
+
if not checks:
|
|
165
|
+
return " checks: []"
|
|
166
|
+
items = "\n".join(
|
|
137
167
|
f" - name: '{check.name}'\n status: '{check.value.name.upper()}'\n message: {format_check_message(check.message)}"
|
|
138
168
|
for check in checks
|
|
139
169
|
)
|
|
170
|
+
return f"""
|
|
171
|
+
checks:
|
|
172
|
+
{items}"""
|
|
140
173
|
|
|
141
174
|
if preserve_exact_body_bytes:
|
|
142
175
|
|
|
@@ -181,9 +214,11 @@ def worker(file_handle: click.utils.LazyFile, preserve_exact_body_bytes: bool, q
|
|
|
181
214
|
)
|
|
182
215
|
write_double_quoted(output, string)
|
|
183
216
|
|
|
217
|
+
seed = "null"
|
|
184
218
|
while True:
|
|
185
219
|
item = queue.get()
|
|
186
220
|
if isinstance(item, Initialize):
|
|
221
|
+
seed = f"'{item.seed}'"
|
|
187
222
|
stream.write(
|
|
188
223
|
f"""command: '{get_command_representation()}'
|
|
189
224
|
recorded_with: 'Schemathesis {SCHEMATHESIS_VERSION}'
|
|
@@ -193,16 +228,45 @@ http_interactions:"""
|
|
|
193
228
|
for interaction in item.interactions:
|
|
194
229
|
status = interaction.status.name.upper()
|
|
195
230
|
# 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"
|
|
196
232
|
stream.write(
|
|
197
233
|
f"""\n- id: '{current_id}'
|
|
198
234
|
status: '{status}'
|
|
199
|
-
seed:
|
|
235
|
+
seed: {seed}
|
|
200
236
|
thread_id: {item.thread_id}
|
|
201
237
|
correlation_id: '{item.correlation_id}'
|
|
202
|
-
data_generation_method: '{
|
|
203
|
-
|
|
238
|
+
data_generation_method: '{interaction.data_generation_method.value}'
|
|
239
|
+
meta:
|
|
240
|
+
description: """
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
if interaction.description is not None:
|
|
244
|
+
write_double_quoted(stream, interaction.description)
|
|
245
|
+
else:
|
|
246
|
+
stream.write("null")
|
|
247
|
+
|
|
248
|
+
stream.write("\n location: ")
|
|
249
|
+
if interaction.location is not None:
|
|
250
|
+
write_double_quoted(stream, interaction.location)
|
|
251
|
+
else:
|
|
252
|
+
stream.write("null")
|
|
253
|
+
|
|
254
|
+
stream.write("\n parameter: ")
|
|
255
|
+
if interaction.parameter is not None:
|
|
256
|
+
write_double_quoted(stream, interaction.parameter)
|
|
257
|
+
else:
|
|
258
|
+
stream.write("null")
|
|
259
|
+
|
|
260
|
+
stream.write("\n parameter_location: ")
|
|
261
|
+
if interaction.parameter_location is not None:
|
|
262
|
+
write_double_quoted(stream, interaction.parameter_location)
|
|
263
|
+
else:
|
|
264
|
+
stream.write("null")
|
|
265
|
+
stream.write(
|
|
266
|
+
f"""
|
|
267
|
+
phase: {phase}
|
|
268
|
+
elapsed: '{interaction.response.elapsed if interaction.response else 0}'
|
|
204
269
|
recorded_at: '{interaction.recorded_at}'
|
|
205
|
-
checks:
|
|
206
270
|
{format_checks(interaction.checks)}
|
|
207
271
|
request:
|
|
208
272
|
uri: '{interaction.request.uri}'
|
|
@@ -211,8 +275,9 @@ http_interactions:"""
|
|
|
211
275
|
{format_headers(interaction.request.headers)}"""
|
|
212
276
|
)
|
|
213
277
|
format_request_body(stream, interaction.request)
|
|
214
|
-
|
|
215
|
-
|
|
278
|
+
if interaction.response is not None:
|
|
279
|
+
stream.write(
|
|
280
|
+
f"""
|
|
216
281
|
response:
|
|
217
282
|
status:
|
|
218
283
|
code: '{interaction.response.status_code}'
|
|
@@ -220,12 +285,16 @@ http_interactions:"""
|
|
|
220
285
|
headers:
|
|
221
286
|
{format_headers(interaction.response.headers)}
|
|
222
287
|
"""
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
288
|
+
)
|
|
289
|
+
format_response_body(stream, interaction.response)
|
|
290
|
+
stream.write(
|
|
291
|
+
f"""
|
|
227
292
|
http_version: '{interaction.response.http_version}'"""
|
|
228
|
-
|
|
293
|
+
)
|
|
294
|
+
else:
|
|
295
|
+
stream.write("""
|
|
296
|
+
response: null
|
|
297
|
+
""")
|
|
229
298
|
current_id += 1
|
|
230
299
|
else:
|
|
231
300
|
break
|
|
@@ -254,8 +323,8 @@ def write_double_quoted(stream: IO, text: str) -> None:
|
|
|
254
323
|
ch = text[end]
|
|
255
324
|
if (
|
|
256
325
|
ch is None
|
|
257
|
-
or ch in '"\\\x85\u2028\u2029\
|
|
258
|
-
or not ("\x20" <= ch <= "\
|
|
326
|
+
or ch in '"\\\x85\u2028\u2029\ufeff'
|
|
327
|
+
or not ("\x20" <= ch <= "\x7e" or ("\xa0" <= ch <= "\ud7ff" or "\ue000" <= ch <= "\ufffd"))
|
|
259
328
|
):
|
|
260
329
|
if start < end:
|
|
261
330
|
stream.write(text[start:end])
|
|
@@ -264,18 +333,135 @@ def write_double_quoted(stream: IO, text: str) -> None:
|
|
|
264
333
|
# Escape character
|
|
265
334
|
if ch in Emitter.ESCAPE_REPLACEMENTS:
|
|
266
335
|
data = "\\" + Emitter.ESCAPE_REPLACEMENTS[ch]
|
|
267
|
-
elif ch <= "\
|
|
268
|
-
data = "\\x
|
|
269
|
-
elif ch <= "\
|
|
270
|
-
data = "\\u
|
|
336
|
+
elif ch <= "\xff":
|
|
337
|
+
data = f"\\x{ord(ch):02X}"
|
|
338
|
+
elif ch <= "\uffff":
|
|
339
|
+
data = f"\\u{ord(ch):04X}"
|
|
271
340
|
else:
|
|
272
|
-
data = "\\U
|
|
341
|
+
data = f"\\U{ord(ch):08X}"
|
|
273
342
|
stream.write(data)
|
|
274
343
|
start = end + 1
|
|
275
344
|
end += 1
|
|
276
345
|
stream.write('"')
|
|
277
346
|
|
|
278
347
|
|
|
348
|
+
def har_writer(file_handle: click.utils.LazyFile, preserve_exact_body_bytes: bool, queue: Queue) -> None:
|
|
349
|
+
if preserve_exact_body_bytes:
|
|
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:
|
|
359
|
+
while True:
|
|
360
|
+
item = queue.get()
|
|
361
|
+
if isinstance(item, Process):
|
|
362
|
+
for interaction in item.interactions:
|
|
363
|
+
query_params = urlparse(interaction.request.uri).query
|
|
364
|
+
if interaction.request.body is not None:
|
|
365
|
+
post_data = harfile.PostData(
|
|
366
|
+
mimeType=interaction.request.headers.get("Content-Type", [""])[0],
|
|
367
|
+
text=get_body(interaction.request.body),
|
|
368
|
+
)
|
|
369
|
+
else:
|
|
370
|
+
post_data = None
|
|
371
|
+
if interaction.response is not None:
|
|
372
|
+
content_type = interaction.response.headers.get("Content-Type", [""])[0]
|
|
373
|
+
content = harfile.Content(
|
|
374
|
+
size=interaction.response.body_size or 0,
|
|
375
|
+
mimeType=content_type,
|
|
376
|
+
text=get_body(interaction.response.body) if interaction.response.body is not None else None,
|
|
377
|
+
encoding="base64"
|
|
378
|
+
if interaction.response.body is not None and preserve_exact_body_bytes
|
|
379
|
+
else None,
|
|
380
|
+
)
|
|
381
|
+
http_version = f"HTTP/{interaction.response.http_version}"
|
|
382
|
+
response = harfile.Response(
|
|
383
|
+
status=interaction.response.status_code,
|
|
384
|
+
httpVersion=http_version,
|
|
385
|
+
statusText=interaction.response.message,
|
|
386
|
+
headers=[
|
|
387
|
+
harfile.Record(name=name, value=values[0])
|
|
388
|
+
for name, values in interaction.response.headers.items()
|
|
389
|
+
],
|
|
390
|
+
cookies=_extract_cookies(interaction.response.headers.get("Set-Cookie", [])),
|
|
391
|
+
content=content,
|
|
392
|
+
headersSize=_headers_size(interaction.response.headers),
|
|
393
|
+
bodySize=interaction.response.body_size or 0,
|
|
394
|
+
redirectURL=interaction.response.headers.get("Location", [""])[0],
|
|
395
|
+
)
|
|
396
|
+
time = round(interaction.response.elapsed * 1000, 2)
|
|
397
|
+
else:
|
|
398
|
+
response = HARFILE_NO_RESPONSE
|
|
399
|
+
time = 0
|
|
400
|
+
http_version = ""
|
|
401
|
+
|
|
402
|
+
har.add_entry(
|
|
403
|
+
startedDateTime=interaction.recorded_at,
|
|
404
|
+
time=time,
|
|
405
|
+
request=harfile.Request(
|
|
406
|
+
method=interaction.request.method.upper(),
|
|
407
|
+
url=interaction.request.uri,
|
|
408
|
+
httpVersion=http_version,
|
|
409
|
+
headers=[
|
|
410
|
+
harfile.Record(name=name, value=values[0])
|
|
411
|
+
for name, values in interaction.request.headers.items()
|
|
412
|
+
],
|
|
413
|
+
queryString=[
|
|
414
|
+
harfile.Record(name=name, value=value)
|
|
415
|
+
for name, value in parse_qsl(query_params, keep_blank_values=True)
|
|
416
|
+
],
|
|
417
|
+
cookies=_extract_cookies(interaction.request.headers.get("Cookie", [])),
|
|
418
|
+
headersSize=_headers_size(interaction.request.headers),
|
|
419
|
+
bodySize=interaction.request.body_size or 0,
|
|
420
|
+
postData=post_data,
|
|
421
|
+
),
|
|
422
|
+
response=response,
|
|
423
|
+
timings=harfile.Timings(send=0, wait=0, receive=time, blocked=0, dns=0, connect=0, ssl=0),
|
|
424
|
+
)
|
|
425
|
+
elif isinstance(item, Finalize):
|
|
426
|
+
break
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
HARFILE_NO_RESPONSE = harfile.Response(
|
|
430
|
+
status=0,
|
|
431
|
+
httpVersion="",
|
|
432
|
+
statusText="",
|
|
433
|
+
headers=[],
|
|
434
|
+
cookies=[],
|
|
435
|
+
content=harfile.Content(),
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _headers_size(headers: dict[str, list[str]]) -> int:
|
|
440
|
+
size = 0
|
|
441
|
+
for name, values in headers.items():
|
|
442
|
+
# 4 is for ": " and "\r\n"
|
|
443
|
+
size += len(name) + 4 + len(values[0])
|
|
444
|
+
return size
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _extract_cookies(headers: list[str]) -> list[harfile.Cookie]:
|
|
448
|
+
return [cookie for items in headers for item in items for cookie in _cookie_to_har(item)]
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _cookie_to_har(cookie: str) -> Iterator[harfile.Cookie]:
|
|
452
|
+
parsed = SimpleCookie(cookie)
|
|
453
|
+
for name, data in parsed.items():
|
|
454
|
+
yield harfile.Cookie(
|
|
455
|
+
name=name,
|
|
456
|
+
value=data.value,
|
|
457
|
+
path=data["path"] or None,
|
|
458
|
+
domain=data["domain"] or None,
|
|
459
|
+
expires=data["expires"] or None,
|
|
460
|
+
httpOnly=data["httponly"] or None,
|
|
461
|
+
secure=data["secure"] or None,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
|
|
279
465
|
@dataclass
|
|
280
466
|
class Replayed:
|
|
281
467
|
interaction: dict[str, Any]
|
|
@@ -351,9 +537,9 @@ def filter_cassette(
|
|
|
351
537
|
|
|
352
538
|
def get_prepared_request(data: dict[str, Any]) -> requests.PreparedRequest:
|
|
353
539
|
"""Create a `requests.PreparedRequest` from a serialized one."""
|
|
354
|
-
from requests.structures import CaseInsensitiveDict
|
|
355
|
-
from requests.cookies import RequestsCookieJar
|
|
356
540
|
import requests
|
|
541
|
+
from requests.cookies import RequestsCookieJar
|
|
542
|
+
from requests.structures import CaseInsensitiveDict
|
|
357
543
|
|
|
358
544
|
prepared = requests.PreparedRequest()
|
|
359
545
|
prepared.method = data["method"]
|
schemathesis/cli/constants.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
from enum import IntEnum, unique
|
|
3
4
|
from typing import TYPE_CHECKING
|
|
4
5
|
|
|
5
6
|
if TYPE_CHECKING:
|
|
6
7
|
import hypothesis
|
|
8
|
+
|
|
7
9
|
MIN_WORKERS = 1
|
|
8
10
|
DEFAULT_WORKERS = MIN_WORKERS
|
|
9
11
|
MAX_WORKERS = 64
|
|
@@ -40,11 +42,15 @@ class HealthCheck(IntEnum):
|
|
|
40
42
|
filter_too_much = 2
|
|
41
43
|
too_slow = 3
|
|
42
44
|
large_base_example = 7
|
|
45
|
+
all = 8
|
|
43
46
|
|
|
44
|
-
def as_hypothesis(self) -> hypothesis.HealthCheck:
|
|
47
|
+
def as_hypothesis(self) -> list[hypothesis.HealthCheck]:
|
|
45
48
|
from hypothesis import HealthCheck
|
|
46
49
|
|
|
47
|
-
|
|
50
|
+
if self.name == "all":
|
|
51
|
+
return list(HealthCheck)
|
|
52
|
+
|
|
53
|
+
return [HealthCheck[self.name]]
|
|
48
54
|
|
|
49
55
|
|
|
50
56
|
@unique
|
schemathesis/cli/context.py
CHANGED
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
|
|
2
|
+
|
|
3
3
|
import shutil
|
|
4
4
|
from dataclasses import dataclass, field
|
|
5
|
-
from
|
|
6
|
-
from typing import TYPE_CHECKING
|
|
5
|
+
from typing import TYPE_CHECKING, Generator
|
|
7
6
|
|
|
8
7
|
from ..code_samples import CodeSampleStyle
|
|
9
8
|
from ..internal.deprecation import deprecated_property
|
|
10
|
-
from ..
|
|
9
|
+
from ..internal.output import OutputConfig
|
|
11
10
|
|
|
12
11
|
if TYPE_CHECKING:
|
|
12
|
+
import os
|
|
13
|
+
from queue import Queue
|
|
14
|
+
|
|
13
15
|
import hypothesis
|
|
14
16
|
|
|
17
|
+
from ..internal.result import Result
|
|
18
|
+
from ..runner.probes import ProbeRun
|
|
19
|
+
from ..runner.serialization import SerializedTestResult
|
|
20
|
+
from ..service.models import AnalysisResult
|
|
21
|
+
from ..stateful.sink import StateMachineSink
|
|
22
|
+
|
|
15
23
|
|
|
16
24
|
@dataclass
|
|
17
25
|
class ServiceReportContext:
|
|
@@ -49,7 +57,19 @@ class ExecutionContext:
|
|
|
49
57
|
verbosity: int = 0
|
|
50
58
|
code_sample_style: CodeSampleStyle = CodeSampleStyle.default()
|
|
51
59
|
report: ServiceReportContext | FileReportContext | None = None
|
|
60
|
+
probes: list[ProbeRun] | None = None
|
|
61
|
+
analysis: Result[AnalysisResult, Exception] | None = None
|
|
62
|
+
output_config: OutputConfig = field(default_factory=OutputConfig)
|
|
63
|
+
state_machine_sink: StateMachineSink | None = None
|
|
64
|
+
initialization_lines: list[str | Generator[str, None, None]] = field(default_factory=list)
|
|
65
|
+
summary_lines: list[str | Generator[str, None, None]] = field(default_factory=list)
|
|
52
66
|
|
|
53
67
|
@deprecated_property(removed_in="4.0", replacement="show_trace")
|
|
54
68
|
def show_errors_tracebacks(self) -> bool:
|
|
55
69
|
return self.show_trace
|
|
70
|
+
|
|
71
|
+
def add_initialization_line(self, line: str | Generator[str, None, None]) -> None:
|
|
72
|
+
self.initialization_lines.append(line)
|
|
73
|
+
|
|
74
|
+
def add_summary_line(self, line: str | Generator[str, None, None]) -> None:
|
|
75
|
+
self.summary_lines.append(line)
|
schemathesis/cli/debug.py
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
import json
|
|
3
4
|
from dataclasses import dataclass
|
|
4
5
|
from typing import TYPE_CHECKING
|
|
5
6
|
|
|
6
|
-
|
|
7
7
|
from .handlers import EventHandler
|
|
8
8
|
|
|
9
9
|
if TYPE_CHECKING:
|
|
10
10
|
from click.utils import LazyFile
|
|
11
|
+
|
|
11
12
|
from ..runner import events
|
|
12
13
|
from .context import ExecutionContext
|
|
13
14
|
|
schemathesis/cli/handlers.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
from typing import TYPE_CHECKING
|
|
3
2
|
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
4
|
|
|
5
5
|
if TYPE_CHECKING:
|
|
6
6
|
from ..runner import events
|
|
@@ -8,6 +8,9 @@ if TYPE_CHECKING:
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class EventHandler:
|
|
11
|
+
def __init__(self, *args: Any, **params: Any) -> None:
|
|
12
|
+
pass
|
|
13
|
+
|
|
11
14
|
def handle_event(self, context: ExecutionContext, event: events.ExecutionEvent) -> None:
|
|
12
15
|
raise NotImplementedError
|
|
13
16
|
|