schemathesis 4.1.4__py3-none-any.whl → 4.2.1__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 (70) hide show
  1. schemathesis/cli/commands/run/executor.py +1 -1
  2. schemathesis/cli/commands/run/handlers/base.py +28 -1
  3. schemathesis/cli/commands/run/handlers/cassettes.py +109 -137
  4. schemathesis/cli/commands/run/handlers/junitxml.py +5 -6
  5. schemathesis/cli/commands/run/handlers/output.py +7 -1
  6. schemathesis/cli/ext/fs.py +1 -1
  7. schemathesis/config/_diff_base.py +3 -1
  8. schemathesis/config/_operations.py +2 -0
  9. schemathesis/config/_phases.py +21 -4
  10. schemathesis/config/_projects.py +10 -2
  11. schemathesis/core/adapter.py +34 -0
  12. schemathesis/core/errors.py +29 -5
  13. schemathesis/core/jsonschema/__init__.py +13 -0
  14. schemathesis/core/jsonschema/bundler.py +163 -0
  15. schemathesis/{specs/openapi/constants.py → core/jsonschema/keywords.py} +0 -8
  16. schemathesis/core/jsonschema/references.py +122 -0
  17. schemathesis/core/jsonschema/types.py +41 -0
  18. schemathesis/core/media_types.py +6 -4
  19. schemathesis/core/parameters.py +37 -0
  20. schemathesis/core/transforms.py +25 -2
  21. schemathesis/core/validation.py +19 -0
  22. schemathesis/engine/context.py +1 -1
  23. schemathesis/engine/errors.py +11 -18
  24. schemathesis/engine/phases/stateful/_executor.py +1 -1
  25. schemathesis/engine/phases/unit/_executor.py +30 -13
  26. schemathesis/errors.py +4 -0
  27. schemathesis/filters.py +2 -2
  28. schemathesis/generation/coverage.py +87 -11
  29. schemathesis/generation/hypothesis/__init__.py +79 -2
  30. schemathesis/generation/hypothesis/builder.py +108 -70
  31. schemathesis/generation/meta.py +5 -14
  32. schemathesis/generation/overrides.py +17 -17
  33. schemathesis/pytest/lazy.py +1 -1
  34. schemathesis/pytest/plugin.py +1 -6
  35. schemathesis/schemas.py +22 -72
  36. schemathesis/specs/graphql/schemas.py +27 -16
  37. schemathesis/specs/openapi/_hypothesis.py +83 -68
  38. schemathesis/specs/openapi/adapter/__init__.py +10 -0
  39. schemathesis/specs/openapi/adapter/parameters.py +504 -0
  40. schemathesis/specs/openapi/adapter/protocol.py +57 -0
  41. schemathesis/specs/openapi/adapter/references.py +19 -0
  42. schemathesis/specs/openapi/adapter/responses.py +329 -0
  43. schemathesis/specs/openapi/adapter/security.py +141 -0
  44. schemathesis/specs/openapi/adapter/v2.py +28 -0
  45. schemathesis/specs/openapi/adapter/v3_0.py +28 -0
  46. schemathesis/specs/openapi/adapter/v3_1.py +28 -0
  47. schemathesis/specs/openapi/checks.py +99 -90
  48. schemathesis/specs/openapi/converter.py +114 -27
  49. schemathesis/specs/openapi/examples.py +210 -168
  50. schemathesis/specs/openapi/negative/__init__.py +12 -7
  51. schemathesis/specs/openapi/negative/mutations.py +68 -40
  52. schemathesis/specs/openapi/references.py +2 -175
  53. schemathesis/specs/openapi/schemas.py +142 -490
  54. schemathesis/specs/openapi/serialization.py +15 -7
  55. schemathesis/specs/openapi/stateful/__init__.py +17 -12
  56. schemathesis/specs/openapi/stateful/inference.py +13 -11
  57. schemathesis/specs/openapi/stateful/links.py +5 -20
  58. schemathesis/specs/openapi/types/__init__.py +3 -0
  59. schemathesis/specs/openapi/types/v3.py +68 -0
  60. schemathesis/specs/openapi/utils.py +1 -13
  61. schemathesis/transport/requests.py +3 -11
  62. schemathesis/transport/serialization.py +63 -27
  63. schemathesis/transport/wsgi.py +1 -8
  64. {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/METADATA +2 -2
  65. {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/RECORD +68 -53
  66. schemathesis/specs/openapi/parameters.py +0 -405
  67. schemathesis/specs/openapi/security.py +0 -162
  68. {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/WHEEL +0 -0
  69. {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/entry_points.txt +0 -0
  70. {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -3,8 +3,8 @@ from __future__ import annotations
3
3
  import json
4
4
  from typing import Any, Callable, Dict, Generator, List
5
5
 
6
+ from schemathesis.core.parameters import LOCATION_TO_CONTAINER
6
7
  from schemathesis.schemas import APIOperation
7
- from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
8
8
 
9
9
  Generated = Dict[str, Any]
10
10
  Definition = Dict[str, Any]
@@ -57,7 +57,11 @@ def _serialize_openapi3(definitions: DefinitionList) -> Generator[Callable | Non
57
57
  # Simple serialization
58
58
  style = definition.get("style")
59
59
  explode = definition.get("explode")
60
- type_ = definition.get("schema", {}).get("type")
60
+ schema = definition.get("schema", {})
61
+ if isinstance(schema, dict):
62
+ type_ = schema.get("type")
63
+ else:
64
+ type_ = None
61
65
  if definition["in"] == "path":
62
66
  yield from _serialize_path_openapi3(name, type_, style, explode)
63
67
  elif definition["in"] == "query":
@@ -69,7 +73,7 @@ def _serialize_openapi3(definitions: DefinitionList) -> Generator[Callable | Non
69
73
 
70
74
 
71
75
  def _serialize_path_openapi3(
72
- name: str, type_: str, style: str | None, explode: bool | None
76
+ name: str, type_: str | None, style: str | None, explode: bool | None
73
77
  ) -> Generator[Callable | None, None, None]:
74
78
  if style == "simple":
75
79
  if type_ == "object":
@@ -96,7 +100,7 @@ def _serialize_path_openapi3(
96
100
 
97
101
 
98
102
  def _serialize_query_openapi3(
99
- name: str, type_: str, style: str | None, explode: bool | None
103
+ name: str, type_: str | None, style: str | None, explode: bool | None
100
104
  ) -> Generator[Callable | None, None, None]:
101
105
  if type_ == "object":
102
106
  if style == "deepObject":
@@ -115,7 +119,9 @@ def _serialize_query_openapi3(
115
119
  yield delimited(name, delimiter=",")
116
120
 
117
121
 
118
- def _serialize_header_openapi3(name: str, type_: str, explode: bool | None) -> Generator[Callable | None, None, None]:
122
+ def _serialize_header_openapi3(
123
+ name: str, type_: str | None, explode: bool | None
124
+ ) -> Generator[Callable | None, None, None]:
119
125
  # Headers should be coerced to a string so we can check it for validity later
120
126
  yield to_string(name)
121
127
  # Header parameters always use the "simple" style, that is, comma-separated values
@@ -128,7 +134,9 @@ def _serialize_header_openapi3(name: str, type_: str, explode: bool | None) -> G
128
134
  yield delimited_object(name)
129
135
 
130
136
 
131
- def _serialize_cookie_openapi3(name: str, type_: str, explode: bool | None) -> Generator[Callable | None, None, None]:
137
+ def _serialize_cookie_openapi3(
138
+ name: str, type_: str | None, explode: bool | None
139
+ ) -> Generator[Callable | None, None, None]:
132
140
  # Cookies should be coerced to a string so we can check it for validity later
133
141
  yield to_string(name)
134
142
  # Cookie parameters always use the "form" style
@@ -213,7 +221,7 @@ def to_json(item: Generated, name: str) -> None:
213
221
 
214
222
  @conversion
215
223
  def delimited(item: Generated, name: str, delimiter: str) -> None:
216
- item[name] = delimiter.join(map(str, force_iterable(item[name] or ())))
224
+ item[name] = delimiter.join(map(str, force_iterable(item[name] if item[name] is not None else ())))
217
225
 
218
226
 
219
227
  @conversion
@@ -8,19 +8,20 @@ import jsonschema
8
8
  from hypothesis import strategies as st
9
9
  from hypothesis.stateful import Bundle, Rule, precondition, rule
10
10
 
11
- from schemathesis.core.errors import InvalidStateMachine
11
+ from schemathesis.core.errors import InvalidStateMachine, InvalidTransition
12
+ from schemathesis.core.parameters import ParameterLocation
12
13
  from schemathesis.core.result import Ok
13
14
  from schemathesis.core.transforms import UNRESOLVABLE
14
15
  from schemathesis.engine.recorder import ScenarioRecorder
15
16
  from schemathesis.generation import GenerationMode
16
17
  from schemathesis.generation.case import Case
17
18
  from schemathesis.generation.hypothesis import strategies
18
- from schemathesis.generation.meta import ComponentInfo, ComponentKind, TestPhase
19
+ from schemathesis.generation.meta import ComponentInfo, TestPhase
19
20
  from schemathesis.generation.stateful import STATEFUL_TESTS_LABEL
20
21
  from schemathesis.generation.stateful.state_machine import APIStateMachine, StepInput, StepOutput, _normalize_name
21
22
  from schemathesis.schemas import APIOperation
22
23
  from schemathesis.specs.openapi.stateful.control import TransitionController
23
- from schemathesis.specs.openapi.stateful.links import OpenApiLink, get_all_links
24
+ from schemathesis.specs.openapi.stateful.links import OpenApiLink
24
25
  from schemathesis.specs.openapi.utils import expand_status_code
25
26
 
26
27
  if TYPE_CHECKING:
@@ -97,12 +98,14 @@ def collect_transitions(operations: list[APIOperation]) -> ApiTransitions:
97
98
  selected_labels = {operation.label for operation in operations}
98
99
  errors = []
99
100
  for operation in operations:
100
- for _, link in get_all_links(operation):
101
- if isinstance(link, Ok):
102
- if link.ok().target.label in selected_labels:
103
- transitions.add_outgoing(operation.label, link.ok())
104
- else:
105
- errors.append(link.err())
101
+ for status_code, response in operation.responses.items():
102
+ for name, link in response.iter_links():
103
+ try:
104
+ link = OpenApiLink(name, status_code, link, operation)
105
+ if link.target.label in selected_labels:
106
+ transitions.add_outgoing(operation.label, link)
107
+ except InvalidTransition as exc:
108
+ errors.append(exc)
106
109
 
107
110
  if errors:
108
111
  raise InvalidStateMachine(errors)
@@ -118,7 +121,7 @@ def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
118
121
 
119
122
  # Create bundles and matchers
120
123
  for operation in operations:
121
- all_status_codes = tuple(operation.definition.raw["responses"])
124
+ all_status_codes = operation.responses.status_codes
122
125
  bundle_matchers = []
123
126
 
124
127
  if operation.label in transitions.operations:
@@ -287,9 +290,11 @@ def into_step_input(
287
290
  # It is possible that the new body is now valid and the whole test case could be valid too
288
291
  for alternative in case.operation.body:
289
292
  if alternative.media_type == case.media_type:
290
- schema = alternative.as_json_schema(case.operation)
293
+ schema = alternative.optimized_schema
291
294
  if jsonschema.validators.validator_for(schema)(schema).is_valid(new):
292
- case.meta.components[ComponentKind.BODY] = ComponentInfo(mode=GenerationMode.POSITIVE)
295
+ case.meta.components[ParameterLocation.BODY] = ComponentInfo(
296
+ mode=GenerationMode.POSITIVE
297
+ )
293
298
  if all(info.mode == GenerationMode.POSITIVE for info in case.meta.components.values()):
294
299
  case.meta.generation.mode = GenerationMode.POSITIVE
295
300
  return StepInput(case=case, transition=transition)
@@ -20,6 +20,8 @@ from urllib.parse import urlsplit
20
20
  from werkzeug.exceptions import MethodNotAllowed, NotFound
21
21
  from werkzeug.routing import Map, MapAdapter, Rule
22
22
 
23
+ from schemathesis.core.adapter import ResponsesContainer
24
+
23
25
  if TYPE_CHECKING:
24
26
  from schemathesis.engine.observations import LocationHeaderEntry
25
27
  from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
@@ -78,9 +80,9 @@ class LinkInferencer:
78
80
  _operations: list[OperationReference]
79
81
  _base_url: str | None
80
82
  _base_path: str
81
- _links_field_name: str
83
+ _links_keyword: str
82
84
 
83
- __slots__ = ("_adapter", "_operations", "_base_url", "_base_path", "_links_field_name")
85
+ __slots__ = ("_adapter", "_operations", "_base_url", "_base_path", "_links_keyword")
84
86
 
85
87
  @classmethod
86
88
  def from_schema(cls, schema: BaseOpenAPISchema) -> LinkInferencer:
@@ -107,7 +109,7 @@ class LinkInferencer:
107
109
  _operations=operations,
108
110
  _base_url=schema.config.base_url,
109
111
  _base_path=schema.base_path,
110
- _links_field_name=schema.links_field,
112
+ _links_keyword=schema.adapter.links_keyword,
111
113
  )
112
114
 
113
115
  def match(self, path: str) -> tuple[OperationReference, Mapping[str, str]] | None:
@@ -214,10 +216,7 @@ class LinkInferencer:
214
216
  relative_path = path[len(base_path) :]
215
217
  return relative_path if relative_path.startswith("/") else "/" + relative_path
216
218
 
217
- def inject_links(self, operation: dict[str, Any], entries: list[LocationHeaderEntry]) -> int:
218
- from schemathesis.specs.openapi.schemas import _get_response_definition_by_status
219
-
220
- responses = operation.setdefault("responses", {})
219
+ def inject_links(self, responses: ResponsesContainer, entries: list[LocationHeaderEntry]) -> int:
221
220
  # To avoid unnecessary work, we need to skip entries that we know will produce already inferred links
222
221
  seen: set[SeenLinkKey] = set()
223
222
  injected = 0
@@ -239,10 +238,13 @@ class LinkInferencer:
239
238
  continue
240
239
  seen.add(key)
241
240
  # Find the right bucket for the response status or create a new one
242
- definition = _get_response_definition_by_status(entry.status_code, responses)
243
- if definition is None:
244
- definition = responses.setdefault(str(entry.status_code), {})
245
- links = definition.setdefault(self._links_field_name, {})
241
+ response = responses.find_by_status_code(entry.status_code)
242
+ links: dict[str, dict[str, dict]]
243
+ if response is None:
244
+ links = {}
245
+ responses.add(str(entry.status_code), {self._links_keyword: links})
246
+ else:
247
+ links = response.definition.setdefault(self._links_keyword, {})
246
248
 
247
249
  for idx, link in enumerate(self._build_links_from_matches(matches)):
248
250
  links[f"X-Inferred-Link-{idx}"] = link
@@ -2,19 +2,17 @@ from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
4
  from functools import lru_cache
5
- from typing import Any, Callable, Generator, Literal, cast
5
+ from typing import Any, Callable
6
6
 
7
7
  from schemathesis.core import NOT_SET, NotSet
8
8
  from schemathesis.core.errors import InvalidTransition, OperationNotFound, TransitionValidationError
9
+ from schemathesis.core.parameters import ParameterLocation
9
10
  from schemathesis.core.result import Err, Ok, Result
10
11
  from schemathesis.generation.stateful.state_machine import ExtractedParam, StepOutput, Transition
11
12
  from schemathesis.schemas import APIOperation
12
13
  from schemathesis.specs.openapi import expressions
13
- from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
14
- from schemathesis.specs.openapi.references import RECURSION_DEPTH_LIMIT
15
14
 
16
15
  SCHEMATHESIS_LINK_EXTENSION = "x-schemathesis"
17
- ParameterLocation = Literal["path", "query", "header", "cookie", "body"]
18
16
 
19
17
 
20
18
  @dataclass
@@ -96,7 +94,7 @@ class OpenApiLink:
96
94
  try:
97
95
  # The parameter name is prefixed with its location. Example: `path.id`
98
96
  _location, name = tuple(parameter.split("."))
99
- location = cast(ParameterLocation, _location)
97
+ location = ParameterLocation(_location)
100
98
  except ValueError:
101
99
  location = None
102
100
  name = parameter
@@ -135,11 +133,11 @@ class OpenApiLink:
135
133
  def _get_parameter_container(self, location: ParameterLocation | None, name: str) -> str:
136
134
  """Resolve parameter container either from explicit location or by looking up in target operation."""
137
135
  if location:
138
- return LOCATION_TO_CONTAINER[location]
136
+ return location.container_name
139
137
 
140
138
  for param in self.target.iter_parameters():
141
139
  if param.name == name:
142
- return LOCATION_TO_CONTAINER[param.location]
140
+ return param.location.container_name
143
141
  raise TransitionValidationError(f"Parameter `{name}` is not defined in API operation `{self.target.label}`")
144
142
 
145
143
  def extract(self, output: StepOutput) -> Transition:
@@ -195,16 +193,3 @@ class StepOutputWrapper:
195
193
  def __eq__(self, other: object) -> bool:
196
194
  assert isinstance(other, StepOutputWrapper)
197
195
  return self.output.case.id == other.output.case.id
198
-
199
-
200
- def get_all_links(
201
- operation: APIOperation,
202
- ) -> Generator[tuple[str, Result[OpenApiLink, InvalidTransition]], None, None]:
203
- for status_code, definition in operation.definition.raw["responses"].items():
204
- definition = operation.schema.resolver.resolve_all(definition, RECURSION_DEPTH_LIMIT - 8) # type: ignore[attr-defined]
205
- for name, link_definition in definition.get(operation.schema.links_field, {}).items(): # type: ignore
206
- try:
207
- link = OpenApiLink(name, status_code, link_definition, operation)
208
- yield status_code, Ok(link)
209
- except InvalidTransition as exc:
210
- yield status_code, Err(exc)
@@ -0,0 +1,3 @@
1
+ from schemathesis.specs.openapi.types import v3
2
+
3
+ __all__ = ["v3"]
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Mapping, TypedDict, Union
4
+
5
+ from typing_extensions import NotRequired
6
+
7
+ Reference = TypedDict("Reference", {"$ref": str})
8
+
9
+
10
+ class Operation(TypedDict):
11
+ responses: Responses
12
+ requestBody: NotRequired[RequestBodyOrRef]
13
+
14
+
15
+ class RequestBody(TypedDict):
16
+ content: dict[str, MediaType]
17
+ required: NotRequired[bool]
18
+
19
+
20
+ class MediaType(TypedDict):
21
+ schema: Schema
22
+ example: Any
23
+
24
+
25
+ class Example(TypedDict):
26
+ value: NotRequired[Any]
27
+ externalValue: NotRequired[str]
28
+
29
+
30
+ class Link(TypedDict):
31
+ operationId: NotRequired[str]
32
+ operationRef: NotRequired[str]
33
+ parameters: NotRequired[dict[str, Any]]
34
+ requestBody: NotRequired[Any]
35
+ server: NotRequired[Any]
36
+
37
+
38
+ class Response(TypedDict):
39
+ headers: NotRequired[dict[str, HeaderOrRef]]
40
+ content: NotRequired[dict[str, MediaType]]
41
+ links: NotRequired[dict[str, LinkOrRef]]
42
+
43
+
44
+ _ResponsesBase = Mapping[str, Union[Response, Reference]]
45
+
46
+
47
+ class Responses(_ResponsesBase):
48
+ pass
49
+
50
+
51
+ class Header(TypedDict):
52
+ required: NotRequired[bool]
53
+
54
+
55
+ _HeadersBase = Mapping[str, Union[Header, Reference]]
56
+
57
+
58
+ class Headers(_HeadersBase):
59
+ pass
60
+
61
+
62
+ SchemaObject = TypedDict("SchemaObject", {"$ref": str})
63
+ Schema = Union[SchemaObject, bool]
64
+ RequestBodyOrRef = Union[RequestBody, Reference]
65
+ ExampleOrRef = Union[Example, Reference]
66
+ HeaderOrRef = Union[Header, Reference]
67
+ LinkOrRef = Union[Link, Reference]
68
+ ResponseOrRef = Union[Response, Reference]
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import string
4
4
  from itertools import chain, product
5
- from typing import Any, Generator
5
+ from typing import Generator
6
6
 
7
7
 
8
8
  def expand_status_code(status_code: str | int) -> Generator[int, None, None]:
@@ -13,15 +13,3 @@ def expand_status_code(status_code: str | int) -> Generator[int, None, None]:
13
13
 
14
14
  def expand_status_codes(status_codes: list[str]) -> set[int]:
15
15
  return set(chain.from_iterable(expand_status_code(code) for code in status_codes))
16
-
17
-
18
- def is_header_location(location: str) -> bool:
19
- """Whether this location affects HTTP headers."""
20
- return location in ("header", "cookie")
21
-
22
-
23
- def get_type(schema: dict[str, Any]) -> list[str]:
24
- type_ = schema.get("type", ["null", "boolean", "integer", "number", "string", "array", "object"])
25
- if isinstance(type_, str):
26
- return [type_]
27
- return type_
@@ -9,7 +9,7 @@ from urllib.parse import urlparse
9
9
 
10
10
  from schemathesis.core import NotSet
11
11
  from schemathesis.core.rate_limit import ratelimit
12
- from schemathesis.core.transforms import deepclone, merge_at
12
+ from schemathesis.core.transforms import merge_at
13
13
  from schemathesis.core.transport import DEFAULT_RESPONSE_TIMEOUT, Response
14
14
  from schemathesis.generation.overrides import Override
15
15
  from schemathesis.transport import BaseTransport, SerializationContext
@@ -61,7 +61,7 @@ class RequestsTransport(BaseTransport["requests.Session"]):
61
61
 
62
62
  # Replace empty dictionaries with empty strings, so the parameters actually present in the query string
63
63
  if any(value == {} for value in (params or {}).values()):
64
- params = deepclone(params)
64
+ params = dict(params)
65
65
  for key, value in params.items():
66
66
  if value == {}:
67
67
  params[key] = ""
@@ -243,7 +243,6 @@ def multipart_serializer(ctx: SerializationContext, value: Any) -> dict[str, Any
243
243
  if isinstance(value, bytes):
244
244
  return {"data": value}
245
245
  if isinstance(value, dict):
246
- value = deepclone(value)
247
246
  multipart = _prepare_form_data(value)
248
247
  files, data = ctx.case.operation.prepare_multipart(multipart)
249
248
  return {"files": files, "data": data}
@@ -256,14 +255,7 @@ def multipart_serializer(ctx: SerializationContext, value: Any) -> dict[str, Any
256
255
 
257
256
  @REQUESTS_TRANSPORT.serializer("application/xml", "text/xml")
258
257
  def xml_serializer(ctx: SerializationContext, value: Any) -> dict[str, Any]:
259
- media_type = ctx.case.media_type
260
-
261
- assert media_type is not None
262
-
263
- raw_schema = ctx.case.operation.get_raw_payload_schema(media_type)
264
- resolved_schema = ctx.case.operation.get_resolved_payload_schema(media_type)
265
-
266
- return serialize_xml(value, raw_schema, resolved_schema)
258
+ return serialize_xml(ctx.case, value)
267
259
 
268
260
 
269
261
  @REQUESTS_TRANSPORT.serializer("application/x-www-form-urlencoded")
@@ -3,11 +3,15 @@ from __future__ import annotations
3
3
  import re
4
4
  from dataclasses import dataclass
5
5
  from io import StringIO
6
- from typing import Any, Dict, List, Union
6
+ from typing import TYPE_CHECKING, Any, Dict, List, Union
7
7
  from unicodedata import normalize
8
8
 
9
9
  from schemathesis.core.errors import UnboundPrefix
10
- from schemathesis.core.transforms import deepclone, transform
10
+ from schemathesis.core.transforms import transform
11
+
12
+ if TYPE_CHECKING:
13
+ from schemathesis.core.compat import RefResolver
14
+ from schemathesis.generation.case import Case
11
15
 
12
16
 
13
17
  @dataclass
@@ -76,44 +80,67 @@ DEFAULT_TAG_NAME = "data"
76
80
  NAMESPACE_URL = "http://example.com/schema"
77
81
 
78
82
 
79
- def serialize_xml(
80
- value: Any, raw_schema: dict[str, Any] | None, resolved_schema: dict[str, Any] | None
81
- ) -> dict[str, Any]:
83
+ def serialize_xml(case: Case, value: Any) -> dict[str, Any]:
84
+ media_type = case.media_type
85
+
86
+ assert media_type is not None
87
+
88
+ schema = None
89
+ resource_name = None
90
+
91
+ for body in case.operation.get_bodies_for_media_type(media_type):
92
+ schema = body.optimized_schema
93
+ resource_name = body.resource_name
94
+ break
95
+ assert schema is not None, (case.operation.body, media_type)
96
+
97
+ return _serialize_xml(value, schema, resource_name=resource_name)
98
+
99
+
100
+ def _serialize_xml(value: Any, schema: dict[str, Any], resource_name: str | None) -> dict[str, Any]:
82
101
  """Serialize a generated Python object as an XML string.
83
102
 
84
103
  Schemas may contain additional information for fine-tuned XML serialization.
85
104
  """
105
+ from schemathesis.core.compat import RefResolver
106
+
86
107
  if isinstance(value, (bytes, str)):
87
108
  return {"data": value}
88
- tag = _get_xml_tag(raw_schema, resolved_schema)
109
+ resolver = RefResolver.from_schema(schema)
110
+ if "$ref" in schema:
111
+ _, schema = resolver.resolve(schema["$ref"])
112
+ tag = _get_xml_tag(schema, resource_name)
89
113
  buffer = StringIO()
90
114
  # Collect all namespaces to ensure that all child nodes with prefixes have proper namespaces in their parent nodes
91
115
  namespace_stack: list[str] = []
92
- _write_xml(buffer, value, tag, resolved_schema, namespace_stack)
116
+ _write_xml(buffer, value, tag, schema, namespace_stack, resolver)
93
117
  data = buffer.getvalue()
94
118
  return {"data": data.encode("utf8")}
95
119
 
96
120
 
97
- def _get_xml_tag(raw_schema: dict[str, Any] | None, resolved_schema: dict[str, Any] | None) -> str:
121
+ def _get_xml_tag(schema: dict[str, Any] | None, resource_name: str | None) -> str:
98
122
  # On the top level we need to detect the proper XML tag, in other cases it is known from object properties
99
- if (resolved_schema or {}).get("xml", {}).get("name"):
100
- return (resolved_schema or {})["xml"]["name"]
101
-
102
- # Check if the name can be derived from a reference in the raw schema
103
- if "$ref" in (raw_schema or {}):
104
- return _get_tag_name_from_reference((raw_schema or {})["$ref"])
123
+ if (schema or {}).get("xml", {}).get("name"):
124
+ return (schema or {})["xml"]["name"]
125
+ if resource_name is not None:
126
+ return resource_name
105
127
 
106
128
  # Here we don't have any name for the payload schema - no reference or the `xml` property
107
129
  return DEFAULT_TAG_NAME
108
130
 
109
131
 
110
132
  def _write_xml(
111
- buffer: StringIO, value: JSON, tag: str, schema: dict[str, Any] | None, namespace_stack: list[str]
133
+ buffer: StringIO,
134
+ value: JSON,
135
+ tag: str,
136
+ schema: dict[str, Any] | None,
137
+ namespace_stack: list[str],
138
+ resolver: RefResolver,
112
139
  ) -> None:
113
140
  if isinstance(value, dict):
114
- _write_object(buffer, value, tag, schema, namespace_stack)
141
+ _write_object(buffer, value, tag, schema, namespace_stack, resolver)
115
142
  elif isinstance(value, list):
116
- _write_array(buffer, value, tag, schema, namespace_stack)
143
+ _write_array(buffer, value, tag, schema, namespace_stack, resolver)
117
144
  else:
118
145
  _write_primitive(buffer, value, tag, schema, namespace_stack)
119
146
 
@@ -138,7 +165,12 @@ def pop_namespace_if_any(namespace_stack: list[str], options: dict[str, Any]) ->
138
165
 
139
166
 
140
167
  def _write_object(
141
- buffer: StringIO, obj: dict[str, JSON], tag: str, schema: dict[str, Any] | None, stack: list[str]
168
+ buffer: StringIO,
169
+ obj: dict[str, JSON],
170
+ tag: str,
171
+ schema: dict[str, Any] | None,
172
+ stack: list[str],
173
+ resolver: RefResolver,
142
174
  ) -> None:
143
175
  options = (schema or {}).get("xml", {})
144
176
  push_namespace_if_any(stack, options)
@@ -155,6 +187,8 @@ def _write_object(
155
187
  properties = (schema or {}).get("properties", {})
156
188
  for child_name, value in obj.items():
157
189
  property_schema = properties.get(child_name, {})
190
+ if "$ref" in property_schema:
191
+ _, property_schema = resolver.resolve(property_schema["$ref"])
158
192
  child_options = property_schema.get("xml", {})
159
193
  push_namespace_if_any(stack, child_options)
160
194
  child_tag = child_options.get("name", child_name)
@@ -178,7 +212,7 @@ def _write_object(
178
212
  _validate_prefix(child_options, stack)
179
213
  prefix = child_options["prefix"]
180
214
  child_tag = f"{prefix}:{child_tag}"
181
- _write_xml(children_buffer, value, child_tag, property_schema, stack)
215
+ _write_xml(children_buffer, value, child_tag, property_schema, stack, resolver)
182
216
  pop_namespace_if_any(stack, child_options)
183
217
 
184
218
  # Write namespace declarations for attributes
@@ -193,7 +227,9 @@ def _write_object(
193
227
  pop_namespace_if_any(stack, options)
194
228
 
195
229
 
196
- def _write_array(buffer: StringIO, obj: list[JSON], tag: str, schema: dict[str, Any] | None, stack: list[str]) -> None:
230
+ def _write_array(
231
+ buffer: StringIO, obj: list[JSON], tag: str, schema: dict[str, Any] | None, stack: list[str], resolver: RefResolver
232
+ ) -> None:
197
233
  options = (schema or {}).get("xml", {})
198
234
  push_namespace_if_any(stack, options)
199
235
  if options.get("prefix"):
@@ -207,7 +243,12 @@ def _write_array(buffer: StringIO, obj: list[JSON], tag: str, schema: dict[str,
207
243
  _write_namespace(buffer, options)
208
244
  buffer.write(">")
209
245
  # In Open API `items` value should be an object and not an array
210
- items = deepclone((schema or {}).get("items", {}))
246
+ if schema:
247
+ items = dict(schema.get("items", {}))
248
+ else:
249
+ items = {}
250
+ if "$ref" in items:
251
+ _, items = resolver.resolve(items["$ref"])
211
252
  child_options = items.get("xml", {})
212
253
  child_tag = child_options.get("name", tag)
213
254
  if not is_namespace_specified and "namespace" in options:
@@ -217,7 +258,7 @@ def _write_array(buffer: StringIO, obj: list[JSON], tag: str, schema: dict[str,
217
258
  items["xml"] = child_options
218
259
  _validate_prefix(child_options, stack)
219
260
  for item in obj:
220
- _write_xml(buffer, item, child_tag, items, stack)
261
+ _write_xml(buffer, item, child_tag, items, stack, resolver)
221
262
  if wrapped:
222
263
  buffer.write(f"</{tag}>")
223
264
  pop_namespace_if_any(stack, options)
@@ -243,11 +284,6 @@ def _write_namespace(buffer: StringIO, options: dict[str, Any]) -> None:
243
284
  buffer.write(f'="{options["namespace"]}"')
244
285
 
245
286
 
246
- def _get_tag_name_from_reference(reference: str) -> str:
247
- """Extract object name from a reference."""
248
- return reference.rsplit("/", maxsplit=1)[1]
249
-
250
-
251
287
  def _escape_xml(value: JSON) -> str:
252
288
  """Escape special characters in XML content."""
253
289
  if isinstance(value, (int, float, bool)):
@@ -154,14 +154,7 @@ def multipart_serializer(ctx: SerializationContext, value: Any) -> dict[str, Any
154
154
 
155
155
  @WSGI_TRANSPORT.serializer("application/xml", "text/xml")
156
156
  def xml_serializer(ctx: SerializationContext, value: Any) -> dict[str, Any]:
157
- media_type = ctx.case.media_type
158
-
159
- assert media_type is not None
160
-
161
- raw_schema = ctx.case.operation.get_raw_payload_schema(media_type)
162
- resolved_schema = ctx.case.operation.get_resolved_payload_schema(media_type)
163
-
164
- return serialize_xml(value, raw_schema, resolved_schema)
157
+ return serialize_xml(ctx.case, value)
165
158
 
166
159
 
167
160
  @WSGI_TRANSPORT.serializer("application/x-www-form-urlencoded")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: schemathesis
3
- Version: 4.1.4
3
+ Version: 4.2.1
4
4
  Summary: Property-based testing framework for Open API and GraphQL based apps
5
5
  Project-URL: Documentation, https://schemathesis.readthedocs.io/en/stable/
6
6
  Project-URL: Changelog, https://github.com/schemathesis/schemathesis/blob/master/CHANGELOG.md
@@ -32,7 +32,7 @@ Requires-Python: >=3.9
32
32
  Requires-Dist: backoff<3.0,>=2.1.2
33
33
  Requires-Dist: click<9,>=8.0
34
34
  Requires-Dist: colorama<1.0,>=0.4
35
- Requires-Dist: harfile<1.0,>=0.3.1
35
+ Requires-Dist: harfile<1.0,>=0.4.0
36
36
  Requires-Dist: httpx<1.0,>=0.22.0
37
37
  Requires-Dist: hypothesis-graphql<1,>=0.11.1
38
38
  Requires-Dist: hypothesis-jsonschema<0.24,>=0.23.1