schemathesis 4.0.0a11__py3-none-any.whl → 4.0.0b1__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 +35 -27
- schemathesis/auths.py +85 -54
- schemathesis/checks.py +65 -36
- schemathesis/cli/commands/run/__init__.py +32 -27
- schemathesis/cli/commands/run/context.py +6 -1
- schemathesis/cli/commands/run/events.py +7 -1
- schemathesis/cli/commands/run/executor.py +12 -7
- schemathesis/cli/commands/run/handlers/output.py +188 -80
- schemathesis/cli/commands/run/validation.py +21 -6
- schemathesis/cli/constants.py +1 -1
- schemathesis/config/__init__.py +2 -1
- schemathesis/config/_generation.py +12 -13
- schemathesis/config/_operations.py +14 -0
- schemathesis/config/_phases.py +41 -5
- schemathesis/config/_projects.py +33 -1
- schemathesis/config/_report.py +6 -2
- schemathesis/config/_warnings.py +25 -0
- schemathesis/config/schema.json +49 -1
- schemathesis/core/errors.py +15 -19
- schemathesis/core/transport.py +117 -2
- schemathesis/engine/context.py +1 -0
- schemathesis/engine/errors.py +61 -2
- schemathesis/engine/events.py +10 -2
- schemathesis/engine/phases/probes.py +3 -0
- schemathesis/engine/phases/stateful/__init__.py +2 -1
- schemathesis/engine/phases/stateful/_executor.py +38 -5
- schemathesis/engine/phases/stateful/context.py +2 -2
- schemathesis/engine/phases/unit/_executor.py +36 -7
- schemathesis/generation/__init__.py +0 -3
- schemathesis/generation/case.py +153 -28
- schemathesis/generation/coverage.py +1 -1
- schemathesis/generation/hypothesis/builder.py +43 -19
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +0 -8
- schemathesis/generation/overrides.py +11 -27
- schemathesis/generation/stateful/__init__.py +17 -0
- schemathesis/generation/stateful/state_machine.py +32 -108
- schemathesis/graphql/loaders.py +152 -8
- schemathesis/hooks.py +63 -39
- schemathesis/openapi/checks.py +82 -20
- schemathesis/openapi/generation/filters.py +9 -2
- schemathesis/openapi/loaders.py +134 -8
- schemathesis/pytest/lazy.py +4 -31
- schemathesis/pytest/loaders.py +24 -0
- schemathesis/pytest/plugin.py +38 -6
- schemathesis/schemas.py +161 -94
- schemathesis/specs/graphql/scalars.py +37 -3
- schemathesis/specs/graphql/schemas.py +18 -9
- schemathesis/specs/openapi/_hypothesis.py +53 -34
- schemathesis/specs/openapi/checks.py +111 -47
- schemathesis/specs/openapi/expressions/nodes.py +1 -1
- schemathesis/specs/openapi/formats.py +30 -3
- schemathesis/specs/openapi/media_types.py +44 -1
- schemathesis/specs/openapi/negative/__init__.py +5 -3
- schemathesis/specs/openapi/negative/mutations.py +2 -2
- schemathesis/specs/openapi/parameters.py +0 -3
- schemathesis/specs/openapi/schemas.py +14 -93
- schemathesis/specs/openapi/stateful/__init__.py +2 -1
- schemathesis/specs/openapi/stateful/links.py +1 -63
- schemathesis/transport/__init__.py +54 -16
- schemathesis/transport/prepare.py +31 -7
- schemathesis/transport/requests.py +21 -9
- schemathesis/transport/serialization.py +0 -4
- schemathesis/transport/wsgi.py +15 -8
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/METADATA +45 -87
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/RECORD +69 -71
- schemathesis/contrib/__init__.py +0 -9
- schemathesis/contrib/openapi/__init__.py +0 -9
- schemathesis/contrib/openapi/fill_missing_examples.py +0 -20
- schemathesis/generation/targets.py +0 -69
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/licenses/LICENSE +0 -0
@@ -28,16 +28,17 @@ class CacheKey:
|
|
28
28
|
operation_name: str
|
29
29
|
location: str
|
30
30
|
schema: Schema
|
31
|
+
validator_cls: type[jsonschema.Validator]
|
31
32
|
|
32
33
|
def __hash__(self) -> int:
|
33
34
|
return hash((self.operation_name, self.location))
|
34
35
|
|
35
36
|
|
36
37
|
@lru_cache
|
37
|
-
def get_validator(cache_key: CacheKey) -> jsonschema.
|
38
|
+
def get_validator(cache_key: CacheKey) -> jsonschema.Validator:
|
38
39
|
"""Get JSON Schema validator for the given schema."""
|
39
40
|
# Each operation / location combo has only a single schema, therefore could be cached
|
40
|
-
return
|
41
|
+
return cache_key.validator_cls(cache_key.schema)
|
41
42
|
|
42
43
|
|
43
44
|
@lru_cache
|
@@ -63,6 +64,7 @@ def negative_schema(
|
|
63
64
|
generation_config: GenerationConfig,
|
64
65
|
*,
|
65
66
|
custom_formats: dict[str, st.SearchStrategy[str]],
|
67
|
+
validator_cls: type[jsonschema.Validator],
|
66
68
|
) -> st.SearchStrategy:
|
67
69
|
"""A strategy for instances that DO NOT match the input schema.
|
68
70
|
|
@@ -70,7 +72,7 @@ def negative_schema(
|
|
70
72
|
"""
|
71
73
|
# The mutated schema is passed to `from_schema` and guarded against producing instances valid against
|
72
74
|
# the original schema.
|
73
|
-
cache_key = CacheKey(operation_name, location, schema)
|
75
|
+
cache_key = CacheKey(operation_name, location, schema, validator_cls)
|
74
76
|
validator = get_validator(cache_key)
|
75
77
|
keywords, non_keywords = split_schema(cache_key)
|
76
78
|
|
@@ -402,8 +402,8 @@ def negate_constraints(context: MutationContext, draw: Draw, schema: Schema) ->
|
|
402
402
|
if key in DEPENDENCIES:
|
403
403
|
# If this keyword has a dependency, then it should be also negated
|
404
404
|
dependency = DEPENDENCIES[key]
|
405
|
-
if dependency not in negated:
|
406
|
-
negated[dependency] = copied[dependency]
|
405
|
+
if dependency not in negated and dependency in copied:
|
406
|
+
negated[dependency] = copied[dependency]
|
407
407
|
else:
|
408
408
|
schema[key] = value
|
409
409
|
if is_negated:
|
@@ -314,9 +314,6 @@ def parameters_to_json_schema(
|
|
314
314
|
) -> dict[str, Any]:
|
315
315
|
"""Create an "object" JSON schema from a list of Open API parameters.
|
316
316
|
|
317
|
-
:param List[OpenAPIParameter] parameters: A list of Open API parameters related to the same location. All of
|
318
|
-
them are expected to have the same "in" value.
|
319
|
-
|
320
317
|
For each input parameter, there will be a property in the output schema.
|
321
318
|
|
322
319
|
This:
|
@@ -39,7 +39,6 @@ from schemathesis.core.transport import Response
|
|
39
39
|
from schemathesis.core.validation import INVALID_HEADER_RE
|
40
40
|
from schemathesis.generation.case import Case
|
41
41
|
from schemathesis.generation.meta import CaseMetadata
|
42
|
-
from schemathesis.generation.overrides import Override, OverrideMark, check_no_override_mark
|
43
42
|
from schemathesis.openapi.checks import JsonSchemaError, MissingContentType
|
44
43
|
from schemathesis.specs.openapi.stateful import links
|
45
44
|
|
@@ -120,13 +119,18 @@ class BaseOpenAPISchema(BaseSchema):
|
|
120
119
|
if map is not None:
|
121
120
|
return map
|
122
121
|
path_item = self.raw_schema.get("paths", {})[path]
|
123
|
-
|
122
|
+
with in_scope(self.resolver, self.location or ""):
|
123
|
+
scope, path_item = self._resolve_path_item(path_item)
|
124
124
|
self.dispatch_hook("before_process_path", HookContext(), path, path_item)
|
125
125
|
map = APIOperationMap(self, {})
|
126
126
|
map._data = MethodMap(map, scope, path, CaseInsensitiveDict(path_item))
|
127
127
|
cache.insert_map(path, map)
|
128
128
|
return map
|
129
129
|
|
130
|
+
def find_operation_by_label(self, label: str) -> APIOperation | None:
|
131
|
+
method, path = label.split(" ", maxsplit=1)
|
132
|
+
return self[path][method]
|
133
|
+
|
130
134
|
def on_missing_operation(self, item: str, exc: KeyError) -> NoReturn:
|
131
135
|
matches = get_close_matches(item, list(self))
|
132
136
|
self._on_missing_operation(item, exc, matches)
|
@@ -254,26 +258,6 @@ class BaseOpenAPISchema(BaseSchema):
|
|
254
258
|
# Ignore errors
|
255
259
|
continue
|
256
260
|
|
257
|
-
def override(
|
258
|
-
self,
|
259
|
-
*,
|
260
|
-
query: dict[str, str] | None = None,
|
261
|
-
headers: dict[str, str] | None = None,
|
262
|
-
cookies: dict[str, str] | None = None,
|
263
|
-
path_parameters: dict[str, str] | None = None,
|
264
|
-
) -> Callable[[Callable], Callable]:
|
265
|
-
"""Override Open API parameters with fixed values."""
|
266
|
-
|
267
|
-
def _add_override(test: Callable) -> Callable:
|
268
|
-
check_no_override_mark(test)
|
269
|
-
override = Override(
|
270
|
-
query=query or {}, headers=headers or {}, cookies=cookies or {}, path_parameters=path_parameters or {}
|
271
|
-
)
|
272
|
-
OverrideMark.set(test, override)
|
273
|
-
return test
|
274
|
-
|
275
|
-
return _add_override
|
276
|
-
|
277
261
|
def _resolve_until_no_references(self, value: dict[str, Any]) -> dict[str, Any]:
|
278
262
|
while "$ref" in value:
|
279
263
|
_, value = self.resolver.resolve(value["$ref"])
|
@@ -536,7 +520,7 @@ class BaseOpenAPISchema(BaseSchema):
|
|
536
520
|
operation: APIOperation,
|
537
521
|
hooks: HookDispatcher | None = None,
|
538
522
|
auth_storage: AuthStorage | None = None,
|
539
|
-
generation_mode: GenerationMode = GenerationMode.
|
523
|
+
generation_mode: GenerationMode = GenerationMode.POSITIVE,
|
540
524
|
**kwargs: Any,
|
541
525
|
) -> SearchStrategy:
|
542
526
|
return openapi_cases(
|
@@ -591,52 +575,6 @@ class BaseOpenAPISchema(BaseSchema):
|
|
591
575
|
def as_state_machine(self) -> type[APIStateMachine]:
|
592
576
|
return create_state_machine(self)
|
593
577
|
|
594
|
-
def add_link(
|
595
|
-
self,
|
596
|
-
source: APIOperation,
|
597
|
-
target: str | APIOperation,
|
598
|
-
status_code: str | int,
|
599
|
-
parameters: dict[str, str] | None = None,
|
600
|
-
request_body: Any = None,
|
601
|
-
name: str | None = None,
|
602
|
-
) -> None:
|
603
|
-
"""Add a new Open API link to the schema definition.
|
604
|
-
|
605
|
-
:param APIOperation source: This operation is the source of data
|
606
|
-
:param target: This operation will receive the data from this link.
|
607
|
-
Can be an ``APIOperation`` instance or a reference like this - ``#/paths/~1users~1{userId}/get``
|
608
|
-
:param str status_code: The link is triggered when the source API operation responds with this status code.
|
609
|
-
:param parameters: A dictionary that describes how parameters should be extracted from the matched response.
|
610
|
-
The key represents the parameter name in the target API operation, and the value is a runtime
|
611
|
-
expression string.
|
612
|
-
:param request_body: A literal value or runtime expression to use as a request body when
|
613
|
-
calling the target operation.
|
614
|
-
:param str name: Explicit link name.
|
615
|
-
|
616
|
-
.. code-block:: python
|
617
|
-
|
618
|
-
schema = schemathesis.openapi.from_url("http://0.0.0.0/schema.yaml")
|
619
|
-
|
620
|
-
schema.add_link(
|
621
|
-
source=schema["/users/"]["POST"],
|
622
|
-
target=schema["/users/{userId}"]["GET"],
|
623
|
-
status_code="201",
|
624
|
-
parameters={"userId": "$response.body#/id"},
|
625
|
-
)
|
626
|
-
"""
|
627
|
-
if parameters is None and request_body is None:
|
628
|
-
raise ValueError("You need to provide `parameters` or `request_body`.")
|
629
|
-
links.add_link(
|
630
|
-
resolver=self.resolver,
|
631
|
-
responses=self[source.path][source.method].definition.raw["responses"],
|
632
|
-
links_field=self.links_field,
|
633
|
-
parameters=parameters,
|
634
|
-
request_body=request_body,
|
635
|
-
status_code=status_code,
|
636
|
-
target=target,
|
637
|
-
name=name,
|
638
|
-
)
|
639
|
-
|
640
578
|
def get_links(self, operation: APIOperation) -> dict[str, dict[str, Any]]:
|
641
579
|
result: dict[str, dict[str, Any]] = defaultdict(dict)
|
642
580
|
for status_code, link in links.get_all_links(operation):
|
@@ -658,6 +596,7 @@ class BaseOpenAPISchema(BaseSchema):
|
|
658
596
|
return jsonschema.Draft4Validator
|
659
597
|
|
660
598
|
def validate_response(self, operation: APIOperation, response: Response) -> bool | None:
|
599
|
+
__tracebackhide__ = True
|
661
600
|
responses = {str(key): value for key, value in operation.definition.raw.get("responses", {}).items()}
|
662
601
|
status_code = str(response.status_code)
|
663
602
|
if status_code in responses:
|
@@ -903,7 +842,7 @@ class MethodMap(Mapping):
|
|
903
842
|
try:
|
904
843
|
return self._init_operation(item)
|
905
844
|
except LookupError as exc:
|
906
|
-
available_methods = ", ".join(
|
845
|
+
available_methods = ", ".join(key.upper() for key in self if key in HTTP_METHODS)
|
907
846
|
message = f"Method `{item.upper()}` not found."
|
908
847
|
if available_methods:
|
909
848
|
message += f" Available methods: {available_methods}"
|
@@ -1001,12 +940,6 @@ class SwaggerV20(BaseOpenAPISchema):
|
|
1001
940
|
def prepare_multipart(
|
1002
941
|
self, form_data: dict[str, Any], operation: APIOperation
|
1003
942
|
) -> tuple[list | None, dict[str, Any] | None]:
|
1004
|
-
"""Prepare form data for sending with `requests`.
|
1005
|
-
|
1006
|
-
:param form_data: Raw generated data as a dictionary.
|
1007
|
-
:param operation: The tested API operation for which the data was generated.
|
1008
|
-
:return: `files` and `data` values for `requests.request`.
|
1009
|
-
"""
|
1010
943
|
files, data = [], {}
|
1011
944
|
# If there is no content types specified for the request or "application/x-www-form-urlencoded" is specified
|
1012
945
|
# explicitly, then use it., but if "multipart/form-data" is specified, then use it
|
@@ -1050,7 +983,7 @@ class SwaggerV20(BaseOpenAPISchema):
|
|
1050
983
|
method: str | None = None,
|
1051
984
|
path: str | None = None,
|
1052
985
|
path_parameters: dict[str, Any] | None = None,
|
1053
|
-
headers: dict[str, Any] | None = None,
|
986
|
+
headers: dict[str, Any] | CaseInsensitiveDict | None = None,
|
1054
987
|
cookies: dict[str, Any] | None = None,
|
1055
988
|
query: dict[str, Any] | None = None,
|
1056
989
|
body: list | dict[str, Any] | str | int | float | bool | bytes | NotSet = NOT_SET,
|
@@ -1063,22 +996,16 @@ class SwaggerV20(BaseOpenAPISchema):
|
|
1063
996
|
operation=operation,
|
1064
997
|
method=method or operation.method.upper(),
|
1065
998
|
path=path or operation.path,
|
1066
|
-
path_parameters=path_parameters,
|
1067
|
-
headers=CaseInsensitiveDict(
|
1068
|
-
cookies=cookies,
|
1069
|
-
query=query,
|
999
|
+
path_parameters=path_parameters or {},
|
1000
|
+
headers=CaseInsensitiveDict() if headers is None else CaseInsensitiveDict(headers),
|
1001
|
+
cookies=cookies or {},
|
1002
|
+
query=query or {},
|
1070
1003
|
body=body,
|
1071
1004
|
media_type=media_type,
|
1072
1005
|
meta=meta,
|
1073
1006
|
)
|
1074
1007
|
|
1075
1008
|
def _get_consumes_for_operation(self, definition: dict[str, Any]) -> list[str]:
|
1076
|
-
"""Get the `consumes` value for the given API operation.
|
1077
|
-
|
1078
|
-
:param definition: Raw API operation definition.
|
1079
|
-
:return: A list of media-types for this operation.
|
1080
|
-
:rtype: List[str]
|
1081
|
-
"""
|
1082
1009
|
global_consumes = self.raw_schema.get("consumes", [])
|
1083
1010
|
consumes = definition.get("consumes", [])
|
1084
1011
|
if not consumes:
|
@@ -1177,12 +1104,6 @@ class OpenApi30(SwaggerV20):
|
|
1177
1104
|
def prepare_multipart(
|
1178
1105
|
self, form_data: dict[str, Any], operation: APIOperation
|
1179
1106
|
) -> tuple[list | None, dict[str, Any] | None]:
|
1180
|
-
"""Prepare form data for sending with `requests`.
|
1181
|
-
|
1182
|
-
:param form_data: Raw generated data as a dictionary.
|
1183
|
-
:param operation: The tested API operation for which the data was generated.
|
1184
|
-
:return: `files` and `data` values for `requests.request`.
|
1185
|
-
"""
|
1186
1107
|
files = []
|
1187
1108
|
definition = operation.definition.raw
|
1188
1109
|
if "$ref" in definition["requestBody"]:
|
@@ -14,6 +14,7 @@ from schemathesis.engine.recorder import ScenarioRecorder
|
|
14
14
|
from schemathesis.generation import GenerationMode
|
15
15
|
from schemathesis.generation.case import Case
|
16
16
|
from schemathesis.generation.hypothesis import strategies
|
17
|
+
from schemathesis.generation.stateful import STATEFUL_TESTS_LABEL
|
17
18
|
from schemathesis.generation.stateful.state_machine import APIStateMachine, StepInput, StepOutput, _normalize_name
|
18
19
|
from schemathesis.schemas import APIOperation
|
19
20
|
from schemathesis.specs.openapi.stateful.control import TransitionController
|
@@ -32,7 +33,7 @@ class OpenAPIStateMachine(APIStateMachine):
|
|
32
33
|
_transitions: ApiTransitions
|
33
34
|
|
34
35
|
def __init__(self) -> None:
|
35
|
-
self.recorder = ScenarioRecorder(label=
|
36
|
+
self.recorder = ScenarioRecorder(label=STATEFUL_TESTS_LABEL)
|
36
37
|
self.control = TransitionController(self._transitions)
|
37
38
|
super().__init__()
|
38
39
|
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
from dataclasses import dataclass
|
4
4
|
from functools import lru_cache
|
5
|
-
from typing import
|
5
|
+
from typing import Any, Callable, Generator, Literal, cast
|
6
6
|
|
7
7
|
from schemathesis.core import NOT_SET, NotSet
|
8
8
|
from schemathesis.core.errors import InvalidTransition, OperationNotFound, TransitionValidationError
|
@@ -13,10 +13,6 @@ from schemathesis.specs.openapi import expressions
|
|
13
13
|
from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
|
14
14
|
from schemathesis.specs.openapi.references import RECURSION_DEPTH_LIMIT
|
15
15
|
|
16
|
-
if TYPE_CHECKING:
|
17
|
-
from jsonschema import RefResolver
|
18
|
-
|
19
|
-
|
20
16
|
SCHEMATHESIS_LINK_EXTENSION = "x-schemathesis"
|
21
17
|
ParameterLocation = Literal["path", "query", "header", "cookie", "body"]
|
22
18
|
|
@@ -211,61 +207,3 @@ def get_all_links(
|
|
211
207
|
yield status_code, Ok(link)
|
212
208
|
except InvalidTransition as exc:
|
213
209
|
yield status_code, Err(exc)
|
214
|
-
|
215
|
-
|
216
|
-
StatusCode = Union[str, int]
|
217
|
-
|
218
|
-
|
219
|
-
def _get_response_by_status_code(responses: dict[StatusCode, dict[str, Any]], status_code: str | int) -> dict:
|
220
|
-
if isinstance(status_code, int):
|
221
|
-
# Invalid schemas may contain status codes as integers
|
222
|
-
if status_code in responses:
|
223
|
-
return responses[status_code]
|
224
|
-
# Passed here as an integer, but there is no such status code as int
|
225
|
-
# We cast it to a string because it is either there already and we'll get relevant responses, otherwise
|
226
|
-
# a new dict will be created because there is no such status code in the schema (as an int or a string)
|
227
|
-
return responses.setdefault(str(status_code), {})
|
228
|
-
if status_code.isnumeric():
|
229
|
-
# Invalid schema but the status code is passed as a string
|
230
|
-
numeric_status_code = int(status_code)
|
231
|
-
if numeric_status_code in responses:
|
232
|
-
return responses[numeric_status_code]
|
233
|
-
# All status codes as strings, including `default` and patterned values like `5XX`
|
234
|
-
return responses.setdefault(status_code, {})
|
235
|
-
|
236
|
-
|
237
|
-
def add_link(
|
238
|
-
resolver: RefResolver,
|
239
|
-
responses: dict[StatusCode, dict[str, Any]],
|
240
|
-
links_field: str,
|
241
|
-
parameters: dict[str, str] | None,
|
242
|
-
request_body: Any,
|
243
|
-
status_code: StatusCode,
|
244
|
-
target: str | APIOperation,
|
245
|
-
name: str | None = None,
|
246
|
-
) -> None:
|
247
|
-
response = _get_response_by_status_code(responses, status_code)
|
248
|
-
if "$ref" in response:
|
249
|
-
_, response = resolver.resolve(response["$ref"])
|
250
|
-
links_definition = response.setdefault(links_field, {})
|
251
|
-
new_link: dict[str, str | dict[str, str]] = {}
|
252
|
-
if parameters is not None:
|
253
|
-
new_link["parameters"] = parameters
|
254
|
-
if request_body is not None:
|
255
|
-
new_link["requestBody"] = request_body
|
256
|
-
if isinstance(target, str):
|
257
|
-
name = name or target
|
258
|
-
new_link["operationRef"] = target
|
259
|
-
else:
|
260
|
-
name = name or f"{target.method.upper()} {target.path}"
|
261
|
-
# operationId is a dict lookup which is more efficient than using `operationRef`, since it
|
262
|
-
# doesn't involve reference resolving when we will look up for this target during testing.
|
263
|
-
if "operationId" in target.definition.raw:
|
264
|
-
new_link["operationId"] = target.definition.raw["operationId"]
|
265
|
-
else:
|
266
|
-
new_link["operationRef"] = target.operation_reference
|
267
|
-
# The name is arbitrary, so we don't really case what it is,
|
268
|
-
# but it should not override existing links
|
269
|
-
while name in links_definition:
|
270
|
-
name += "_new"
|
271
|
-
links_definition[name] = new_link
|
@@ -2,11 +2,15 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
from dataclasses import dataclass
|
4
4
|
from inspect import iscoroutinefunction
|
5
|
-
from typing import Any, Callable, Generic, Iterator, TypeVar
|
5
|
+
from typing import TYPE_CHECKING, Any, Callable, Generic, Iterator, TypeVar, Union
|
6
6
|
|
7
7
|
from schemathesis.core import media_types
|
8
8
|
from schemathesis.core.errors import SerializationNotPossible
|
9
9
|
|
10
|
+
if TYPE_CHECKING:
|
11
|
+
from schemathesis.core.transport import Response
|
12
|
+
from schemathesis.generation.case import Case
|
13
|
+
|
10
14
|
|
11
15
|
def get(app: Any) -> BaseTransport:
|
12
16
|
"""Get transport to send the data to the application."""
|
@@ -23,41 +27,43 @@ def get(app: Any) -> BaseTransport:
|
|
23
27
|
return WSGI_TRANSPORT
|
24
28
|
|
25
29
|
|
26
|
-
C = TypeVar("C", contravariant=True)
|
27
|
-
R = TypeVar("R", covariant=True)
|
28
30
|
S = TypeVar("S", contravariant=True)
|
29
31
|
|
30
32
|
|
31
33
|
@dataclass
|
32
|
-
class SerializationContext
|
33
|
-
"""
|
34
|
+
class SerializationContext:
|
35
|
+
"""Context object passed to serializer functions.
|
36
|
+
|
37
|
+
It provides access to the generated test case and any related metadata.
|
38
|
+
"""
|
34
39
|
|
35
|
-
case:
|
40
|
+
case: Case
|
41
|
+
"""The generated test case."""
|
36
42
|
|
37
43
|
__slots__ = ("case",)
|
38
44
|
|
39
45
|
|
40
|
-
Serializer = Callable[[SerializationContext
|
46
|
+
Serializer = Callable[[SerializationContext, Any], Any]
|
41
47
|
|
42
48
|
|
43
|
-
class BaseTransport(Generic[
|
49
|
+
class BaseTransport(Generic[S]):
|
44
50
|
"""Base implementation with serializer registration."""
|
45
51
|
|
46
52
|
def __init__(self) -> None:
|
47
|
-
self._serializers: dict[str, Serializer
|
53
|
+
self._serializers: dict[str, Serializer] = {}
|
48
54
|
|
49
|
-
def serialize_case(self, case:
|
55
|
+
def serialize_case(self, case: Case, **kwargs: Any) -> dict[str, Any]:
|
50
56
|
"""Prepare the case for sending."""
|
51
57
|
raise NotImplementedError
|
52
58
|
|
53
|
-
def send(self, case:
|
59
|
+
def send(self, case: Case, *, session: S | None = None, **kwargs: Any) -> Response:
|
54
60
|
"""Send the case using this transport."""
|
55
61
|
raise NotImplementedError
|
56
62
|
|
57
|
-
def serializer(self, *media_types: str) -> Callable[[Serializer
|
63
|
+
def serializer(self, *media_types: str) -> Callable[[Serializer], Serializer]:
|
58
64
|
"""Register a serializer for given media types."""
|
59
65
|
|
60
|
-
def decorator(func: Serializer
|
66
|
+
def decorator(func: Serializer) -> Serializer:
|
61
67
|
for media_type in media_types:
|
62
68
|
self._serializers[media_type] = func
|
63
69
|
return func
|
@@ -71,10 +77,10 @@ class BaseTransport(Generic[C, R, S]):
|
|
71
77
|
def _copy_serializers_from(self, transport: BaseTransport) -> None:
|
72
78
|
self._serializers.update(transport._serializers)
|
73
79
|
|
74
|
-
def get_first_matching_media_type(self, media_type: str) -> tuple[str, Serializer
|
80
|
+
def get_first_matching_media_type(self, media_type: str) -> tuple[str, Serializer] | None:
|
75
81
|
return next(self.get_matching_media_types(media_type), None)
|
76
82
|
|
77
|
-
def get_matching_media_types(self, media_type: str) -> Iterator[tuple[str, Serializer
|
83
|
+
def get_matching_media_types(self, media_type: str) -> Iterator[tuple[str, Serializer]]:
|
78
84
|
"""Get all registered media types matching the given media type."""
|
79
85
|
if media_type == "*/*":
|
80
86
|
# Shortcut to avoid comparing all values
|
@@ -96,9 +102,41 @@ class BaseTransport(Generic[C, R, S]):
|
|
96
102
|
if main in ("*", target_main) and sub in ("*", target_sub):
|
97
103
|
yield registered_media_type, serializer
|
98
104
|
|
99
|
-
def _get_serializer(self, input_media_type: str) -> Serializer
|
105
|
+
def _get_serializer(self, input_media_type: str) -> Serializer:
|
100
106
|
pair = self.get_first_matching_media_type(input_media_type)
|
101
107
|
if pair is None:
|
102
108
|
# This media type is set manually. Otherwise, it should have been rejected during the data generation
|
103
109
|
raise SerializationNotPossible.for_media_type(input_media_type)
|
104
110
|
return pair[1]
|
111
|
+
|
112
|
+
|
113
|
+
_Serializer = Callable[[SerializationContext, Any], Union[bytes, None]]
|
114
|
+
|
115
|
+
|
116
|
+
def serializer(*media_types: str) -> Callable[[_Serializer], None]:
|
117
|
+
"""Register a serializer for specified media types on HTTP, ASGI, and WSGI transports.
|
118
|
+
|
119
|
+
Args:
|
120
|
+
*media_types: One or more MIME types (e.g., "application/json") this serializer handles.
|
121
|
+
|
122
|
+
Returns:
|
123
|
+
A decorator that wraps a function taking `(ctx: SerializationContext, value: Any)` and returning `bytes` for serialized body and `None` for omitting request body.
|
124
|
+
|
125
|
+
"""
|
126
|
+
|
127
|
+
def register(func: _Serializer) -> None:
|
128
|
+
from schemathesis.transport.asgi import ASGI_TRANSPORT
|
129
|
+
from schemathesis.transport.requests import REQUESTS_TRANSPORT
|
130
|
+
from schemathesis.transport.wsgi import WSGI_TRANSPORT
|
131
|
+
|
132
|
+
@ASGI_TRANSPORT.serializer(*media_types)
|
133
|
+
@REQUESTS_TRANSPORT.serializer(*media_types)
|
134
|
+
@WSGI_TRANSPORT.serializer(*media_types)
|
135
|
+
def inner(ctx: SerializationContext, value: Any) -> dict[str, bytes]:
|
136
|
+
result = {}
|
137
|
+
serialized = func(ctx, value)
|
138
|
+
if serialized is not None:
|
139
|
+
result["data"] = serialized
|
140
|
+
return result
|
141
|
+
|
142
|
+
return register
|
@@ -1,5 +1,6 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
+
from functools import lru_cache
|
3
4
|
from typing import TYPE_CHECKING, Any, Mapping, cast
|
4
5
|
from urllib.parse import quote, unquote, urljoin, urlsplit, urlunsplit
|
5
6
|
|
@@ -8,6 +9,7 @@ from schemathesis.core import SCHEMATHESIS_TEST_CASE_HEADER, NotSet
|
|
8
9
|
from schemathesis.core.errors import InvalidSchema
|
9
10
|
from schemathesis.core.output.sanitization import sanitize_url, sanitize_value
|
10
11
|
from schemathesis.core.transport import USER_AGENT
|
12
|
+
from schemathesis.generation.meta import CoveragePhaseData
|
11
13
|
|
12
14
|
if TYPE_CHECKING:
|
13
15
|
from requests import PreparedRequest
|
@@ -16,15 +18,37 @@ if TYPE_CHECKING:
|
|
16
18
|
from schemathesis.generation.case import Case
|
17
19
|
|
18
20
|
|
19
|
-
|
20
|
-
|
21
|
+
@lru_cache()
|
22
|
+
def get_default_headers() -> CaseInsensitiveDict:
|
23
|
+
from requests.utils import default_headers
|
24
|
+
|
25
|
+
headers = default_headers()
|
26
|
+
headers["User-Agent"] = USER_AGENT
|
27
|
+
return headers
|
21
28
|
|
22
|
-
|
29
|
+
|
30
|
+
def prepare_headers(case: Case, headers: dict[str, str] | None = None) -> CaseInsensitiveDict:
|
31
|
+
default_headers = get_default_headers().copy()
|
32
|
+
if case.headers:
|
33
|
+
default_headers.update(case.headers)
|
34
|
+
default_headers.setdefault(SCHEMATHESIS_TEST_CASE_HEADER, case.id)
|
23
35
|
if headers:
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
return
|
36
|
+
default_headers.update(headers)
|
37
|
+
for header in get_exclude_headers(case):
|
38
|
+
default_headers.pop(header, None)
|
39
|
+
return default_headers
|
40
|
+
|
41
|
+
|
42
|
+
def get_exclude_headers(case: Case) -> list[str]:
|
43
|
+
if (
|
44
|
+
case.meta is not None
|
45
|
+
and isinstance(case.meta.phase.data, CoveragePhaseData)
|
46
|
+
and case.meta.phase.data.description.startswith("Missing")
|
47
|
+
and case.meta.phase.data.description.endswith("at header")
|
48
|
+
and case.meta.phase.data.parameter is not None
|
49
|
+
):
|
50
|
+
return [case.meta.phase.data.parameter]
|
51
|
+
return []
|
28
52
|
|
29
53
|
|
30
54
|
def prepare_url(case: Case, base_url: str | None) -> str:
|
@@ -11,6 +11,7 @@ from schemathesis.core import NotSet
|
|
11
11
|
from schemathesis.core.rate_limit import ratelimit
|
12
12
|
from schemathesis.core.transforms import deepclone, merge_at
|
13
13
|
from schemathesis.core.transport import DEFAULT_RESPONSE_TIMEOUT, Response
|
14
|
+
from schemathesis.generation.overrides import Override
|
14
15
|
from schemathesis.transport import BaseTransport, SerializationContext
|
15
16
|
from schemathesis.transport.prepare import prepare_body, prepare_headers, prepare_url
|
16
17
|
from schemathesis.transport.serialization import Binary, serialize_binary, serialize_json, serialize_xml, serialize_yaml
|
@@ -21,7 +22,7 @@ if TYPE_CHECKING:
|
|
21
22
|
from schemathesis.generation.case import Case
|
22
23
|
|
23
24
|
|
24
|
-
class RequestsTransport(BaseTransport["
|
25
|
+
class RequestsTransport(BaseTransport["requests.Session"]):
|
25
26
|
def serialize_case(self, case: Case, **kwargs: Any) -> dict[str, Any]:
|
26
27
|
base_url = kwargs.get("base_url")
|
27
28
|
headers = kwargs.get("headers")
|
@@ -92,6 +93,7 @@ class RequestsTransport(BaseTransport["Case", Response, "requests.Session"]):
|
|
92
93
|
if session is None:
|
93
94
|
validate_vanilla_requests_kwargs(data)
|
94
95
|
session = requests.Session()
|
96
|
+
session.headers = {}
|
95
97
|
close_session = True
|
96
98
|
else:
|
97
99
|
close_session = False
|
@@ -103,7 +105,17 @@ class RequestsTransport(BaseTransport["Case", Response, "requests.Session"]):
|
|
103
105
|
rate_limit = config.rate_limit_for(operation=case.operation)
|
104
106
|
with ratelimit(rate_limit, config.base_url):
|
105
107
|
response = session.request(**data) # type: ignore
|
106
|
-
return Response.from_requests(
|
108
|
+
return Response.from_requests(
|
109
|
+
response,
|
110
|
+
verify=verify,
|
111
|
+
_override=Override(
|
112
|
+
query=kwargs.get("params") or {},
|
113
|
+
headers=kwargs.get("headers") or {},
|
114
|
+
cookies=kwargs.get("cookies") or {},
|
115
|
+
path_parameters={},
|
116
|
+
),
|
117
|
+
)
|
118
|
+
|
107
119
|
finally:
|
108
120
|
if close_session:
|
109
121
|
session.close()
|
@@ -135,14 +147,14 @@ REQUESTS_TRANSPORT = RequestsTransport()
|
|
135
147
|
|
136
148
|
|
137
149
|
@REQUESTS_TRANSPORT.serializer("application/json", "text/json")
|
138
|
-
def json_serializer(ctx: SerializationContext
|
150
|
+
def json_serializer(ctx: SerializationContext, value: Any) -> dict[str, Any]:
|
139
151
|
return serialize_json(value)
|
140
152
|
|
141
153
|
|
142
154
|
@REQUESTS_TRANSPORT.serializer(
|
143
155
|
"text/yaml", "text/x-yaml", "text/vnd.yaml", "text/yml", "application/yaml", "application/x-yaml"
|
144
156
|
)
|
145
|
-
def yaml_serializer(ctx: SerializationContext
|
157
|
+
def yaml_serializer(ctx: SerializationContext, value: Any) -> dict[str, Any]:
|
146
158
|
return serialize_yaml(value)
|
147
159
|
|
148
160
|
|
@@ -188,7 +200,7 @@ def _encode_multipart(value: Any, boundary: str) -> bytes:
|
|
188
200
|
|
189
201
|
|
190
202
|
@REQUESTS_TRANSPORT.serializer("multipart/form-data", "multipart/mixed")
|
191
|
-
def multipart_serializer(ctx: SerializationContext
|
203
|
+
def multipart_serializer(ctx: SerializationContext, value: Any) -> dict[str, Any]:
|
192
204
|
if isinstance(value, bytes):
|
193
205
|
return {"data": value}
|
194
206
|
if isinstance(value, dict):
|
@@ -204,7 +216,7 @@ def multipart_serializer(ctx: SerializationContext[Case], value: Any) -> dict[st
|
|
204
216
|
|
205
217
|
|
206
218
|
@REQUESTS_TRANSPORT.serializer("application/xml", "text/xml")
|
207
|
-
def xml_serializer(ctx: SerializationContext
|
219
|
+
def xml_serializer(ctx: SerializationContext, value: Any) -> dict[str, Any]:
|
208
220
|
media_type = ctx.case.media_type
|
209
221
|
|
210
222
|
assert media_type is not None
|
@@ -216,17 +228,17 @@ def xml_serializer(ctx: SerializationContext[Case], value: Any) -> dict[str, Any
|
|
216
228
|
|
217
229
|
|
218
230
|
@REQUESTS_TRANSPORT.serializer("application/x-www-form-urlencoded")
|
219
|
-
def urlencoded_serializer(ctx: SerializationContext
|
231
|
+
def urlencoded_serializer(ctx: SerializationContext, value: Any) -> dict[str, Any]:
|
220
232
|
return {"data": value}
|
221
233
|
|
222
234
|
|
223
235
|
@REQUESTS_TRANSPORT.serializer("text/plain")
|
224
|
-
def text_serializer(ctx: SerializationContext
|
236
|
+
def text_serializer(ctx: SerializationContext, value: Any) -> dict[str, Any]:
|
225
237
|
if isinstance(value, bytes):
|
226
238
|
return {"data": value}
|
227
239
|
return {"data": str(value).encode("utf8")}
|
228
240
|
|
229
241
|
|
230
242
|
@REQUESTS_TRANSPORT.serializer("application/octet-stream")
|
231
|
-
def binary_serializer(ctx: SerializationContext
|
243
|
+
def binary_serializer(ctx: SerializationContext, value: Any) -> dict[str, Any]:
|
232
244
|
return {"data": serialize_binary(value)}
|
@@ -82,10 +82,6 @@ def serialize_xml(
|
|
82
82
|
"""Serialize a generated Python object as an XML string.
|
83
83
|
|
84
84
|
Schemas may contain additional information for fine-tuned XML serialization.
|
85
|
-
|
86
|
-
:param value: Generated value
|
87
|
-
:param raw_schema: The payload definition with not resolved references.
|
88
|
-
:param resolved_schema: The payload definition with all references resolved.
|
89
85
|
"""
|
90
86
|
if isinstance(value, (bytes, str)):
|
91
87
|
return {"data": value}
|