schemathesis 3.25.5__py3-none-any.whl → 4.0.0a1__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 (221) hide show
  1. schemathesis/__init__.py +27 -65
  2. schemathesis/auths.py +102 -82
  3. schemathesis/checks.py +126 -46
  4. schemathesis/cli/__init__.py +11 -1766
  5. schemathesis/cli/__main__.py +4 -0
  6. schemathesis/cli/commands/__init__.py +37 -0
  7. schemathesis/cli/commands/run/__init__.py +662 -0
  8. schemathesis/cli/commands/run/checks.py +80 -0
  9. schemathesis/cli/commands/run/context.py +117 -0
  10. schemathesis/cli/commands/run/events.py +35 -0
  11. schemathesis/cli/commands/run/executor.py +138 -0
  12. schemathesis/cli/commands/run/filters.py +194 -0
  13. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  14. schemathesis/cli/commands/run/handlers/base.py +18 -0
  15. schemathesis/cli/commands/run/handlers/cassettes.py +494 -0
  16. schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
  17. schemathesis/cli/commands/run/handlers/output.py +746 -0
  18. schemathesis/cli/commands/run/hypothesis.py +105 -0
  19. schemathesis/cli/commands/run/loaders.py +129 -0
  20. schemathesis/cli/{callbacks.py → commands/run/validation.py} +103 -174
  21. schemathesis/cli/constants.py +5 -52
  22. schemathesis/cli/core.py +17 -0
  23. schemathesis/cli/ext/fs.py +14 -0
  24. schemathesis/cli/ext/groups.py +55 -0
  25. schemathesis/cli/{options.py → ext/options.py} +39 -10
  26. schemathesis/cli/hooks.py +36 -0
  27. schemathesis/contrib/__init__.py +1 -3
  28. schemathesis/contrib/openapi/__init__.py +1 -3
  29. schemathesis/contrib/openapi/fill_missing_examples.py +3 -5
  30. schemathesis/core/__init__.py +58 -0
  31. schemathesis/core/compat.py +25 -0
  32. schemathesis/core/control.py +2 -0
  33. schemathesis/core/curl.py +58 -0
  34. schemathesis/core/deserialization.py +65 -0
  35. schemathesis/core/errors.py +370 -0
  36. schemathesis/core/failures.py +285 -0
  37. schemathesis/core/fs.py +19 -0
  38. schemathesis/{_lazy_import.py → core/lazy_import.py} +1 -0
  39. schemathesis/core/loaders.py +104 -0
  40. schemathesis/core/marks.py +66 -0
  41. schemathesis/{transports/content_types.py → core/media_types.py} +17 -13
  42. schemathesis/core/output/__init__.py +69 -0
  43. schemathesis/core/output/sanitization.py +197 -0
  44. schemathesis/core/rate_limit.py +60 -0
  45. schemathesis/core/registries.py +31 -0
  46. schemathesis/{internal → core}/result.py +1 -1
  47. schemathesis/core/transforms.py +113 -0
  48. schemathesis/core/transport.py +108 -0
  49. schemathesis/core/validation.py +38 -0
  50. schemathesis/core/version.py +7 -0
  51. schemathesis/engine/__init__.py +30 -0
  52. schemathesis/engine/config.py +59 -0
  53. schemathesis/engine/context.py +119 -0
  54. schemathesis/engine/control.py +36 -0
  55. schemathesis/engine/core.py +157 -0
  56. schemathesis/engine/errors.py +394 -0
  57. schemathesis/engine/events.py +337 -0
  58. schemathesis/engine/phases/__init__.py +66 -0
  59. schemathesis/{cli → engine/phases}/probes.py +63 -70
  60. schemathesis/engine/phases/stateful/__init__.py +65 -0
  61. schemathesis/engine/phases/stateful/_executor.py +326 -0
  62. schemathesis/engine/phases/stateful/context.py +85 -0
  63. schemathesis/engine/phases/unit/__init__.py +174 -0
  64. schemathesis/engine/phases/unit/_executor.py +321 -0
  65. schemathesis/engine/phases/unit/_pool.py +74 -0
  66. schemathesis/engine/recorder.py +241 -0
  67. schemathesis/errors.py +31 -0
  68. schemathesis/experimental/__init__.py +18 -14
  69. schemathesis/filters.py +103 -14
  70. schemathesis/generation/__init__.py +21 -37
  71. schemathesis/generation/case.py +190 -0
  72. schemathesis/generation/coverage.py +931 -0
  73. schemathesis/generation/hypothesis/__init__.py +30 -0
  74. schemathesis/generation/hypothesis/builder.py +585 -0
  75. schemathesis/generation/hypothesis/examples.py +50 -0
  76. schemathesis/generation/hypothesis/given.py +66 -0
  77. schemathesis/generation/hypothesis/reporting.py +14 -0
  78. schemathesis/generation/hypothesis/strategies.py +16 -0
  79. schemathesis/generation/meta.py +115 -0
  80. schemathesis/generation/modes.py +28 -0
  81. schemathesis/generation/overrides.py +96 -0
  82. schemathesis/generation/stateful/__init__.py +20 -0
  83. schemathesis/{stateful → generation/stateful}/state_machine.py +68 -81
  84. schemathesis/generation/targets.py +69 -0
  85. schemathesis/graphql/__init__.py +15 -0
  86. schemathesis/graphql/checks.py +115 -0
  87. schemathesis/graphql/loaders.py +131 -0
  88. schemathesis/hooks.py +99 -67
  89. schemathesis/openapi/__init__.py +13 -0
  90. schemathesis/openapi/checks.py +412 -0
  91. schemathesis/openapi/generation/__init__.py +0 -0
  92. schemathesis/openapi/generation/filters.py +63 -0
  93. schemathesis/openapi/loaders.py +178 -0
  94. schemathesis/pytest/__init__.py +5 -0
  95. schemathesis/pytest/control_flow.py +7 -0
  96. schemathesis/pytest/lazy.py +273 -0
  97. schemathesis/pytest/loaders.py +12 -0
  98. schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +106 -127
  99. schemathesis/python/__init__.py +0 -0
  100. schemathesis/python/asgi.py +12 -0
  101. schemathesis/python/wsgi.py +12 -0
  102. schemathesis/schemas.py +537 -261
  103. schemathesis/specs/graphql/__init__.py +0 -1
  104. schemathesis/specs/graphql/_cache.py +25 -0
  105. schemathesis/specs/graphql/nodes.py +1 -0
  106. schemathesis/specs/graphql/scalars.py +7 -5
  107. schemathesis/specs/graphql/schemas.py +215 -187
  108. schemathesis/specs/graphql/validation.py +11 -18
  109. schemathesis/specs/openapi/__init__.py +7 -1
  110. schemathesis/specs/openapi/_cache.py +122 -0
  111. schemathesis/specs/openapi/_hypothesis.py +146 -165
  112. schemathesis/specs/openapi/checks.py +565 -67
  113. schemathesis/specs/openapi/converter.py +33 -6
  114. schemathesis/specs/openapi/definitions.py +11 -18
  115. schemathesis/specs/openapi/examples.py +153 -39
  116. schemathesis/specs/openapi/expressions/__init__.py +37 -2
  117. schemathesis/specs/openapi/expressions/context.py +4 -6
  118. schemathesis/specs/openapi/expressions/extractors.py +23 -0
  119. schemathesis/specs/openapi/expressions/lexer.py +20 -18
  120. schemathesis/specs/openapi/expressions/nodes.py +38 -14
  121. schemathesis/specs/openapi/expressions/parser.py +26 -5
  122. schemathesis/specs/openapi/formats.py +45 -0
  123. schemathesis/specs/openapi/links.py +65 -165
  124. schemathesis/specs/openapi/media_types.py +32 -0
  125. schemathesis/specs/openapi/negative/__init__.py +7 -3
  126. schemathesis/specs/openapi/negative/mutations.py +24 -8
  127. schemathesis/specs/openapi/parameters.py +46 -30
  128. schemathesis/specs/openapi/patterns.py +137 -0
  129. schemathesis/specs/openapi/references.py +47 -57
  130. schemathesis/specs/openapi/schemas.py +483 -367
  131. schemathesis/specs/openapi/security.py +25 -7
  132. schemathesis/specs/openapi/serialization.py +11 -6
  133. schemathesis/specs/openapi/stateful/__init__.py +185 -73
  134. schemathesis/specs/openapi/utils.py +6 -1
  135. schemathesis/transport/__init__.py +104 -0
  136. schemathesis/transport/asgi.py +26 -0
  137. schemathesis/transport/prepare.py +99 -0
  138. schemathesis/transport/requests.py +221 -0
  139. schemathesis/{_xml.py → transport/serialization.py} +143 -28
  140. schemathesis/transport/wsgi.py +165 -0
  141. schemathesis-4.0.0a1.dist-info/METADATA +297 -0
  142. schemathesis-4.0.0a1.dist-info/RECORD +152 -0
  143. {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/WHEEL +1 -1
  144. {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/entry_points.txt +1 -1
  145. schemathesis/_compat.py +0 -74
  146. schemathesis/_dependency_versions.py +0 -17
  147. schemathesis/_hypothesis.py +0 -246
  148. schemathesis/_override.py +0 -49
  149. schemathesis/cli/cassettes.py +0 -375
  150. schemathesis/cli/context.py +0 -55
  151. schemathesis/cli/debug.py +0 -26
  152. schemathesis/cli/handlers.py +0 -16
  153. schemathesis/cli/junitxml.py +0 -43
  154. schemathesis/cli/output/__init__.py +0 -1
  155. schemathesis/cli/output/default.py +0 -765
  156. schemathesis/cli/output/short.py +0 -40
  157. schemathesis/cli/sanitization.py +0 -20
  158. schemathesis/code_samples.py +0 -149
  159. schemathesis/constants.py +0 -55
  160. schemathesis/contrib/openapi/formats/__init__.py +0 -9
  161. schemathesis/contrib/openapi/formats/uuid.py +0 -15
  162. schemathesis/contrib/unique_data.py +0 -41
  163. schemathesis/exceptions.py +0 -560
  164. schemathesis/extra/_aiohttp.py +0 -27
  165. schemathesis/extra/_flask.py +0 -10
  166. schemathesis/extra/_server.py +0 -17
  167. schemathesis/failures.py +0 -209
  168. schemathesis/fixups/__init__.py +0 -36
  169. schemathesis/fixups/fast_api.py +0 -41
  170. schemathesis/fixups/utf8_bom.py +0 -29
  171. schemathesis/graphql.py +0 -4
  172. schemathesis/internal/__init__.py +0 -7
  173. schemathesis/internal/copy.py +0 -13
  174. schemathesis/internal/datetime.py +0 -5
  175. schemathesis/internal/deprecation.py +0 -34
  176. schemathesis/internal/jsonschema.py +0 -35
  177. schemathesis/internal/transformation.py +0 -15
  178. schemathesis/internal/validation.py +0 -34
  179. schemathesis/lazy.py +0 -361
  180. schemathesis/loaders.py +0 -120
  181. schemathesis/models.py +0 -1231
  182. schemathesis/parameters.py +0 -86
  183. schemathesis/runner/__init__.py +0 -555
  184. schemathesis/runner/events.py +0 -309
  185. schemathesis/runner/impl/__init__.py +0 -3
  186. schemathesis/runner/impl/core.py +0 -986
  187. schemathesis/runner/impl/solo.py +0 -90
  188. schemathesis/runner/impl/threadpool.py +0 -400
  189. schemathesis/runner/serialization.py +0 -411
  190. schemathesis/sanitization.py +0 -248
  191. schemathesis/serializers.py +0 -315
  192. schemathesis/service/__init__.py +0 -18
  193. schemathesis/service/auth.py +0 -11
  194. schemathesis/service/ci.py +0 -201
  195. schemathesis/service/client.py +0 -100
  196. schemathesis/service/constants.py +0 -38
  197. schemathesis/service/events.py +0 -57
  198. schemathesis/service/hosts.py +0 -107
  199. schemathesis/service/metadata.py +0 -46
  200. schemathesis/service/models.py +0 -49
  201. schemathesis/service/report.py +0 -255
  202. schemathesis/service/serialization.py +0 -184
  203. schemathesis/service/usage.py +0 -65
  204. schemathesis/specs/graphql/loaders.py +0 -344
  205. schemathesis/specs/openapi/filters.py +0 -49
  206. schemathesis/specs/openapi/loaders.py +0 -667
  207. schemathesis/specs/openapi/stateful/links.py +0 -92
  208. schemathesis/specs/openapi/validation.py +0 -25
  209. schemathesis/stateful/__init__.py +0 -133
  210. schemathesis/targets.py +0 -45
  211. schemathesis/throttling.py +0 -41
  212. schemathesis/transports/__init__.py +0 -5
  213. schemathesis/transports/auth.py +0 -15
  214. schemathesis/transports/headers.py +0 -35
  215. schemathesis/transports/responses.py +0 -52
  216. schemathesis/types.py +0 -35
  217. schemathesis/utils.py +0 -169
  218. schemathesis-3.25.5.dist-info/METADATA +0 -356
  219. schemathesis-3.25.5.dist-info/RECORD +0 -134
  220. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
  221. {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,221 @@
1
+ from __future__ import annotations
2
+
3
+ import binascii
4
+ import inspect
5
+ import os
6
+ from io import BytesIO
7
+ from typing import TYPE_CHECKING, Any
8
+ from urllib.parse import urlparse
9
+
10
+ from schemathesis.core import NotSet
11
+ from schemathesis.core.rate_limit import ratelimit
12
+ from schemathesis.core.transforms import deepclone, merge_at
13
+ from schemathesis.core.transport import DEFAULT_RESPONSE_TIMEOUT, Response
14
+ from schemathesis.transport import BaseTransport, SerializationContext
15
+ from schemathesis.transport.prepare import prepare_body, prepare_headers, prepare_url
16
+ from schemathesis.transport.serialization import Binary, serialize_binary, serialize_json, serialize_xml, serialize_yaml
17
+
18
+ if TYPE_CHECKING:
19
+ import requests
20
+
21
+ from schemathesis.generation.case import Case
22
+
23
+
24
+ class RequestsTransport(BaseTransport["Case", Response, "requests.Session"]):
25
+ def serialize_case(self, case: Case, **kwargs: Any) -> dict[str, Any]:
26
+ base_url = kwargs.get("base_url")
27
+ headers = kwargs.get("headers")
28
+ params = kwargs.get("params")
29
+ cookies = kwargs.get("cookies")
30
+
31
+ final_headers = prepare_headers(case, headers)
32
+
33
+ media_type = case.media_type
34
+
35
+ # Set content type header if needed
36
+ if media_type and media_type != "multipart/form-data" and not isinstance(case.body, NotSet):
37
+ if "content-type" not in final_headers:
38
+ final_headers["Content-Type"] = media_type
39
+
40
+ url = prepare_url(case, base_url)
41
+
42
+ # Handle serialization
43
+ if not isinstance(case.body, NotSet) and media_type is not None:
44
+ serializer = self._get_serializer(media_type)
45
+ context = SerializationContext(case=case)
46
+ extra = serializer(context, prepare_body(case))
47
+ else:
48
+ extra = {}
49
+
50
+ if case._auth is not None:
51
+ extra["auth"] = case._auth
52
+
53
+ # Additional headers from serializer
54
+ additional_headers = extra.pop("headers", None)
55
+ if additional_headers:
56
+ for key, value in additional_headers.items():
57
+ final_headers.setdefault(key, value)
58
+
59
+ data = {
60
+ "method": case.method,
61
+ "url": url,
62
+ "cookies": case.cookies,
63
+ "headers": final_headers,
64
+ "params": case.query,
65
+ **extra,
66
+ }
67
+
68
+ if params is not None:
69
+ merge_at(data, "params", params)
70
+ if cookies is not None:
71
+ merge_at(data, "cookies", cookies)
72
+
73
+ return data
74
+
75
+ def send(self, case: Case, *, session: requests.Session | None = None, **kwargs: Any) -> Response:
76
+ import requests
77
+
78
+ data = self.serialize_case(case, **kwargs)
79
+ kwargs.pop("base_url", None)
80
+ data.update({key: value for key, value in kwargs.items() if key not in data})
81
+ data.setdefault("timeout", DEFAULT_RESPONSE_TIMEOUT)
82
+
83
+ if session is None:
84
+ validate_vanilla_requests_kwargs(data)
85
+ session = requests.Session()
86
+ close_session = True
87
+ else:
88
+ close_session = False
89
+
90
+ verify = data.get("verify", True)
91
+
92
+ try:
93
+ with ratelimit(case.operation.schema.rate_limiter, case.operation.schema.base_url):
94
+ response = session.request(**data) # type: ignore
95
+ return Response.from_requests(response, verify=verify)
96
+ finally:
97
+ if close_session:
98
+ session.close()
99
+
100
+
101
+ def validate_vanilla_requests_kwargs(data: dict[str, Any]) -> None:
102
+ """Check arguments for `requests.Session.request`.
103
+
104
+ Some arguments can be valid for cases like ASGI integration, but at the same time they won't work for the regular
105
+ `requests` calls. In such cases we need to avoid an obscure error message, that comes from `requests`.
106
+ """
107
+ url = data["url"]
108
+ if not urlparse(url).netloc:
109
+ stack = inspect.stack()
110
+ method_name = "call"
111
+ for frame in stack[1:]:
112
+ if frame.function == "call_and_validate":
113
+ method_name = "call_and_validate"
114
+ break
115
+ raise RuntimeError(
116
+ "The `base_url` argument is required when specifying a schema via a file, so Schemathesis knows where to send the data. \n"
117
+ f"Pass `base_url` either to the `schemathesis.openapi.from_*` loader or to the `Case.{method_name}`.\n"
118
+ f"If you use the ASGI integration, please supply your test client "
119
+ f"as the `session` argument to `call`.\nURL: {url}"
120
+ )
121
+
122
+
123
+ REQUESTS_TRANSPORT = RequestsTransport()
124
+
125
+
126
+ @REQUESTS_TRANSPORT.serializer("application/json", "text/json")
127
+ def json_serializer(ctx: SerializationContext[Case], value: Any) -> dict[str, Any]:
128
+ return serialize_json(value)
129
+
130
+
131
+ @REQUESTS_TRANSPORT.serializer(
132
+ "text/yaml", "text/x-yaml", "text/vnd.yaml", "text/yml", "application/yaml", "application/x-yaml"
133
+ )
134
+ def yaml_serializer(ctx: SerializationContext[Case], value: Any) -> dict[str, Any]:
135
+ return serialize_yaml(value)
136
+
137
+
138
+ def _should_coerce_to_bytes(item: Any) -> bool:
139
+ """Whether the item should be converted to bytes."""
140
+ # These types are OK in forms, others should be coerced to bytes
141
+ return isinstance(item, Binary) or not isinstance(item, (bytes, str, int))
142
+
143
+
144
+ def _prepare_form_data(data: dict[str, Any]) -> dict[str, Any]:
145
+ """Make the generated data suitable for sending as multipart.
146
+
147
+ If the schema is loose, Schemathesis can generate data that can't be sent as multipart. In these cases,
148
+ we convert it to bytes and send it as-is, ignoring any conversion errors.
149
+
150
+ NOTE. This behavior might change in the future.
151
+ """
152
+ for name, value in data.items():
153
+ if isinstance(value, list):
154
+ data[name] = [serialize_binary(item) if _should_coerce_to_bytes(item) else item for item in value]
155
+ elif _should_coerce_to_bytes(value):
156
+ data[name] = serialize_binary(value)
157
+ return data
158
+
159
+
160
+ def choose_boundary() -> str:
161
+ """Random boundary name."""
162
+ return binascii.hexlify(os.urandom(16)).decode("ascii")
163
+
164
+
165
+ def _encode_multipart(value: Any, boundary: str) -> bytes:
166
+ """Encode any value as multipart.
167
+
168
+ NOTE. It doesn't aim to be 100% correct multipart payload, but rather a way to send data which is not intended to
169
+ be used as multipart, in cases when the API schema dictates so.
170
+ """
171
+ # For such cases we stringify the value and wrap it to a randomly-generated boundary
172
+ body = BytesIO()
173
+ body.write(f"--{boundary}\r\n".encode())
174
+ body.write(str(value).encode())
175
+ body.write(f"--{boundary}--\r\n".encode("latin-1"))
176
+ return body.getvalue()
177
+
178
+
179
+ @REQUESTS_TRANSPORT.serializer("multipart/form-data", "multipart/mixed")
180
+ def multipart_serializer(ctx: SerializationContext[Case], value: Any) -> dict[str, Any]:
181
+ if isinstance(value, bytes):
182
+ return {"data": value}
183
+ if isinstance(value, dict):
184
+ value = deepclone(value)
185
+ multipart = _prepare_form_data(value)
186
+ files, data = ctx.case.operation.prepare_multipart(multipart)
187
+ return {"files": files, "data": data}
188
+ # Uncommon schema. For example - `{"type": "string"}`
189
+ boundary = choose_boundary()
190
+ raw_data = _encode_multipart(value, boundary)
191
+ content_type = f"multipart/form-data; boundary={boundary}"
192
+ return {"data": raw_data, "headers": {"Content-Type": content_type}}
193
+
194
+
195
+ @REQUESTS_TRANSPORT.serializer("application/xml", "text/xml")
196
+ def xml_serializer(ctx: SerializationContext[Case], value: Any) -> dict[str, Any]:
197
+ media_type = ctx.case.media_type
198
+
199
+ assert media_type is not None
200
+
201
+ raw_schema = ctx.case.operation.get_raw_payload_schema(media_type)
202
+ resolved_schema = ctx.case.operation.get_resolved_payload_schema(media_type)
203
+
204
+ return serialize_xml(value, raw_schema, resolved_schema)
205
+
206
+
207
+ @REQUESTS_TRANSPORT.serializer("application/x-www-form-urlencoded")
208
+ def urlencoded_serializer(ctx: SerializationContext[Case], value: Any) -> dict[str, Any]:
209
+ return {"data": value}
210
+
211
+
212
+ @REQUESTS_TRANSPORT.serializer("text/plain")
213
+ def text_serializer(ctx: SerializationContext[Case], value: Any) -> dict[str, Any]:
214
+ if isinstance(value, bytes):
215
+ return {"data": value}
216
+ return {"data": str(value).encode("utf8")}
217
+
218
+
219
+ @REQUESTS_TRANSPORT.serializer("application/octet-stream")
220
+ def binary_serializer(ctx: SerializationContext[Case], value: Any) -> dict[str, Any]:
221
+ return {"data": serialize_binary(value)}
@@ -1,11 +1,74 @@
1
- """XML serialization."""
2
1
  from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
3
5
  from io import StringIO
4
6
  from typing import Any, Dict, List, Union
5
- from xml.etree import ElementTree
7
+ from unicodedata import normalize
8
+
9
+ from schemathesis.core.errors import UnboundPrefix
10
+ from schemathesis.core.transforms import deepclone, transform
11
+
12
+
13
+ @dataclass
14
+ class Binary(str):
15
+ """A wrapper around `bytes` to resolve OpenAPI and JSON Schema `format` discrepancies.
16
+
17
+ Treat `bytes` as a valid type, allowing generation of bytes for OpenAPI `format` values like `binary` or `file`
18
+ that JSON Schema expects to be strings.
19
+ """
20
+
21
+ data: bytes
22
+
23
+ __slots__ = ("data",)
24
+
25
+ def __hash__(self) -> int:
26
+ return hash(self.data)
27
+
28
+
29
+ def serialize_json(value: Any) -> dict[str, Any]:
30
+ if isinstance(value, bytes):
31
+ # Possible to get via explicit examples, e.g. `externalValue`
32
+ return {"data": value}
33
+ if isinstance(value, Binary):
34
+ return {"data": value.data}
35
+ if value is None:
36
+ # If the body is `None`, then the app expects `null`, but `None` is also the default value for the `json`
37
+ # argument in `requests.request` and `werkzeug.Client.open` which makes these cases indistinguishable.
38
+ # Therefore we explicitly create such payload
39
+ return {"data": b"null"}
40
+ return {"json": value}
41
+
42
+
43
+ def _replace_binary(value: dict) -> dict:
44
+ return {key: value.data if isinstance(value, Binary) else value for key, value in value.items()}
45
+
46
+
47
+ def serialize_binary(value: Any) -> bytes:
48
+ """Convert the input value to bytes and ignore any conversion errors."""
49
+ if isinstance(value, bytes):
50
+ return value
51
+ if isinstance(value, Binary):
52
+ return value.data
53
+ return str(value).encode(errors="ignore")
54
+
55
+
56
+ def serialize_yaml(value: Any) -> dict[str, Any]:
57
+ import yaml
58
+
59
+ try:
60
+ from yaml import CSafeDumper as SafeDumper
61
+ except ImportError:
62
+ from yaml import SafeDumper # type: ignore
63
+
64
+ if isinstance(value, bytes):
65
+ return {"data": value}
66
+ if isinstance(value, Binary):
67
+ return {"data": value.data}
68
+ if isinstance(value, (list, dict)):
69
+ value = transform(value, _replace_binary)
70
+ return {"data": yaml.dump(value, Dumper=SafeDumper)}
6
71
 
7
- from .exceptions import UnboundPrefixError
8
- from .internal.copy import fast_deepcopy
9
72
 
10
73
  Primitive = Union[str, int, float, bool, None]
11
74
  JSON = Union[Primitive, List, Dict[str, Any]]
@@ -13,7 +76,9 @@ DEFAULT_TAG_NAME = "data"
13
76
  NAMESPACE_URL = "http://example.com/schema"
14
77
 
15
78
 
16
- def _to_xml(value: Any, raw_schema: dict[str, Any] | None, resolved_schema: dict[str, Any] | None) -> dict[str, Any]:
79
+ def serialize_xml(
80
+ value: Any, raw_schema: dict[str, Any] | None, resolved_schema: dict[str, Any] | None
81
+ ) -> dict[str, Any]:
17
82
  """Serialize a generated Python object as an XML string.
18
83
 
19
84
  Schemas may contain additional information for fine-tuned XML serialization.
@@ -30,24 +95,9 @@ def _to_xml(value: Any, raw_schema: dict[str, Any] | None, resolved_schema: dict
30
95
  namespace_stack: list[str] = []
31
96
  _write_xml(buffer, value, tag, resolved_schema, namespace_stack)
32
97
  data = buffer.getvalue()
33
- if not is_valid_xml(data):
34
- from hypothesis import reject
35
-
36
- reject()
37
98
  return {"data": data.encode("utf8")}
38
99
 
39
100
 
40
- _from_string = ElementTree.fromstring
41
-
42
-
43
- def is_valid_xml(data: str) -> bool:
44
- try:
45
- _from_string(f"<root xmlns:smp='{NAMESPACE_URL}'>{data}</root>")
46
- return True
47
- except ElementTree.ParseError:
48
- return False
49
-
50
-
51
101
  def _get_xml_tag(raw_schema: dict[str, Any] | None, resolved_schema: dict[str, Any] | None) -> str:
52
102
  # On the top level we need to detect the proper XML tag, in other cases it is known from object properties
53
103
  if (resolved_schema or {}).get("xml", {}).get("name"):
@@ -76,7 +126,7 @@ def _validate_prefix(options: dict[str, Any], namespace_stack: list[str]) -> Non
76
126
  try:
77
127
  prefix = options["prefix"]
78
128
  if prefix not in namespace_stack:
79
- raise UnboundPrefixError(prefix)
129
+ raise UnboundPrefix(prefix)
80
130
  except KeyError:
81
131
  pass
82
132
 
@@ -96,12 +146,15 @@ def _write_object(
96
146
  ) -> None:
97
147
  options = (schema or {}).get("xml", {})
98
148
  push_namespace_if_any(stack, options)
149
+ tag = _sanitize_xml_name(tag)
99
150
  if "prefix" in options:
100
151
  tag = f"{options['prefix']}:{tag}"
101
152
  buffer.write(f"<{tag}")
102
153
  if "namespace" in options:
103
154
  _write_namespace(buffer, options)
104
- attributes = []
155
+
156
+ attribute_namespaces = {}
157
+ attributes = {}
105
158
  children_buffer = StringIO()
106
159
  properties = (schema or {}).get("properties", {})
107
160
  for child_name, value in obj.items():
@@ -109,18 +162,35 @@ def _write_object(
109
162
  child_options = property_schema.get("xml", {})
110
163
  push_namespace_if_any(stack, child_options)
111
164
  child_tag = child_options.get("name", child_name)
165
+
166
+ if child_options.get("attribute", False):
167
+ if child_options.get("prefix") and child_options.get("namespace"):
168
+ _validate_prefix(child_options, stack)
169
+ prefix = child_options["prefix"]
170
+ attr_name = f"{prefix}:{_sanitize_xml_name(child_tag)}"
171
+ # Store namespace declaration
172
+ attribute_namespaces[prefix] = child_options["namespace"]
173
+ else:
174
+ attr_name = _sanitize_xml_name(child_tag)
175
+
176
+ if attr_name not in attributes: # Only keep first occurrence
177
+ attributes[attr_name] = f'{attr_name}="{_escape_xml(value)}"'
178
+ continue
179
+
180
+ child_tag = _sanitize_xml_name(child_tag)
112
181
  if child_options.get("prefix"):
113
182
  _validate_prefix(child_options, stack)
114
183
  prefix = child_options["prefix"]
115
184
  child_tag = f"{prefix}:{child_tag}"
116
- if child_options.get("attribute", False):
117
- attributes.append(f'{child_tag}="{value}"')
118
- continue
119
185
  _write_xml(children_buffer, value, child_tag, property_schema, stack)
120
186
  pop_namespace_if_any(stack, child_options)
121
187
 
188
+ # Write namespace declarations for attributes
189
+ for prefix, namespace in attribute_namespaces.items():
190
+ buffer.write(f' xmlns:{prefix}="{namespace}"')
191
+
122
192
  if attributes:
123
- buffer.write(f" {' '.join(attributes)}")
193
+ buffer.write(f" {' '.join(attributes.values())}")
124
194
  buffer.write(">")
125
195
  buffer.write(children_buffer.getvalue())
126
196
  buffer.write(f"</{tag}>")
@@ -141,7 +211,7 @@ def _write_array(buffer: StringIO, obj: list[JSON], tag: str, schema: dict[str,
141
211
  _write_namespace(buffer, options)
142
212
  buffer.write(">")
143
213
  # In Open API `items` value should be an object and not an array
144
- items = fast_deepcopy((schema or {}).get("items", {}))
214
+ items = deepclone((schema or {}).get("items", {}))
145
215
  child_options = items.get("xml", {})
146
216
  child_tag = child_options.get("name", tag)
147
217
  if not is_namespace_specified and "namespace" in options:
@@ -167,7 +237,7 @@ def _write_primitive(
167
237
  buffer.write(f"<{tag}")
168
238
  if "namespace" in xml_options:
169
239
  _write_namespace(buffer, xml_options)
170
- buffer.write(f">{obj}</{tag}>")
240
+ buffer.write(f">{_escape_xml(obj)}</{tag}>")
171
241
 
172
242
 
173
243
  def _write_namespace(buffer: StringIO, options: dict[str, Any]) -> None:
@@ -180,3 +250,48 @@ def _write_namespace(buffer: StringIO, options: dict[str, Any]) -> None:
180
250
  def _get_tag_name_from_reference(reference: str) -> str:
181
251
  """Extract object name from a reference."""
182
252
  return reference.rsplit("/", maxsplit=1)[1]
253
+
254
+
255
+ def _escape_xml(value: JSON) -> str:
256
+ """Escape special characters in XML content."""
257
+ if isinstance(value, (int, float, bool)):
258
+ return str(value)
259
+ if value is None:
260
+ return ""
261
+
262
+ # Filter out invalid XML characters
263
+ cleaned = "".join(
264
+ char
265
+ for char in str(value)
266
+ if (
267
+ char in "\t\n\r"
268
+ or 0x20 <= ord(char) <= 0xD7FF
269
+ or 0xE000 <= ord(char) <= 0xFFFD
270
+ or 0x10000 <= ord(char) <= 0x10FFFF
271
+ )
272
+ )
273
+
274
+ replacements = {
275
+ "&": "&amp;",
276
+ "<": "&lt;",
277
+ ">": "&gt;",
278
+ '"': "&quot;",
279
+ "'": "&apos;",
280
+ }
281
+ return "".join(replacements.get(c, c) for c in cleaned)
282
+
283
+
284
+ def _sanitize_xml_name(name: str) -> str:
285
+ """Sanitize a string to be a valid XML element name."""
286
+ if not name:
287
+ return "element"
288
+
289
+ name = normalize("NFKC", str(name))
290
+
291
+ name = name.replace(":", "_")
292
+ sanitized = re.sub(r"[^a-zA-Z0-9_\-.]", "_", name)
293
+
294
+ if not sanitized[0].isalpha() and sanitized[0] != "_":
295
+ sanitized = "x_" + sanitized
296
+
297
+ return sanitized
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from contextlib import contextmanager
5
+ from typing import TYPE_CHECKING, Any, Generator
6
+
7
+ from schemathesis.core import NotSet
8
+ from schemathesis.core.rate_limit import ratelimit
9
+ from schemathesis.core.transforms import merge_at
10
+ from schemathesis.core.transport import Response
11
+ from schemathesis.generation.case import Case
12
+ from schemathesis.python import wsgi
13
+ from schemathesis.transport import BaseTransport, SerializationContext
14
+ from schemathesis.transport.prepare import normalize_base_url, prepare_body, prepare_headers, prepare_path
15
+ from schemathesis.transport.requests import REQUESTS_TRANSPORT
16
+ from schemathesis.transport.serialization import serialize_binary, serialize_json, serialize_xml, serialize_yaml
17
+
18
+ if TYPE_CHECKING:
19
+ import werkzeug
20
+
21
+
22
+ class WSGITransport(BaseTransport["Case", Response, "werkzeug.Client"]):
23
+ def serialize_case(self, case: Case, **kwargs: Any) -> dict[str, Any]:
24
+ headers = kwargs.get("headers")
25
+ params = kwargs.get("params")
26
+
27
+ final_headers = prepare_headers(case, headers)
28
+
29
+ media_type = case.media_type
30
+
31
+ # Set content type for payload
32
+ if media_type and not isinstance(case.body, NotSet):
33
+ final_headers["Content-Type"] = media_type
34
+
35
+ extra: dict[str, Any]
36
+ # Handle serialization
37
+ if not isinstance(case.body, NotSet) and media_type is not None:
38
+ serializer = self._get_serializer(media_type)
39
+ context = SerializationContext(case=case)
40
+ extra = serializer(context, prepare_body(case))
41
+ else:
42
+ extra = {}
43
+
44
+ data = {
45
+ "method": case.method,
46
+ "path": case.operation.schema.get_full_path(prepare_path(case.path, case.path_parameters)),
47
+ # Convert to regular dict for Werkzeug compatibility
48
+ "headers": dict(final_headers),
49
+ "query_string": case.query,
50
+ **extra,
51
+ }
52
+
53
+ if params is not None:
54
+ merge_at(data, "query_string", params)
55
+
56
+ return data
57
+
58
+ def send(
59
+ self,
60
+ case: Case,
61
+ *,
62
+ session: werkzeug.Client | None = None,
63
+ **kwargs: Any,
64
+ ) -> Response:
65
+ import requests
66
+
67
+ headers = kwargs.pop("headers", None)
68
+ params = kwargs.pop("params", None)
69
+ cookies = kwargs.pop("cookies", None)
70
+ application = kwargs.pop("app")
71
+
72
+ data = self.serialize_case(case, headers=headers, params=params)
73
+ data.update({key: value for key, value in kwargs.items() if key not in data})
74
+
75
+ client = session or wsgi.get_client(application)
76
+ cookies = {**(case.cookies or {}), **(cookies or {})}
77
+
78
+ with (
79
+ cookie_handler(client, cookies),
80
+ ratelimit(case.operation.schema.rate_limiter, case.operation.schema.base_url),
81
+ ):
82
+ start = time.monotonic()
83
+ response = client.open(**data)
84
+ elapsed = time.monotonic() - start
85
+
86
+ requests_kwargs = REQUESTS_TRANSPORT.serialize_case(
87
+ case,
88
+ base_url=normalize_base_url(case.operation.base_url),
89
+ headers=headers,
90
+ params=params,
91
+ cookies=cookies,
92
+ )
93
+
94
+ headers = {key: response.headers.getlist(key) for key in response.headers.keys()}
95
+
96
+ return Response(
97
+ status_code=response.status_code,
98
+ headers=headers,
99
+ content=response.get_data(),
100
+ request=requests.Request(**requests_kwargs).prepare(),
101
+ elapsed=elapsed,
102
+ verify=False,
103
+ )
104
+
105
+
106
+ @contextmanager
107
+ def cookie_handler(client: werkzeug.Client, cookies: dict[str, Any] | None) -> Generator[None, None, None]:
108
+ """Set cookies required for a call."""
109
+ if not cookies:
110
+ yield
111
+ else:
112
+ for key, value in cookies.items():
113
+ client.set_cookie(key=key, value=value, domain="localhost")
114
+ yield
115
+ for key in cookies:
116
+ client.delete_cookie(key=key, domain="localhost")
117
+
118
+
119
+ WSGI_TRANSPORT = WSGITransport()
120
+
121
+
122
+ @WSGI_TRANSPORT.serializer("application/json", "text/json")
123
+ def json_serializer(ctx: SerializationContext[Case], value: Any) -> dict[str, Any]:
124
+ return serialize_json(value)
125
+
126
+
127
+ @WSGI_TRANSPORT.serializer(
128
+ "text/yaml", "text/x-yaml", "text/vnd.yaml", "text/yml", "application/yaml", "application/x-yaml"
129
+ )
130
+ def yaml_serializer(ctx: SerializationContext[Case], value: Any) -> dict[str, Any]:
131
+ return serialize_yaml(value)
132
+
133
+
134
+ @WSGI_TRANSPORT.serializer("multipart/form-data", "multipart/mixed")
135
+ def multipart_serializer(ctx: SerializationContext[Case], value: Any) -> dict[str, Any]:
136
+ return {"data": value}
137
+
138
+
139
+ @WSGI_TRANSPORT.serializer("application/xml", "text/xml")
140
+ def xml_serializer(ctx: SerializationContext[Case], value: Any) -> dict[str, Any]:
141
+ media_type = ctx.case.media_type
142
+
143
+ assert media_type is not None
144
+
145
+ raw_schema = ctx.case.operation.get_raw_payload_schema(media_type)
146
+ resolved_schema = ctx.case.operation.get_resolved_payload_schema(media_type)
147
+
148
+ return serialize_xml(value, raw_schema, resolved_schema)
149
+
150
+
151
+ @WSGI_TRANSPORT.serializer("application/x-www-form-urlencoded")
152
+ def urlencoded_serializer(ctx: SerializationContext[Case], value: Any) -> dict[str, Any]:
153
+ return {"data": value}
154
+
155
+
156
+ @WSGI_TRANSPORT.serializer("text/plain")
157
+ def text_serializer(ctx: SerializationContext[Case], value: Any) -> dict[str, Any]:
158
+ if isinstance(value, bytes):
159
+ return {"data": value}
160
+ return {"data": str(value)}
161
+
162
+
163
+ @WSGI_TRANSPORT.serializer("application/octet-stream")
164
+ def binary_serializer(ctx: SerializationContext[Case], value: Any) -> dict[str, Any]:
165
+ return {"data": serialize_binary(value)}