schemathesis 3.37.1__py3-none-any.whl → 3.38.0__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/_hypothesis.py +18 -8
- schemathesis/_patches.py +21 -0
- schemathesis/cli/__init__.py +1 -1
- schemathesis/cli/cassettes.py +6 -0
- schemathesis/extra/pytest_plugin.py +1 -1
- schemathesis/generation/_hypothesis.py +2 -0
- schemathesis/generation/coverage.py +257 -59
- schemathesis/internal/checks.py +4 -2
- schemathesis/internal/diff.py +15 -0
- schemathesis/models.py +55 -3
- schemathesis/runner/impl/context.py +5 -1
- schemathesis/runner/impl/core.py +14 -4
- schemathesis/runner/serialization.py +6 -3
- schemathesis/serializers.py +3 -0
- schemathesis/service/extensions.py +1 -1
- schemathesis/service/metadata.py +3 -3
- schemathesis/specs/openapi/_hypothesis.py +7 -46
- schemathesis/specs/openapi/checks.py +7 -2
- schemathesis/specs/openapi/converter.py +27 -11
- schemathesis/specs/openapi/formats.py +44 -0
- schemathesis/specs/openapi/negative/mutations.py +5 -0
- schemathesis/specs/openapi/parameters.py +16 -14
- schemathesis/specs/openapi/schemas.py +6 -2
- schemathesis/stateful/context.py +1 -1
- schemathesis/stateful/runner.py +6 -2
- schemathesis/utils.py +6 -4
- {schemathesis-3.37.1.dist-info → schemathesis-3.38.0.dist-info}/METADATA +2 -1
- {schemathesis-3.37.1.dist-info → schemathesis-3.38.0.dist-info}/RECORD +31 -29
- {schemathesis-3.37.1.dist-info → schemathesis-3.38.0.dist-info}/WHEEL +0 -0
- {schemathesis-3.37.1.dist-info → schemathesis-3.38.0.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.37.1.dist-info → schemathesis-3.38.0.dist-info}/licenses/LICENSE +0 -0
schemathesis/models.py
CHANGED
|
@@ -16,6 +16,7 @@ from typing import (
|
|
|
16
16
|
Generator,
|
|
17
17
|
Generic,
|
|
18
18
|
Iterator,
|
|
19
|
+
Literal,
|
|
19
20
|
NoReturn,
|
|
20
21
|
Sequence,
|
|
21
22
|
Type,
|
|
@@ -26,6 +27,7 @@ from urllib.parse import quote, unquote, urljoin, urlsplit, urlunsplit
|
|
|
26
27
|
|
|
27
28
|
from . import serializers
|
|
28
29
|
from ._dependency_versions import IS_WERKZEUG_ABOVE_3
|
|
30
|
+
from ._override import CaseOverride
|
|
29
31
|
from .code_samples import CodeSampleStyle
|
|
30
32
|
from .constants import (
|
|
31
33
|
NOT_SET,
|
|
@@ -48,6 +50,7 @@ from .hooks import GLOBAL_HOOK_DISPATCHER, HookContext, HookDispatcher, dispatch
|
|
|
48
50
|
from .internal.checks import CheckContext
|
|
49
51
|
from .internal.copy import fast_deepcopy
|
|
50
52
|
from .internal.deprecation import deprecated_function, deprecated_property
|
|
53
|
+
from .internal.diff import diff
|
|
51
54
|
from .internal.output import prepare_response_payload
|
|
52
55
|
from .parameters import Parameter, ParameterSet, PayloadAlternatives
|
|
53
56
|
from .sanitization import sanitize_request, sanitize_response
|
|
@@ -156,8 +159,9 @@ class GenerationMetadata:
|
|
|
156
159
|
body: DataGenerationMethod | None
|
|
157
160
|
phase: TestPhase
|
|
158
161
|
description: str | None
|
|
162
|
+
location: str | None
|
|
159
163
|
|
|
160
|
-
__slots__ = ("query", "path_parameters", "headers", "cookies", "body", "phase", "description")
|
|
164
|
+
__slots__ = ("query", "path_parameters", "headers", "cookies", "body", "phase", "description", "location")
|
|
161
165
|
|
|
162
166
|
|
|
163
167
|
@dataclass(repr=False)
|
|
@@ -187,6 +191,26 @@ class Case:
|
|
|
187
191
|
_auth: requests.auth.AuthBase | None = None
|
|
188
192
|
_has_explicit_auth: bool = False
|
|
189
193
|
|
|
194
|
+
def __post_init__(self) -> None:
|
|
195
|
+
self._original_path_parameters = self.path_parameters.copy() if self.path_parameters else None
|
|
196
|
+
self._original_headers = self.headers.copy() if self.headers else None
|
|
197
|
+
self._original_cookies = self.cookies.copy() if self.cookies else None
|
|
198
|
+
self._original_query = self.query.copy() if self.query else None
|
|
199
|
+
|
|
200
|
+
def _has_generated_component(self, name: str) -> bool:
|
|
201
|
+
assert name in ["path_parameters", "headers", "cookies", "query"]
|
|
202
|
+
if self.meta is None:
|
|
203
|
+
return False
|
|
204
|
+
return getattr(self.meta, name) is not None
|
|
205
|
+
|
|
206
|
+
def _get_diff(self, component: Literal["path_parameters", "headers", "query", "cookies"]) -> dict[str, Any]:
|
|
207
|
+
original = getattr(self, f"_original_{component}")
|
|
208
|
+
current = getattr(self, component)
|
|
209
|
+
if not (current and original):
|
|
210
|
+
return {}
|
|
211
|
+
original_value = original if self._has_generated_component(component) else {}
|
|
212
|
+
return diff(original_value, current)
|
|
213
|
+
|
|
190
214
|
def __repr__(self) -> str:
|
|
191
215
|
parts = [f"{self.__class__.__name__}("]
|
|
192
216
|
first = True
|
|
@@ -203,6 +227,15 @@ class Case:
|
|
|
203
227
|
def __hash__(self) -> int:
|
|
204
228
|
return hash(self.as_curl_command({SCHEMATHESIS_TEST_CASE_HEADER: "0"}))
|
|
205
229
|
|
|
230
|
+
@property
|
|
231
|
+
def _override(self) -> CaseOverride:
|
|
232
|
+
return CaseOverride(
|
|
233
|
+
path_parameters=self._get_diff("path_parameters"),
|
|
234
|
+
headers=self._get_diff("headers"),
|
|
235
|
+
query=self._get_diff("query"),
|
|
236
|
+
cookies=self._get_diff("cookies"),
|
|
237
|
+
)
|
|
238
|
+
|
|
206
239
|
def _repr_pretty_(self, printer: RepresentationPrinter, cycle: bool) -> None:
|
|
207
240
|
return None
|
|
208
241
|
|
|
@@ -460,7 +493,9 @@ class Case:
|
|
|
460
493
|
checks = tuple(check for check in checks if check not in excluded_checks)
|
|
461
494
|
additional_checks = tuple(check for check in _additional_checks if check not in excluded_checks)
|
|
462
495
|
failed_checks = []
|
|
463
|
-
ctx = CheckContext(
|
|
496
|
+
ctx = CheckContext(
|
|
497
|
+
override=self._override, auth=None, headers=CaseInsensitiveDict(headers) if headers else None
|
|
498
|
+
)
|
|
464
499
|
for check in chain(checks, additional_checks):
|
|
465
500
|
copied_case = self.partial_deepcopy()
|
|
466
501
|
try:
|
|
@@ -529,12 +564,21 @@ class Case:
|
|
|
529
564
|
session: requests.Session | None = None,
|
|
530
565
|
headers: dict[str, Any] | None = None,
|
|
531
566
|
checks: tuple[CheckFunction, ...] = (),
|
|
567
|
+
additional_checks: tuple[CheckFunction, ...] = (),
|
|
568
|
+
excluded_checks: tuple[CheckFunction, ...] = (),
|
|
532
569
|
code_sample_style: str | None = None,
|
|
533
570
|
**kwargs: Any,
|
|
534
571
|
) -> requests.Response:
|
|
535
572
|
__tracebackhide__ = True
|
|
536
573
|
response = self.call(base_url, session, headers, **kwargs)
|
|
537
|
-
self.validate_response(
|
|
574
|
+
self.validate_response(
|
|
575
|
+
response,
|
|
576
|
+
checks,
|
|
577
|
+
code_sample_style=code_sample_style,
|
|
578
|
+
headers=headers,
|
|
579
|
+
additional_checks=additional_checks,
|
|
580
|
+
excluded_checks=excluded_checks,
|
|
581
|
+
)
|
|
538
582
|
return response
|
|
539
583
|
|
|
540
584
|
def _get_url(self, base_url: str | None) -> str:
|
|
@@ -610,6 +654,9 @@ class OperationDefinition(Generic[D]):
|
|
|
610
654
|
|
|
611
655
|
__slots__ = ("raw", "resolved", "scope")
|
|
612
656
|
|
|
657
|
+
def _repr_pretty_(self, printer: RepresentationPrinter, cycle: bool) -> None:
|
|
658
|
+
return None
|
|
659
|
+
|
|
613
660
|
|
|
614
661
|
C = TypeVar("C", bound=Case)
|
|
615
662
|
|
|
@@ -1022,7 +1069,10 @@ class Interaction:
|
|
|
1022
1069
|
status: Status
|
|
1023
1070
|
data_generation_method: DataGenerationMethod
|
|
1024
1071
|
phase: TestPhase | None
|
|
1072
|
+
# `description` & `location` are related to metadata about this interaction
|
|
1073
|
+
# NOTE: It will be better to keep it in a separate attribute
|
|
1025
1074
|
description: str | None
|
|
1075
|
+
location: str | None
|
|
1026
1076
|
recorded_at: str = field(default_factory=lambda: datetime.datetime.now(TIMEZONE).isoformat())
|
|
1027
1077
|
|
|
1028
1078
|
@classmethod
|
|
@@ -1053,6 +1103,7 @@ class Interaction:
|
|
|
1053
1103
|
data_generation_method=cast(DataGenerationMethod, case.data_generation_method),
|
|
1054
1104
|
phase=case.meta.phase if case.meta is not None else None,
|
|
1055
1105
|
description=case.meta.description if case.meta is not None else None,
|
|
1106
|
+
location=case.meta.location if case.meta is not None else None,
|
|
1056
1107
|
)
|
|
1057
1108
|
|
|
1058
1109
|
@classmethod
|
|
@@ -1077,6 +1128,7 @@ class Interaction:
|
|
|
1077
1128
|
data_generation_method=cast(DataGenerationMethod, case.data_generation_method),
|
|
1078
1129
|
phase=case.meta.phase if case.meta is not None else None,
|
|
1079
1130
|
description=case.meta.description if case.meta is not None else None,
|
|
1131
|
+
location=case.meta.location if case.meta is not None else None,
|
|
1080
1132
|
)
|
|
1081
1133
|
|
|
1082
1134
|
|
|
@@ -12,6 +12,7 @@ if TYPE_CHECKING:
|
|
|
12
12
|
|
|
13
13
|
from hypothesis.vendor.pretty import RepresentationPrinter
|
|
14
14
|
|
|
15
|
+
from ..._override import CaseOverride
|
|
15
16
|
from ...exceptions import OperationSchemaError
|
|
16
17
|
from ...models import Case
|
|
17
18
|
from ...types import NotSet, RawAuth
|
|
@@ -28,8 +29,9 @@ class RunnerContext:
|
|
|
28
29
|
unique_data: bool
|
|
29
30
|
outcome_cache: dict[int, BaseException | None]
|
|
30
31
|
checks_config: CheckConfig
|
|
32
|
+
override: CaseOverride | None
|
|
31
33
|
|
|
32
|
-
__slots__ = ("data", "auth", "seed", "stop_event", "unique_data", "outcome_cache", "checks_config")
|
|
34
|
+
__slots__ = ("data", "auth", "seed", "stop_event", "unique_data", "outcome_cache", "checks_config", "override")
|
|
33
35
|
|
|
34
36
|
def __init__(
|
|
35
37
|
self,
|
|
@@ -39,6 +41,7 @@ class RunnerContext:
|
|
|
39
41
|
stop_event: threading.Event,
|
|
40
42
|
unique_data: bool,
|
|
41
43
|
checks_config: CheckConfig,
|
|
44
|
+
override: CaseOverride | None,
|
|
42
45
|
) -> None:
|
|
43
46
|
self.data = TestResultSet(seed=seed)
|
|
44
47
|
self.auth = auth
|
|
@@ -47,6 +50,7 @@ class RunnerContext:
|
|
|
47
50
|
self.outcome_cache = {}
|
|
48
51
|
self.unique_data = unique_data
|
|
49
52
|
self.checks_config = checks_config
|
|
53
|
+
self.override = override
|
|
50
54
|
|
|
51
55
|
def _repr_pretty_(self, printer: RepresentationPrinter, cycle: bool) -> None:
|
|
52
56
|
return None
|
schemathesis/runner/impl/core.py
CHANGED
|
@@ -138,6 +138,7 @@ class BaseRunner:
|
|
|
138
138
|
stop_event=stop_event,
|
|
139
139
|
unique_data=self.unique_data,
|
|
140
140
|
checks_config=self.checks_config,
|
|
141
|
+
override=self.override,
|
|
141
142
|
)
|
|
142
143
|
start_time = time.monotonic()
|
|
143
144
|
initialized = None
|
|
@@ -429,7 +430,7 @@ def run_probes(schema: BaseSchema, config: probes.ProbeConfig) -> list[probes.Pr
|
|
|
429
430
|
results = probes.run(schema, config)
|
|
430
431
|
for result in results:
|
|
431
432
|
if isinstance(result.probe, probes.NullByteInHeader) and result.is_failure:
|
|
432
|
-
from ...specs.openapi.
|
|
433
|
+
from ...specs.openapi.formats import HEADER_FORMAT, header_values
|
|
433
434
|
|
|
434
435
|
formats.register(HEADER_FORMAT, header_values(blacklist_characters="\n\r\x00"))
|
|
435
436
|
return results
|
|
@@ -1025,7 +1026,10 @@ def _network_test(
|
|
|
1025
1026
|
status = Status.success
|
|
1026
1027
|
|
|
1027
1028
|
check_ctx = CheckContext(
|
|
1028
|
-
|
|
1029
|
+
override=ctx.override,
|
|
1030
|
+
auth=ctx.auth,
|
|
1031
|
+
headers=CaseInsensitiveDict(headers) if headers else None,
|
|
1032
|
+
config=ctx.checks_config,
|
|
1029
1033
|
)
|
|
1030
1034
|
try:
|
|
1031
1035
|
run_checks(
|
|
@@ -1119,7 +1123,10 @@ def _wsgi_test(
|
|
|
1119
1123
|
status = Status.success
|
|
1120
1124
|
check_results: list[Check] = []
|
|
1121
1125
|
check_ctx = CheckContext(
|
|
1122
|
-
|
|
1126
|
+
override=ctx.override,
|
|
1127
|
+
auth=ctx.auth,
|
|
1128
|
+
headers=CaseInsensitiveDict(headers) if headers else None,
|
|
1129
|
+
config=ctx.checks_config,
|
|
1123
1130
|
)
|
|
1124
1131
|
try:
|
|
1125
1132
|
run_checks(
|
|
@@ -1201,7 +1208,10 @@ def _asgi_test(
|
|
|
1201
1208
|
status = Status.success
|
|
1202
1209
|
check_results: list[Check] = []
|
|
1203
1210
|
check_ctx = CheckContext(
|
|
1204
|
-
|
|
1211
|
+
override=ctx.override,
|
|
1212
|
+
auth=ctx.auth,
|
|
1213
|
+
headers=CaseInsensitiveDict(headers) if headers else None,
|
|
1214
|
+
config=ctx.checks_config,
|
|
1205
1215
|
)
|
|
1206
1216
|
try:
|
|
1207
1217
|
run_checks(
|
|
@@ -275,9 +275,10 @@ class SerializedError:
|
|
|
275
275
|
message = f"Scalar type '{scalar_name}' is not recognized"
|
|
276
276
|
extras = []
|
|
277
277
|
title = "Unknown GraphQL Scalar"
|
|
278
|
-
elif
|
|
279
|
-
|
|
280
|
-
|
|
278
|
+
elif (
|
|
279
|
+
isinstance(exception, hypothesis.errors.InvalidArgument)
|
|
280
|
+
and str(exception).endswith("larger than Hypothesis is designed to handle")
|
|
281
|
+
or "can never generate an example, because min_size is larger than Hypothesis supports" in str(exception)
|
|
281
282
|
):
|
|
282
283
|
type_ = RuntimeErrorType.HYPOTHESIS_HEALTH_CHECK_LARGE_BASE_EXAMPLE
|
|
283
284
|
message = HEALTH_CHECK_MESSAGE_LARGE_BASE_EXAMPLE
|
|
@@ -396,6 +397,7 @@ class SerializedInteraction:
|
|
|
396
397
|
data_generation_method: DataGenerationMethod
|
|
397
398
|
phase: TestPhase | None
|
|
398
399
|
description: str | None
|
|
400
|
+
location: str | None
|
|
399
401
|
recorded_at: str
|
|
400
402
|
|
|
401
403
|
@classmethod
|
|
@@ -408,6 +410,7 @@ class SerializedInteraction:
|
|
|
408
410
|
data_generation_method=interaction.data_generation_method,
|
|
409
411
|
phase=interaction.phase,
|
|
410
412
|
description=interaction.description,
|
|
413
|
+
location=interaction.location,
|
|
411
414
|
recorded_at=interaction.recorded_at,
|
|
412
415
|
)
|
|
413
416
|
|
schemathesis/serializers.py
CHANGED
|
@@ -204,7 +204,7 @@ def make_strftime(format: str) -> Callable:
|
|
|
204
204
|
|
|
205
205
|
|
|
206
206
|
def _get_map_function(definition: TransformFunctionDefinition) -> Result[Callable | None, Exception]:
|
|
207
|
-
from ..
|
|
207
|
+
from ..serializers import Binary
|
|
208
208
|
|
|
209
209
|
TRANSFORM_FACTORIES: dict[str, Callable] = {
|
|
210
210
|
"str": lambda: str,
|
schemathesis/service/metadata.py
CHANGED
|
@@ -35,7 +35,7 @@ class CliMetadata:
|
|
|
35
35
|
version: str = SCHEMATHESIS_VERSION
|
|
36
36
|
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
DEPENDENCY_NAMES = ["hypothesis", "hypothesis-jsonschema", "hypothesis-graphql"]
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
@dataclass
|
|
@@ -53,7 +53,7 @@ class Dependency:
|
|
|
53
53
|
|
|
54
54
|
|
|
55
55
|
def collect_dependency_versions() -> list[Dependency]:
|
|
56
|
-
return [Dependency.from_name(name) for name in
|
|
56
|
+
return [Dependency.from_name(name) for name in DEPENDENCY_NAMES]
|
|
57
57
|
|
|
58
58
|
|
|
59
59
|
@dataclass
|
|
@@ -68,4 +68,4 @@ class Metadata:
|
|
|
68
68
|
cli: CliMetadata = field(default_factory=CliMetadata)
|
|
69
69
|
# Used Docker image if any
|
|
70
70
|
docker_image: str | None = field(default_factory=lambda: os.getenv(DOCKER_IMAGE_ENV_VAR))
|
|
71
|
-
|
|
71
|
+
depedenencies: list[Dependency] = field(default_factory=collect_dependency_versions)
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import string
|
|
4
3
|
import time
|
|
5
|
-
from base64 import b64encode
|
|
6
4
|
from contextlib import suppress
|
|
7
5
|
from dataclasses import dataclass
|
|
8
|
-
from functools import lru_cache
|
|
9
6
|
from typing import Any, Callable, Dict, Iterable, Optional
|
|
10
7
|
from urllib.parse import quote_plus
|
|
11
8
|
from weakref import WeakKeyDictionary
|
|
@@ -13,7 +10,6 @@ from weakref import WeakKeyDictionary
|
|
|
13
10
|
from hypothesis import reject
|
|
14
11
|
from hypothesis import strategies as st
|
|
15
12
|
from hypothesis_jsonschema import from_schema
|
|
16
|
-
from requests.auth import _basic_auth_str
|
|
17
13
|
from requests.structures import CaseInsensitiveDict
|
|
18
14
|
from requests.utils import to_key_val_list
|
|
19
15
|
|
|
@@ -26,56 +22,22 @@ from ...hooks import HookContext, HookDispatcher, apply_to_all_dispatchers
|
|
|
26
22
|
from ...internal.copy import fast_deepcopy
|
|
27
23
|
from ...internal.validation import is_illegal_surrogate
|
|
28
24
|
from ...models import APIOperation, Case, GenerationMetadata, TestPhase, cant_serialize
|
|
29
|
-
from ...serializers import Binary
|
|
30
25
|
from ...transports.content_types import parse_content_type
|
|
31
26
|
from ...transports.headers import has_invalid_characters, is_latin_1_encodable
|
|
32
27
|
from ...types import NotSet
|
|
33
|
-
from ...utils import
|
|
28
|
+
from ...utils import skip
|
|
34
29
|
from .constants import LOCATION_TO_CONTAINER
|
|
35
|
-
from .formats import STRING_FORMATS
|
|
30
|
+
from .formats import HEADER_FORMAT, STRING_FORMATS, get_default_format_strategies, header_values
|
|
36
31
|
from .media_types import MEDIA_TYPES
|
|
37
32
|
from .negative import negative_schema
|
|
38
33
|
from .negative.utils import can_negate
|
|
39
34
|
from .parameters import OpenAPIBody, OpenAPIParameter, parameters_to_json_schema
|
|
40
35
|
from .utils import is_header_location
|
|
41
36
|
|
|
42
|
-
HEADER_FORMAT = "_header_value"
|
|
43
37
|
SLASH = "/"
|
|
44
38
|
StrategyFactory = Callable[[Dict[str, Any], str, str, Optional[str], GenerationConfig], st.SearchStrategy]
|
|
45
39
|
|
|
46
40
|
|
|
47
|
-
def header_values(blacklist_characters: str = "\n\r") -> st.SearchStrategy[str]:
|
|
48
|
-
return st.text(
|
|
49
|
-
alphabet=st.characters(min_codepoint=0, max_codepoint=255, blacklist_characters=blacklist_characters)
|
|
50
|
-
# Header values with leading non-visible chars can't be sent with `requests`
|
|
51
|
-
).map(str.lstrip)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
@lru_cache
|
|
55
|
-
def get_default_format_strategies() -> dict[str, st.SearchStrategy]:
|
|
56
|
-
"""Get all default "format" strategies."""
|
|
57
|
-
|
|
58
|
-
def make_basic_auth_str(item: tuple[str, str]) -> str:
|
|
59
|
-
return _basic_auth_str(*item)
|
|
60
|
-
|
|
61
|
-
latin1_text = st.text(alphabet=st.characters(min_codepoint=0, max_codepoint=255))
|
|
62
|
-
|
|
63
|
-
# Define valid characters here to avoid filtering them out in `is_valid_header` later
|
|
64
|
-
header_value = header_values()
|
|
65
|
-
|
|
66
|
-
return {
|
|
67
|
-
"binary": st.binary().map(Binary),
|
|
68
|
-
"byte": st.binary().map(lambda x: b64encode(x).decode()),
|
|
69
|
-
# RFC 7230, Section 3.2.6
|
|
70
|
-
"_header_name": st.text(
|
|
71
|
-
min_size=1, alphabet=st.sampled_from("!#$%&'*+-.^_`|~" + string.digits + string.ascii_letters)
|
|
72
|
-
),
|
|
73
|
-
HEADER_FORMAT: header_value,
|
|
74
|
-
"_basic_auth": st.tuples(latin1_text, latin1_text).map(make_basic_auth_str),
|
|
75
|
-
"_bearer_auth": header_value.map("Bearer {}".format),
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
79
41
|
def is_valid_header(headers: dict[str, Any]) -> bool:
|
|
80
42
|
"""Verify if the generated headers are valid."""
|
|
81
43
|
for name, value in headers.items():
|
|
@@ -216,6 +178,7 @@ def get_case_strategy(
|
|
|
216
178
|
body=body_.generator,
|
|
217
179
|
phase=phase,
|
|
218
180
|
description=None,
|
|
181
|
+
location=None,
|
|
219
182
|
),
|
|
220
183
|
)
|
|
221
184
|
auth_context = auths.AuthContext(
|
|
@@ -417,12 +380,10 @@ def get_parameters_strategy(
|
|
|
417
380
|
# `True` / `False` / `None` improves chances of them passing validation in apps
|
|
418
381
|
# that expect boolean / null types
|
|
419
382
|
# and not aware of Python-specific representation of those types
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
if map_func:
|
|
425
|
-
strategy = strategy.map(map_func) # type: ignore
|
|
383
|
+
if location == "path":
|
|
384
|
+
strategy = strategy.map(quote_all).map(jsonify_python_specific_types)
|
|
385
|
+
elif location == "query":
|
|
386
|
+
strategy = strategy.map(jsonify_python_specific_types)
|
|
426
387
|
_PARAMETER_STRATEGIES_CACHE.setdefault(operation, {})[nested_cache_key] = strategy
|
|
427
388
|
return strategy
|
|
428
389
|
# No parameters defined for this location
|
|
@@ -476,17 +476,22 @@ def _contains_auth(
|
|
|
476
476
|
return p["in"] == "cookie" and (p["name"] in cookies or p["name"] in header_cookies)
|
|
477
477
|
|
|
478
478
|
for parameter in security_parameters:
|
|
479
|
+
name = parameter["name"]
|
|
479
480
|
if has_header(parameter):
|
|
480
|
-
if ctx.headers is not None and
|
|
481
|
+
if (ctx.headers is not None and name in ctx.headers) or (ctx.override and name in ctx.override.headers):
|
|
481
482
|
return AuthKind.EXPLICIT
|
|
482
483
|
return AuthKind.GENERATED
|
|
483
484
|
if has_cookie(parameter):
|
|
484
485
|
if ctx.headers is not None and "Cookie" in ctx.headers:
|
|
485
486
|
cookies = cast(RequestsCookieJar, ctx.headers["Cookie"]) # type: ignore
|
|
486
|
-
if
|
|
487
|
+
if name in cookies:
|
|
487
488
|
return AuthKind.EXPLICIT
|
|
489
|
+
if ctx.override and name in ctx.override.cookies:
|
|
490
|
+
return AuthKind.EXPLICIT
|
|
488
491
|
return AuthKind.GENERATED
|
|
489
492
|
if has_query(parameter):
|
|
493
|
+
if ctx.override and name in ctx.override.query:
|
|
494
|
+
return AuthKind.EXPLICIT
|
|
490
495
|
return AuthKind.GENERATED
|
|
491
496
|
|
|
492
497
|
return None
|
|
@@ -9,7 +9,12 @@ from .patterns import update_quantifier
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def to_json_schema(
|
|
12
|
-
schema: dict[str, Any],
|
|
12
|
+
schema: dict[str, Any],
|
|
13
|
+
*,
|
|
14
|
+
nullable_name: str,
|
|
15
|
+
copy: bool = True,
|
|
16
|
+
is_response_schema: bool = False,
|
|
17
|
+
update_quantifiers: bool = True,
|
|
13
18
|
) -> dict[str, Any]:
|
|
14
19
|
"""Convert Open API parameters to JSON Schema.
|
|
15
20
|
|
|
@@ -25,6 +30,19 @@ def to_json_schema(
|
|
|
25
30
|
if schema_type == "file":
|
|
26
31
|
schema["type"] = "string"
|
|
27
32
|
schema["format"] = "binary"
|
|
33
|
+
if update_quantifiers:
|
|
34
|
+
update_pattern_in_schema(schema)
|
|
35
|
+
if schema_type == "object":
|
|
36
|
+
if is_response_schema:
|
|
37
|
+
# Write-only properties should not occur in responses
|
|
38
|
+
rewrite_properties(schema, is_write_only)
|
|
39
|
+
else:
|
|
40
|
+
# Read-only properties should not occur in requests
|
|
41
|
+
rewrite_properties(schema, is_read_only)
|
|
42
|
+
return schema
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def update_pattern_in_schema(schema: dict[str, Any]) -> None:
|
|
28
46
|
pattern = schema.get("pattern")
|
|
29
47
|
min_length = schema.get("minLength")
|
|
30
48
|
max_length = schema.get("maxLength")
|
|
@@ -34,14 +52,6 @@ def to_json_schema(
|
|
|
34
52
|
schema.pop("minLength", None)
|
|
35
53
|
schema.pop("maxLength", None)
|
|
36
54
|
schema["pattern"] = new_pattern
|
|
37
|
-
if schema_type == "object":
|
|
38
|
-
if is_response_schema:
|
|
39
|
-
# Write-only properties should not occur in responses
|
|
40
|
-
rewrite_properties(schema, is_write_only)
|
|
41
|
-
else:
|
|
42
|
-
# Read-only properties should not occur in requests
|
|
43
|
-
rewrite_properties(schema, is_read_only)
|
|
44
|
-
return schema
|
|
45
55
|
|
|
46
56
|
|
|
47
57
|
def rewrite_properties(schema: dict[str, Any], predicate: Callable[[dict[str, Any]], bool]) -> None:
|
|
@@ -82,6 +92,12 @@ def is_read_only(schema: dict[str, Any] | bool) -> bool:
|
|
|
82
92
|
|
|
83
93
|
|
|
84
94
|
def to_json_schema_recursive(
|
|
85
|
-
schema: dict[str, Any], nullable_name: str, is_response_schema: bool = False
|
|
95
|
+
schema: dict[str, Any], nullable_name: str, is_response_schema: bool = False, update_quantifiers: bool = True
|
|
86
96
|
) -> dict[str, Any]:
|
|
87
|
-
return traverse_schema(
|
|
97
|
+
return traverse_schema(
|
|
98
|
+
schema,
|
|
99
|
+
to_json_schema,
|
|
100
|
+
nullable_name=nullable_name,
|
|
101
|
+
is_response_schema=is_response_schema,
|
|
102
|
+
update_quantifiers=update_quantifiers,
|
|
103
|
+
)
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import string
|
|
4
|
+
from base64 import b64encode
|
|
5
|
+
from functools import lru_cache
|
|
3
6
|
from typing import TYPE_CHECKING
|
|
4
7
|
|
|
5
8
|
if TYPE_CHECKING:
|
|
@@ -33,5 +36,46 @@ def unregister_string_format(name: str) -> None:
|
|
|
33
36
|
raise ValueError(f"Unknown Open API format: {name}") from exc
|
|
34
37
|
|
|
35
38
|
|
|
39
|
+
def header_values(blacklist_characters: str = "\n\r") -> st.SearchStrategy[str]:
|
|
40
|
+
from hypothesis import strategies as st
|
|
41
|
+
|
|
42
|
+
return st.text(
|
|
43
|
+
alphabet=st.characters(min_codepoint=0, max_codepoint=255, blacklist_characters=blacklist_characters)
|
|
44
|
+
# Header values with leading non-visible chars can't be sent with `requests`
|
|
45
|
+
).map(str.lstrip)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
HEADER_FORMAT = "_header_value"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@lru_cache
|
|
52
|
+
def get_default_format_strategies() -> dict[str, st.SearchStrategy]:
|
|
53
|
+
"""Get all default "format" strategies."""
|
|
54
|
+
from hypothesis import strategies as st
|
|
55
|
+
from requests.auth import _basic_auth_str
|
|
56
|
+
|
|
57
|
+
from ...serializers import Binary
|
|
58
|
+
|
|
59
|
+
def make_basic_auth_str(item: tuple[str, str]) -> str:
|
|
60
|
+
return _basic_auth_str(*item)
|
|
61
|
+
|
|
62
|
+
latin1_text = st.text(alphabet=st.characters(min_codepoint=0, max_codepoint=255))
|
|
63
|
+
|
|
64
|
+
# Define valid characters here to avoid filtering them out in `is_valid_header` later
|
|
65
|
+
header_value = header_values()
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
"binary": st.binary().map(Binary),
|
|
69
|
+
"byte": st.binary().map(lambda x: b64encode(x).decode()),
|
|
70
|
+
# RFC 7230, Section 3.2.6
|
|
71
|
+
"_header_name": st.text(
|
|
72
|
+
min_size=1, alphabet=st.sampled_from("!#$%&'*+-.^_`|~" + string.digits + string.ascii_letters)
|
|
73
|
+
),
|
|
74
|
+
HEADER_FORMAT: header_value,
|
|
75
|
+
"_basic_auth": st.tuples(latin1_text, latin1_text).map(make_basic_auth_str),
|
|
76
|
+
"_bearer_auth": header_value.map("Bearer {}".format),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
36
80
|
register = register_string_format
|
|
37
81
|
unregister = unregister_string_format
|
|
@@ -372,6 +372,11 @@ def negate_constraints(context: MutationContext, draw: Draw, schema: Schema) ->
|
|
|
372
372
|
# Should we negate this key?
|
|
373
373
|
if k == "required":
|
|
374
374
|
return v != []
|
|
375
|
+
if k in ("example", "examples"):
|
|
376
|
+
return False
|
|
377
|
+
if context.is_path_location and k == "minLength" and v == 1:
|
|
378
|
+
# Empty path parameter will be filtered out
|
|
379
|
+
return False
|
|
375
380
|
return not (
|
|
376
381
|
k in ("type", "properties", "items", "minItems")
|
|
377
382
|
or (k == "additionalProperties" and context.is_header_location)
|
|
@@ -57,7 +57,7 @@ class OpenAPIParameter(Parameter):
|
|
|
57
57
|
def is_header(self) -> bool:
|
|
58
58
|
return self.location in ("header", "cookie")
|
|
59
59
|
|
|
60
|
-
def as_json_schema(self, operation: APIOperation) -> dict[str, Any]:
|
|
60
|
+
def as_json_schema(self, operation: APIOperation, *, update_quantifiers: bool = True) -> dict[str, Any]:
|
|
61
61
|
"""Convert parameter's definition to JSON Schema."""
|
|
62
62
|
# JSON Schema allows `examples` as an array
|
|
63
63
|
examples = []
|
|
@@ -70,11 +70,11 @@ class OpenAPIParameter(Parameter):
|
|
|
70
70
|
schema = self.from_open_api_to_json_schema(operation, self.definition)
|
|
71
71
|
if examples:
|
|
72
72
|
schema["examples"] = examples
|
|
73
|
-
return self.transform_keywords(schema)
|
|
73
|
+
return self.transform_keywords(schema, update_quantifiers=update_quantifiers)
|
|
74
74
|
|
|
75
|
-
def transform_keywords(self, schema: dict[str, Any]) -> dict[str, Any]:
|
|
75
|
+
def transform_keywords(self, schema: dict[str, Any], *, update_quantifiers: bool = True) -> dict[str, Any]:
|
|
76
76
|
"""Transform Open API specific keywords into JSON Schema compatible form."""
|
|
77
|
-
definition = to_json_schema_recursive(schema, self.nullable_field)
|
|
77
|
+
definition = to_json_schema_recursive(schema, self.nullable_field, update_quantifiers=update_quantifiers)
|
|
78
78
|
# Headers are strings, but it is not always explicitly defined in the schema. By preparing them properly, we
|
|
79
79
|
# can achieve significant performance improvements for such cases.
|
|
80
80
|
# For reference (my machine) - running a single test with 100 examples with the resulting strategy:
|
|
@@ -236,11 +236,11 @@ class OpenAPI20Body(OpenAPIBody, OpenAPI20Parameter):
|
|
|
236
236
|
# NOTE. For Open API 2.0 bodies, we still give `x-example` precedence over the schema-level `example` field to keep
|
|
237
237
|
# the precedence rules consistent.
|
|
238
238
|
|
|
239
|
-
def as_json_schema(self, operation: APIOperation) -> dict[str, Any]:
|
|
239
|
+
def as_json_schema(self, operation: APIOperation, *, update_quantifiers: bool = True) -> dict[str, Any]:
|
|
240
240
|
"""Convert body definition to JSON Schema."""
|
|
241
241
|
# `schema` is required in Open API 2.0 when the `in` keyword is `body`
|
|
242
242
|
schema = self.definition["schema"]
|
|
243
|
-
return self.transform_keywords(schema)
|
|
243
|
+
return self.transform_keywords(schema, update_quantifiers=update_quantifiers)
|
|
244
244
|
|
|
245
245
|
|
|
246
246
|
FORM_MEDIA_TYPES = ("multipart/form-data", "application/x-www-form-urlencoded")
|
|
@@ -259,13 +259,13 @@ class OpenAPI30Body(OpenAPIBody, OpenAPI30Parameter):
|
|
|
259
259
|
required: bool = False
|
|
260
260
|
description: str | None = None
|
|
261
261
|
|
|
262
|
-
def as_json_schema(self, operation: APIOperation) -> dict[str, Any]:
|
|
262
|
+
def as_json_schema(self, operation: APIOperation, *, update_quantifiers: bool = True) -> dict[str, Any]:
|
|
263
263
|
"""Convert body definition to JSON Schema."""
|
|
264
264
|
schema = get_media_type_schema(self.definition)
|
|
265
|
-
return self.transform_keywords(schema)
|
|
265
|
+
return self.transform_keywords(schema, update_quantifiers=update_quantifiers)
|
|
266
266
|
|
|
267
|
-
def transform_keywords(self, schema: dict[str, Any]) -> dict[str, Any]:
|
|
268
|
-
definition = super().transform_keywords(schema)
|
|
267
|
+
def transform_keywords(self, schema: dict[str, Any], *, update_quantifiers: bool = True) -> dict[str, Any]:
|
|
268
|
+
definition = super().transform_keywords(schema, update_quantifiers=update_quantifiers)
|
|
269
269
|
if self.is_form:
|
|
270
270
|
# It significantly reduces the "filtering" part of data generation.
|
|
271
271
|
definition.setdefault("type", "object")
|
|
@@ -303,12 +303,14 @@ class OpenAPI20CompositeBody(OpenAPIBody, OpenAPI20Parameter):
|
|
|
303
303
|
# We generate an object for formData - it is always required.
|
|
304
304
|
return bool(self.definition)
|
|
305
305
|
|
|
306
|
-
def as_json_schema(self, operation: APIOperation) -> dict[str, Any]:
|
|
306
|
+
def as_json_schema(self, operation: APIOperation, *, update_quantifiers: bool = True) -> dict[str, Any]:
|
|
307
307
|
"""The composite body is transformed into an "object" JSON Schema."""
|
|
308
|
-
return parameters_to_json_schema(operation, self.definition)
|
|
308
|
+
return parameters_to_json_schema(operation, self.definition, update_quantifiers=update_quantifiers)
|
|
309
309
|
|
|
310
310
|
|
|
311
|
-
def parameters_to_json_schema(
|
|
311
|
+
def parameters_to_json_schema(
|
|
312
|
+
operation: APIOperation, parameters: Iterable[OpenAPIParameter], *, update_quantifiers: bool = True
|
|
313
|
+
) -> dict[str, Any]:
|
|
312
314
|
"""Create an "object" JSON schema from a list of Open API parameters.
|
|
313
315
|
|
|
314
316
|
:param List[OpenAPIParameter] parameters: A list of Open API parameters related to the same location. All of
|
|
@@ -348,7 +350,7 @@ def parameters_to_json_schema(operation: APIOperation, parameters: Iterable[Open
|
|
|
348
350
|
required = []
|
|
349
351
|
for parameter in parameters:
|
|
350
352
|
name = parameter.name
|
|
351
|
-
properties[name] = parameter.as_json_schema(operation)
|
|
353
|
+
properties[name] = parameter.as_json_schema(operation, update_quantifiers=update_quantifiers)
|
|
352
354
|
# If parameter names are duplicated, we need to avoid duplicate entries in `required` anyway
|
|
353
355
|
if parameter.is_required and name not in required:
|
|
354
356
|
required.append(name)
|