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.
Files changed (73) hide show
  1. schemathesis/__init__.py +35 -27
  2. schemathesis/auths.py +85 -54
  3. schemathesis/checks.py +65 -36
  4. schemathesis/cli/commands/run/__init__.py +32 -27
  5. schemathesis/cli/commands/run/context.py +6 -1
  6. schemathesis/cli/commands/run/events.py +7 -1
  7. schemathesis/cli/commands/run/executor.py +12 -7
  8. schemathesis/cli/commands/run/handlers/output.py +188 -80
  9. schemathesis/cli/commands/run/validation.py +21 -6
  10. schemathesis/cli/constants.py +1 -1
  11. schemathesis/config/__init__.py +2 -1
  12. schemathesis/config/_generation.py +12 -13
  13. schemathesis/config/_operations.py +14 -0
  14. schemathesis/config/_phases.py +41 -5
  15. schemathesis/config/_projects.py +33 -1
  16. schemathesis/config/_report.py +6 -2
  17. schemathesis/config/_warnings.py +25 -0
  18. schemathesis/config/schema.json +49 -1
  19. schemathesis/core/errors.py +15 -19
  20. schemathesis/core/transport.py +117 -2
  21. schemathesis/engine/context.py +1 -0
  22. schemathesis/engine/errors.py +61 -2
  23. schemathesis/engine/events.py +10 -2
  24. schemathesis/engine/phases/probes.py +3 -0
  25. schemathesis/engine/phases/stateful/__init__.py +2 -1
  26. schemathesis/engine/phases/stateful/_executor.py +38 -5
  27. schemathesis/engine/phases/stateful/context.py +2 -2
  28. schemathesis/engine/phases/unit/_executor.py +36 -7
  29. schemathesis/generation/__init__.py +0 -3
  30. schemathesis/generation/case.py +153 -28
  31. schemathesis/generation/coverage.py +1 -1
  32. schemathesis/generation/hypothesis/builder.py +43 -19
  33. schemathesis/generation/metrics.py +93 -0
  34. schemathesis/generation/modes.py +0 -8
  35. schemathesis/generation/overrides.py +11 -27
  36. schemathesis/generation/stateful/__init__.py +17 -0
  37. schemathesis/generation/stateful/state_machine.py +32 -108
  38. schemathesis/graphql/loaders.py +152 -8
  39. schemathesis/hooks.py +63 -39
  40. schemathesis/openapi/checks.py +82 -20
  41. schemathesis/openapi/generation/filters.py +9 -2
  42. schemathesis/openapi/loaders.py +134 -8
  43. schemathesis/pytest/lazy.py +4 -31
  44. schemathesis/pytest/loaders.py +24 -0
  45. schemathesis/pytest/plugin.py +38 -6
  46. schemathesis/schemas.py +161 -94
  47. schemathesis/specs/graphql/scalars.py +37 -3
  48. schemathesis/specs/graphql/schemas.py +18 -9
  49. schemathesis/specs/openapi/_hypothesis.py +53 -34
  50. schemathesis/specs/openapi/checks.py +111 -47
  51. schemathesis/specs/openapi/expressions/nodes.py +1 -1
  52. schemathesis/specs/openapi/formats.py +30 -3
  53. schemathesis/specs/openapi/media_types.py +44 -1
  54. schemathesis/specs/openapi/negative/__init__.py +5 -3
  55. schemathesis/specs/openapi/negative/mutations.py +2 -2
  56. schemathesis/specs/openapi/parameters.py +0 -3
  57. schemathesis/specs/openapi/schemas.py +14 -93
  58. schemathesis/specs/openapi/stateful/__init__.py +2 -1
  59. schemathesis/specs/openapi/stateful/links.py +1 -63
  60. schemathesis/transport/__init__.py +54 -16
  61. schemathesis/transport/prepare.py +31 -7
  62. schemathesis/transport/requests.py +21 -9
  63. schemathesis/transport/serialization.py +0 -4
  64. schemathesis/transport/wsgi.py +15 -8
  65. {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/METADATA +45 -87
  66. {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/RECORD +69 -71
  67. schemathesis/contrib/__init__.py +0 -9
  68. schemathesis/contrib/openapi/__init__.py +0 -9
  69. schemathesis/contrib/openapi/fill_missing_examples.py +0 -20
  70. schemathesis/generation/targets.py +0 -69
  71. {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/WHEEL +0 -0
  72. {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/entry_points.txt +0 -0
  73. {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.Draft4Validator:
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 jsonschema.Draft4Validator(cache_key.schema)
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] # Assuming the schema is valid
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
- scope, path_item = self._resolve_path_item(path_item)
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.default(),
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(map(str.upper, self))
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(headers) if headers is not None else headers,
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="Stateful tests")
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 TYPE_CHECKING, Any, Callable, Generator, Literal, Union, cast
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(Generic[C]):
33
- """Generic context for serialization process."""
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: C
40
+ case: Case
41
+ """The generated test case."""
36
42
 
37
43
  __slots__ = ("case",)
38
44
 
39
45
 
40
- Serializer = Callable[[SerializationContext[C], Any], Any]
46
+ Serializer = Callable[[SerializationContext, Any], Any]
41
47
 
42
48
 
43
- class BaseTransport(Generic[C, R, S]):
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[C]] = {}
53
+ self._serializers: dict[str, Serializer] = {}
48
54
 
49
- def serialize_case(self, case: C, **kwargs: Any) -> dict[str, Any]:
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: C, *, session: S | None = None, **kwargs: Any) -> R:
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[C]], Serializer[C]]:
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[C]) -> Serializer[C]:
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[C]] | None:
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[C]]]:
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[C]:
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
- def prepare_headers(case: Case, headers: dict[str, str] | None = None) -> CaseInsensitiveDict:
20
- from requests.structures import CaseInsensitiveDict
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
- final_headers = case.headers.copy() if case.headers is not None else CaseInsensitiveDict()
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
- final_headers.update(headers)
25
- final_headers.setdefault("User-Agent", USER_AGENT)
26
- final_headers.setdefault(SCHEMATHESIS_TEST_CASE_HEADER, case.id)
27
- return final_headers
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["Case", Response, "requests.Session"]):
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(response, verify=verify)
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[Case], value: Any) -> dict[str, Any]:
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[Case], value: Any) -> dict[str, Any]:
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[Case], value: Any) -> dict[str, Any]:
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[Case], value: Any) -> dict[str, Any]:
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[Case], value: Any) -> dict[str, Any]:
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[Case], value: Any) -> dict[str, Any]:
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[Case], value: Any) -> dict[str, Any]:
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}