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.
- schemathesis/__init__.py +27 -65
- schemathesis/auths.py +102 -82
- schemathesis/checks.py +126 -46
- schemathesis/cli/__init__.py +11 -1766
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/commands/__init__.py +37 -0
- schemathesis/cli/commands/run/__init__.py +662 -0
- schemathesis/cli/commands/run/checks.py +80 -0
- schemathesis/cli/commands/run/context.py +117 -0
- schemathesis/cli/commands/run/events.py +35 -0
- schemathesis/cli/commands/run/executor.py +138 -0
- schemathesis/cli/commands/run/filters.py +194 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +18 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +494 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
- schemathesis/cli/commands/run/handlers/output.py +746 -0
- schemathesis/cli/commands/run/hypothesis.py +105 -0
- schemathesis/cli/commands/run/loaders.py +129 -0
- schemathesis/cli/{callbacks.py → commands/run/validation.py} +103 -174
- schemathesis/cli/constants.py +5 -52
- schemathesis/cli/core.py +17 -0
- schemathesis/cli/ext/fs.py +14 -0
- schemathesis/cli/ext/groups.py +55 -0
- schemathesis/cli/{options.py → ext/options.py} +39 -10
- schemathesis/cli/hooks.py +36 -0
- schemathesis/contrib/__init__.py +1 -3
- schemathesis/contrib/openapi/__init__.py +1 -3
- schemathesis/contrib/openapi/fill_missing_examples.py +3 -5
- schemathesis/core/__init__.py +58 -0
- schemathesis/core/compat.py +25 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +58 -0
- schemathesis/core/deserialization.py +65 -0
- schemathesis/core/errors.py +370 -0
- schemathesis/core/failures.py +285 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/{_lazy_import.py → core/lazy_import.py} +1 -0
- schemathesis/core/loaders.py +104 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/{transports/content_types.py → core/media_types.py} +17 -13
- schemathesis/core/output/__init__.py +69 -0
- schemathesis/core/output/sanitization.py +197 -0
- schemathesis/core/rate_limit.py +60 -0
- schemathesis/core/registries.py +31 -0
- schemathesis/{internal → core}/result.py +1 -1
- schemathesis/core/transforms.py +113 -0
- schemathesis/core/transport.py +108 -0
- schemathesis/core/validation.py +38 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +30 -0
- schemathesis/engine/config.py +59 -0
- schemathesis/engine/context.py +119 -0
- schemathesis/engine/control.py +36 -0
- schemathesis/engine/core.py +157 -0
- schemathesis/engine/errors.py +394 -0
- schemathesis/engine/events.py +337 -0
- schemathesis/engine/phases/__init__.py +66 -0
- schemathesis/{cli → engine/phases}/probes.py +63 -70
- schemathesis/engine/phases/stateful/__init__.py +65 -0
- schemathesis/engine/phases/stateful/_executor.py +326 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +174 -0
- schemathesis/engine/phases/unit/_executor.py +321 -0
- schemathesis/engine/phases/unit/_pool.py +74 -0
- schemathesis/engine/recorder.py +241 -0
- schemathesis/errors.py +31 -0
- schemathesis/experimental/__init__.py +18 -14
- schemathesis/filters.py +103 -14
- schemathesis/generation/__init__.py +21 -37
- schemathesis/generation/case.py +190 -0
- schemathesis/generation/coverage.py +931 -0
- schemathesis/generation/hypothesis/__init__.py +30 -0
- schemathesis/generation/hypothesis/builder.py +585 -0
- schemathesis/generation/hypothesis/examples.py +50 -0
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +14 -0
- schemathesis/generation/hypothesis/strategies.py +16 -0
- schemathesis/generation/meta.py +115 -0
- schemathesis/generation/modes.py +28 -0
- schemathesis/generation/overrides.py +96 -0
- schemathesis/generation/stateful/__init__.py +20 -0
- schemathesis/{stateful → generation/stateful}/state_machine.py +68 -81
- schemathesis/generation/targets.py +69 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +115 -0
- schemathesis/graphql/loaders.py +131 -0
- schemathesis/hooks.py +99 -67
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +412 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +63 -0
- schemathesis/openapi/loaders.py +178 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +273 -0
- schemathesis/pytest/loaders.py +12 -0
- schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +106 -127
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +537 -261
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/_cache.py +25 -0
- schemathesis/specs/graphql/nodes.py +1 -0
- schemathesis/specs/graphql/scalars.py +7 -5
- schemathesis/specs/graphql/schemas.py +215 -187
- schemathesis/specs/graphql/validation.py +11 -18
- schemathesis/specs/openapi/__init__.py +7 -1
- schemathesis/specs/openapi/_cache.py +122 -0
- schemathesis/specs/openapi/_hypothesis.py +146 -165
- schemathesis/specs/openapi/checks.py +565 -67
- schemathesis/specs/openapi/converter.py +33 -6
- schemathesis/specs/openapi/definitions.py +11 -18
- schemathesis/specs/openapi/examples.py +153 -39
- schemathesis/specs/openapi/expressions/__init__.py +37 -2
- schemathesis/specs/openapi/expressions/context.py +4 -6
- schemathesis/specs/openapi/expressions/extractors.py +23 -0
- schemathesis/specs/openapi/expressions/lexer.py +20 -18
- schemathesis/specs/openapi/expressions/nodes.py +38 -14
- schemathesis/specs/openapi/expressions/parser.py +26 -5
- schemathesis/specs/openapi/formats.py +45 -0
- schemathesis/specs/openapi/links.py +65 -165
- schemathesis/specs/openapi/media_types.py +32 -0
- schemathesis/specs/openapi/negative/__init__.py +7 -3
- schemathesis/specs/openapi/negative/mutations.py +24 -8
- schemathesis/specs/openapi/parameters.py +46 -30
- schemathesis/specs/openapi/patterns.py +137 -0
- schemathesis/specs/openapi/references.py +47 -57
- schemathesis/specs/openapi/schemas.py +483 -367
- schemathesis/specs/openapi/security.py +25 -7
- schemathesis/specs/openapi/serialization.py +11 -6
- schemathesis/specs/openapi/stateful/__init__.py +185 -73
- schemathesis/specs/openapi/utils.py +6 -1
- schemathesis/transport/__init__.py +104 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +99 -0
- schemathesis/transport/requests.py +221 -0
- schemathesis/{_xml.py → transport/serialization.py} +143 -28
- schemathesis/transport/wsgi.py +165 -0
- schemathesis-4.0.0a1.dist-info/METADATA +297 -0
- schemathesis-4.0.0a1.dist-info/RECORD +152 -0
- {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/WHEEL +1 -1
- {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/entry_points.txt +1 -1
- schemathesis/_compat.py +0 -74
- schemathesis/_dependency_versions.py +0 -17
- schemathesis/_hypothesis.py +0 -246
- schemathesis/_override.py +0 -49
- schemathesis/cli/cassettes.py +0 -375
- schemathesis/cli/context.py +0 -55
- schemathesis/cli/debug.py +0 -26
- schemathesis/cli/handlers.py +0 -16
- schemathesis/cli/junitxml.py +0 -43
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -765
- schemathesis/cli/output/short.py +0 -40
- schemathesis/cli/sanitization.py +0 -20
- schemathesis/code_samples.py +0 -149
- schemathesis/constants.py +0 -55
- schemathesis/contrib/openapi/formats/__init__.py +0 -9
- schemathesis/contrib/openapi/formats/uuid.py +0 -15
- schemathesis/contrib/unique_data.py +0 -41
- schemathesis/exceptions.py +0 -560
- schemathesis/extra/_aiohttp.py +0 -27
- schemathesis/extra/_flask.py +0 -10
- schemathesis/extra/_server.py +0 -17
- schemathesis/failures.py +0 -209
- schemathesis/fixups/__init__.py +0 -36
- schemathesis/fixups/fast_api.py +0 -41
- schemathesis/fixups/utf8_bom.py +0 -29
- schemathesis/graphql.py +0 -4
- schemathesis/internal/__init__.py +0 -7
- schemathesis/internal/copy.py +0 -13
- schemathesis/internal/datetime.py +0 -5
- schemathesis/internal/deprecation.py +0 -34
- schemathesis/internal/jsonschema.py +0 -35
- schemathesis/internal/transformation.py +0 -15
- schemathesis/internal/validation.py +0 -34
- schemathesis/lazy.py +0 -361
- schemathesis/loaders.py +0 -120
- schemathesis/models.py +0 -1231
- schemathesis/parameters.py +0 -86
- schemathesis/runner/__init__.py +0 -555
- schemathesis/runner/events.py +0 -309
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/core.py +0 -986
- schemathesis/runner/impl/solo.py +0 -90
- schemathesis/runner/impl/threadpool.py +0 -400
- schemathesis/runner/serialization.py +0 -411
- schemathesis/sanitization.py +0 -248
- schemathesis/serializers.py +0 -315
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -11
- schemathesis/service/ci.py +0 -201
- schemathesis/service/client.py +0 -100
- schemathesis/service/constants.py +0 -38
- schemathesis/service/events.py +0 -57
- schemathesis/service/hosts.py +0 -107
- schemathesis/service/metadata.py +0 -46
- schemathesis/service/models.py +0 -49
- schemathesis/service/report.py +0 -255
- schemathesis/service/serialization.py +0 -184
- schemathesis/service/usage.py +0 -65
- schemathesis/specs/graphql/loaders.py +0 -344
- schemathesis/specs/openapi/filters.py +0 -49
- schemathesis/specs/openapi/loaders.py +0 -667
- schemathesis/specs/openapi/stateful/links.py +0 -92
- schemathesis/specs/openapi/validation.py +0 -25
- schemathesis/stateful/__init__.py +0 -133
- schemathesis/targets.py +0 -45
- schemathesis/throttling.py +0 -41
- schemathesis/transports/__init__.py +0 -5
- schemathesis/transports/auth.py +0 -15
- schemathesis/transports/headers.py +0 -35
- schemathesis/transports/responses.py +0 -52
- schemathesis/types.py +0 -35
- schemathesis/utils.py +0 -169
- schemathesis-3.25.5.dist-info/METADATA +0 -356
- schemathesis-3.25.5.dist-info/RECORD +0 -134
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
- {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
|
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
|
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
|
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
|
-
|
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 =
|
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
|
+
"&": "&",
|
276
|
+
"<": "<",
|
277
|
+
">": ">",
|
278
|
+
'"': """,
|
279
|
+
"'": "'",
|
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)}
|