schemathesis 3.15.4__py3-none-any.whl → 4.4.2__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 (251) hide show
  1. schemathesis/__init__.py +53 -25
  2. schemathesis/auths.py +507 -0
  3. schemathesis/checks.py +190 -25
  4. schemathesis/cli/__init__.py +27 -1219
  5. schemathesis/cli/__main__.py +4 -0
  6. schemathesis/cli/commands/__init__.py +133 -0
  7. schemathesis/cli/commands/data.py +10 -0
  8. schemathesis/cli/commands/run/__init__.py +602 -0
  9. schemathesis/cli/commands/run/context.py +228 -0
  10. schemathesis/cli/commands/run/events.py +60 -0
  11. schemathesis/cli/commands/run/executor.py +157 -0
  12. schemathesis/cli/commands/run/filters.py +53 -0
  13. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  14. schemathesis/cli/commands/run/handlers/base.py +45 -0
  15. schemathesis/cli/commands/run/handlers/cassettes.py +464 -0
  16. schemathesis/cli/commands/run/handlers/junitxml.py +60 -0
  17. schemathesis/cli/commands/run/handlers/output.py +1750 -0
  18. schemathesis/cli/commands/run/loaders.py +118 -0
  19. schemathesis/cli/commands/run/validation.py +256 -0
  20. schemathesis/cli/constants.py +5 -0
  21. schemathesis/cli/core.py +19 -0
  22. schemathesis/cli/ext/fs.py +16 -0
  23. schemathesis/cli/ext/groups.py +203 -0
  24. schemathesis/cli/ext/options.py +81 -0
  25. schemathesis/config/__init__.py +202 -0
  26. schemathesis/config/_auth.py +51 -0
  27. schemathesis/config/_checks.py +268 -0
  28. schemathesis/config/_diff_base.py +101 -0
  29. schemathesis/config/_env.py +21 -0
  30. schemathesis/config/_error.py +163 -0
  31. schemathesis/config/_generation.py +157 -0
  32. schemathesis/config/_health_check.py +24 -0
  33. schemathesis/config/_operations.py +335 -0
  34. schemathesis/config/_output.py +171 -0
  35. schemathesis/config/_parameters.py +19 -0
  36. schemathesis/config/_phases.py +253 -0
  37. schemathesis/config/_projects.py +543 -0
  38. schemathesis/config/_rate_limit.py +17 -0
  39. schemathesis/config/_report.py +120 -0
  40. schemathesis/config/_validator.py +9 -0
  41. schemathesis/config/_warnings.py +89 -0
  42. schemathesis/config/schema.json +975 -0
  43. schemathesis/core/__init__.py +72 -0
  44. schemathesis/core/adapter.py +34 -0
  45. schemathesis/core/compat.py +32 -0
  46. schemathesis/core/control.py +2 -0
  47. schemathesis/core/curl.py +100 -0
  48. schemathesis/core/deserialization.py +210 -0
  49. schemathesis/core/errors.py +588 -0
  50. schemathesis/core/failures.py +316 -0
  51. schemathesis/core/fs.py +19 -0
  52. schemathesis/core/hooks.py +20 -0
  53. schemathesis/core/jsonschema/__init__.py +13 -0
  54. schemathesis/core/jsonschema/bundler.py +183 -0
  55. schemathesis/core/jsonschema/keywords.py +40 -0
  56. schemathesis/core/jsonschema/references.py +222 -0
  57. schemathesis/core/jsonschema/types.py +41 -0
  58. schemathesis/core/lazy_import.py +15 -0
  59. schemathesis/core/loaders.py +107 -0
  60. schemathesis/core/marks.py +66 -0
  61. schemathesis/core/media_types.py +79 -0
  62. schemathesis/core/output/__init__.py +46 -0
  63. schemathesis/core/output/sanitization.py +54 -0
  64. schemathesis/core/parameters.py +45 -0
  65. schemathesis/core/rate_limit.py +60 -0
  66. schemathesis/core/registries.py +34 -0
  67. schemathesis/core/result.py +27 -0
  68. schemathesis/core/schema_analysis.py +17 -0
  69. schemathesis/core/shell.py +203 -0
  70. schemathesis/core/transforms.py +144 -0
  71. schemathesis/core/transport.py +223 -0
  72. schemathesis/core/validation.py +73 -0
  73. schemathesis/core/version.py +7 -0
  74. schemathesis/engine/__init__.py +28 -0
  75. schemathesis/engine/context.py +152 -0
  76. schemathesis/engine/control.py +44 -0
  77. schemathesis/engine/core.py +201 -0
  78. schemathesis/engine/errors.py +446 -0
  79. schemathesis/engine/events.py +284 -0
  80. schemathesis/engine/observations.py +42 -0
  81. schemathesis/engine/phases/__init__.py +108 -0
  82. schemathesis/engine/phases/analysis.py +28 -0
  83. schemathesis/engine/phases/probes.py +172 -0
  84. schemathesis/engine/phases/stateful/__init__.py +68 -0
  85. schemathesis/engine/phases/stateful/_executor.py +364 -0
  86. schemathesis/engine/phases/stateful/context.py +85 -0
  87. schemathesis/engine/phases/unit/__init__.py +220 -0
  88. schemathesis/engine/phases/unit/_executor.py +459 -0
  89. schemathesis/engine/phases/unit/_pool.py +82 -0
  90. schemathesis/engine/recorder.py +254 -0
  91. schemathesis/errors.py +47 -0
  92. schemathesis/filters.py +395 -0
  93. schemathesis/generation/__init__.py +25 -0
  94. schemathesis/generation/case.py +478 -0
  95. schemathesis/generation/coverage.py +1528 -0
  96. schemathesis/generation/hypothesis/__init__.py +121 -0
  97. schemathesis/generation/hypothesis/builder.py +992 -0
  98. schemathesis/generation/hypothesis/examples.py +56 -0
  99. schemathesis/generation/hypothesis/given.py +66 -0
  100. schemathesis/generation/hypothesis/reporting.py +285 -0
  101. schemathesis/generation/meta.py +227 -0
  102. schemathesis/generation/metrics.py +93 -0
  103. schemathesis/generation/modes.py +20 -0
  104. schemathesis/generation/overrides.py +127 -0
  105. schemathesis/generation/stateful/__init__.py +37 -0
  106. schemathesis/generation/stateful/state_machine.py +294 -0
  107. schemathesis/graphql/__init__.py +15 -0
  108. schemathesis/graphql/checks.py +109 -0
  109. schemathesis/graphql/loaders.py +285 -0
  110. schemathesis/hooks.py +270 -91
  111. schemathesis/openapi/__init__.py +13 -0
  112. schemathesis/openapi/checks.py +467 -0
  113. schemathesis/openapi/generation/__init__.py +0 -0
  114. schemathesis/openapi/generation/filters.py +72 -0
  115. schemathesis/openapi/loaders.py +315 -0
  116. schemathesis/pytest/__init__.py +5 -0
  117. schemathesis/pytest/control_flow.py +7 -0
  118. schemathesis/pytest/lazy.py +341 -0
  119. schemathesis/pytest/loaders.py +36 -0
  120. schemathesis/pytest/plugin.py +357 -0
  121. schemathesis/python/__init__.py +0 -0
  122. schemathesis/python/asgi.py +12 -0
  123. schemathesis/python/wsgi.py +12 -0
  124. schemathesis/schemas.py +682 -257
  125. schemathesis/specs/graphql/__init__.py +0 -1
  126. schemathesis/specs/graphql/nodes.py +26 -2
  127. schemathesis/specs/graphql/scalars.py +77 -12
  128. schemathesis/specs/graphql/schemas.py +367 -148
  129. schemathesis/specs/graphql/validation.py +33 -0
  130. schemathesis/specs/openapi/__init__.py +9 -1
  131. schemathesis/specs/openapi/_hypothesis.py +555 -318
  132. schemathesis/specs/openapi/adapter/__init__.py +10 -0
  133. schemathesis/specs/openapi/adapter/parameters.py +729 -0
  134. schemathesis/specs/openapi/adapter/protocol.py +59 -0
  135. schemathesis/specs/openapi/adapter/references.py +19 -0
  136. schemathesis/specs/openapi/adapter/responses.py +368 -0
  137. schemathesis/specs/openapi/adapter/security.py +144 -0
  138. schemathesis/specs/openapi/adapter/v2.py +30 -0
  139. schemathesis/specs/openapi/adapter/v3_0.py +30 -0
  140. schemathesis/specs/openapi/adapter/v3_1.py +30 -0
  141. schemathesis/specs/openapi/analysis.py +96 -0
  142. schemathesis/specs/openapi/checks.py +748 -82
  143. schemathesis/specs/openapi/converter.py +176 -37
  144. schemathesis/specs/openapi/definitions.py +599 -4
  145. schemathesis/specs/openapi/examples.py +581 -165
  146. schemathesis/specs/openapi/expressions/__init__.py +52 -5
  147. schemathesis/specs/openapi/expressions/extractors.py +25 -0
  148. schemathesis/specs/openapi/expressions/lexer.py +34 -31
  149. schemathesis/specs/openapi/expressions/nodes.py +97 -46
  150. schemathesis/specs/openapi/expressions/parser.py +35 -13
  151. schemathesis/specs/openapi/formats.py +122 -0
  152. schemathesis/specs/openapi/media_types.py +75 -0
  153. schemathesis/specs/openapi/negative/__init__.py +93 -73
  154. schemathesis/specs/openapi/negative/mutations.py +294 -103
  155. schemathesis/specs/openapi/negative/utils.py +0 -9
  156. schemathesis/specs/openapi/patterns.py +458 -0
  157. schemathesis/specs/openapi/references.py +60 -81
  158. schemathesis/specs/openapi/schemas.py +647 -666
  159. schemathesis/specs/openapi/serialization.py +53 -30
  160. schemathesis/specs/openapi/stateful/__init__.py +403 -68
  161. schemathesis/specs/openapi/stateful/control.py +87 -0
  162. schemathesis/specs/openapi/stateful/dependencies/__init__.py +232 -0
  163. schemathesis/specs/openapi/stateful/dependencies/inputs.py +428 -0
  164. schemathesis/specs/openapi/stateful/dependencies/models.py +341 -0
  165. schemathesis/specs/openapi/stateful/dependencies/naming.py +491 -0
  166. schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
  167. schemathesis/specs/openapi/stateful/dependencies/resources.py +339 -0
  168. schemathesis/specs/openapi/stateful/dependencies/schemas.py +447 -0
  169. schemathesis/specs/openapi/stateful/inference.py +254 -0
  170. schemathesis/specs/openapi/stateful/links.py +219 -78
  171. schemathesis/specs/openapi/types/__init__.py +3 -0
  172. schemathesis/specs/openapi/types/common.py +23 -0
  173. schemathesis/specs/openapi/types/v2.py +129 -0
  174. schemathesis/specs/openapi/types/v3.py +134 -0
  175. schemathesis/specs/openapi/utils.py +7 -6
  176. schemathesis/specs/openapi/warnings.py +75 -0
  177. schemathesis/transport/__init__.py +224 -0
  178. schemathesis/transport/asgi.py +26 -0
  179. schemathesis/transport/prepare.py +126 -0
  180. schemathesis/transport/requests.py +278 -0
  181. schemathesis/transport/serialization.py +329 -0
  182. schemathesis/transport/wsgi.py +175 -0
  183. schemathesis-4.4.2.dist-info/METADATA +213 -0
  184. schemathesis-4.4.2.dist-info/RECORD +192 -0
  185. {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info}/WHEEL +1 -1
  186. schemathesis-4.4.2.dist-info/entry_points.txt +6 -0
  187. {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info/licenses}/LICENSE +1 -1
  188. schemathesis/_compat.py +0 -57
  189. schemathesis/_hypothesis.py +0 -123
  190. schemathesis/auth.py +0 -214
  191. schemathesis/cli/callbacks.py +0 -240
  192. schemathesis/cli/cassettes.py +0 -351
  193. schemathesis/cli/context.py +0 -38
  194. schemathesis/cli/debug.py +0 -21
  195. schemathesis/cli/handlers.py +0 -11
  196. schemathesis/cli/junitxml.py +0 -41
  197. schemathesis/cli/options.py +0 -70
  198. schemathesis/cli/output/__init__.py +0 -1
  199. schemathesis/cli/output/default.py +0 -521
  200. schemathesis/cli/output/short.py +0 -40
  201. schemathesis/constants.py +0 -88
  202. schemathesis/exceptions.py +0 -257
  203. schemathesis/extra/_aiohttp.py +0 -27
  204. schemathesis/extra/_flask.py +0 -10
  205. schemathesis/extra/_server.py +0 -16
  206. schemathesis/extra/pytest_plugin.py +0 -251
  207. schemathesis/failures.py +0 -145
  208. schemathesis/fixups/__init__.py +0 -29
  209. schemathesis/fixups/fast_api.py +0 -30
  210. schemathesis/graphql.py +0 -5
  211. schemathesis/internal.py +0 -6
  212. schemathesis/lazy.py +0 -301
  213. schemathesis/models.py +0 -1113
  214. schemathesis/parameters.py +0 -91
  215. schemathesis/runner/__init__.py +0 -470
  216. schemathesis/runner/events.py +0 -242
  217. schemathesis/runner/impl/__init__.py +0 -3
  218. schemathesis/runner/impl/core.py +0 -791
  219. schemathesis/runner/impl/solo.py +0 -85
  220. schemathesis/runner/impl/threadpool.py +0 -367
  221. schemathesis/runner/serialization.py +0 -206
  222. schemathesis/serializers.py +0 -253
  223. schemathesis/service/__init__.py +0 -18
  224. schemathesis/service/auth.py +0 -10
  225. schemathesis/service/client.py +0 -62
  226. schemathesis/service/constants.py +0 -25
  227. schemathesis/service/events.py +0 -39
  228. schemathesis/service/handler.py +0 -46
  229. schemathesis/service/hosts.py +0 -74
  230. schemathesis/service/metadata.py +0 -42
  231. schemathesis/service/models.py +0 -21
  232. schemathesis/service/serialization.py +0 -184
  233. schemathesis/service/worker.py +0 -39
  234. schemathesis/specs/graphql/loaders.py +0 -215
  235. schemathesis/specs/openapi/constants.py +0 -7
  236. schemathesis/specs/openapi/expressions/context.py +0 -12
  237. schemathesis/specs/openapi/expressions/pointers.py +0 -29
  238. schemathesis/specs/openapi/filters.py +0 -44
  239. schemathesis/specs/openapi/links.py +0 -303
  240. schemathesis/specs/openapi/loaders.py +0 -453
  241. schemathesis/specs/openapi/parameters.py +0 -430
  242. schemathesis/specs/openapi/security.py +0 -129
  243. schemathesis/specs/openapi/validation.py +0 -24
  244. schemathesis/stateful.py +0 -358
  245. schemathesis/targets.py +0 -32
  246. schemathesis/types.py +0 -38
  247. schemathesis/utils.py +0 -475
  248. schemathesis-3.15.4.dist-info/METADATA +0 -202
  249. schemathesis-3.15.4.dist-info/RECORD +0 -99
  250. schemathesis-3.15.4.dist-info/entry_points.txt +0 -7
  251. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
@@ -0,0 +1,224 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from inspect import iscoroutinefunction
5
+ from typing import TYPE_CHECKING, Any, Callable, Generic, Iterator, TypeVar, Union
6
+
7
+ from schemathesis.core import media_types
8
+ from schemathesis.core.errors import SerializationNotPossible
9
+
10
+ if TYPE_CHECKING:
11
+ from schemathesis.core.transport import Response
12
+ from schemathesis.generation.case import Case
13
+
14
+
15
+ def get(app: Any) -> BaseTransport:
16
+ """Get transport to send the data to the application."""
17
+ from schemathesis.transport.asgi import ASGI_TRANSPORT
18
+ from schemathesis.transport.requests import REQUESTS_TRANSPORT
19
+ from schemathesis.transport.wsgi import WSGI_TRANSPORT
20
+
21
+ if app is None:
22
+ return REQUESTS_TRANSPORT
23
+ if iscoroutinefunction(app) or (
24
+ hasattr(app, "__call__") and iscoroutinefunction(app.__call__) # noqa: B004
25
+ ):
26
+ return ASGI_TRANSPORT
27
+ return WSGI_TRANSPORT
28
+
29
+
30
+ S = TypeVar("S", contravariant=True)
31
+
32
+
33
+ @dataclass
34
+ class SerializationContext:
35
+ """Context object passed to serializer functions.
36
+
37
+ It provides access to the generated test case and any related metadata.
38
+ """
39
+
40
+ case: Case
41
+ """The generated test case."""
42
+
43
+ __slots__ = ("case",)
44
+
45
+
46
+ Serializer = Callable[[SerializationContext, Any], Any]
47
+
48
+
49
+ class BaseTransport(Generic[S]):
50
+ """Base implementation with serializer registration."""
51
+
52
+ def __init__(self) -> None:
53
+ self._serializers: dict[str, Serializer] = {}
54
+
55
+ def serialize_case(self, case: Case, **kwargs: Any) -> dict[str, Any]:
56
+ """Prepare the case for sending."""
57
+ raise NotImplementedError
58
+
59
+ def send(self, case: Case, *, session: S | None = None, **kwargs: Any) -> Response:
60
+ """Send the case using this transport."""
61
+ raise NotImplementedError
62
+
63
+ def serializer(self, *media_types: str) -> Callable[[Serializer], Serializer]:
64
+ """Register a serializer for given media types."""
65
+
66
+ def decorator(func: Serializer) -> Serializer:
67
+ for media_type in media_types:
68
+ self._serializers[media_type] = func
69
+ return func
70
+
71
+ return decorator
72
+
73
+ def unregister_serializer(self, *media_types: str) -> None:
74
+ for media_type in media_types:
75
+ self._serializers.pop(media_type, None)
76
+
77
+ def _copy_serializers_from(self, transport: BaseTransport) -> None:
78
+ self._serializers.update(transport._serializers)
79
+
80
+ def get_first_matching_media_type(self, media_type: str) -> tuple[str, Serializer] | None:
81
+ return next(self.get_matching_media_types(media_type), None)
82
+
83
+ def get_matching_media_types(self, media_type: str) -> Iterator[tuple[str, Serializer]]:
84
+ """Get all registered media types matching the given media type."""
85
+ if media_type == "*/*":
86
+ # Shortcut to avoid comparing all values
87
+ yield from iter(self._serializers.items())
88
+ else:
89
+ main, sub = media_types.parse(media_type)
90
+ checks = [
91
+ media_types.is_json,
92
+ media_types.is_xml,
93
+ media_types.is_plain_text,
94
+ media_types.is_yaml,
95
+ ]
96
+ for registered_media_type, serializer in self._serializers.items():
97
+ # Try known variations for popular media types and fallback to comparison
98
+ if any(check(media_type) and check(registered_media_type) for check in checks):
99
+ yield media_type, serializer
100
+ else:
101
+ target_main, target_sub = media_types.parse(registered_media_type)
102
+ if main in ("*", target_main) and sub in ("*", target_sub):
103
+ yield registered_media_type, serializer
104
+
105
+ def _get_serializer(self, input_media_type: str) -> Serializer:
106
+ pair = self.get_first_matching_media_type(input_media_type)
107
+ if pair is None:
108
+ # This media type is set manually. Otherwise, it should have been rejected during the data generation
109
+ raise SerializationNotPossible.for_media_type(input_media_type)
110
+ return pair[1]
111
+
112
+
113
+ _Serializer = Callable[[SerializationContext, Any], Union[bytes, None]]
114
+
115
+
116
+ class SerializerRegistry:
117
+ """Registry for serializers with aliasing support."""
118
+
119
+ def __call__(self, *media_types: str) -> Callable[[_Serializer], None]:
120
+ """Register a serializer for specified media types on HTTP, ASGI, and WSGI transports.
121
+
122
+ Args:
123
+ *media_types: One or more MIME types (e.g., "application/json") this serializer handles.
124
+
125
+ Returns:
126
+ A decorator that wraps a function taking `(ctx: SerializationContext, value: Any)` and returning `bytes` for serialized body and `None` for omitting request body.
127
+
128
+ Example:
129
+ ```python
130
+ @schemathesis.serializer("text/csv")
131
+ def csv_serializer(ctx, value):
132
+ # Convert value to CSV bytes
133
+ return csv_bytes
134
+ ```
135
+
136
+ """
137
+
138
+ def register(func: _Serializer) -> None:
139
+ from schemathesis.transport.asgi import ASGI_TRANSPORT
140
+ from schemathesis.transport.requests import REQUESTS_TRANSPORT
141
+ from schemathesis.transport.wsgi import WSGI_TRANSPORT
142
+
143
+ @ASGI_TRANSPORT.serializer(*media_types)
144
+ @REQUESTS_TRANSPORT.serializer(*media_types)
145
+ @WSGI_TRANSPORT.serializer(*media_types)
146
+ def inner(ctx: SerializationContext, value: Any) -> dict[str, bytes]:
147
+ result = {}
148
+ serialized = func(ctx, value)
149
+ if serialized is not None:
150
+ result["data"] = serialized
151
+ return result
152
+
153
+ return register
154
+
155
+ def alias(self, target: str | list[str], source: str) -> None:
156
+ """Reuse an existing serializer for additional media types.
157
+
158
+ Register alias(es) for a built-in or previously registered serializer without
159
+ duplicating implementation.
160
+
161
+ Args:
162
+ target: Media type(s) to register as aliases
163
+ source: Existing media type whose serializer to reuse
164
+
165
+ Raises:
166
+ ValueError: If source media type has no registered serializer
167
+ ValueError: If target is empty
168
+
169
+ Example:
170
+ ```python
171
+ # Reuse built-in YAML serializer for custom media type
172
+ schemathesis.serializer.alias("application/custom+yaml", "application/yaml")
173
+
174
+ # Reuse built-in JSON serializer for vendor-specific type
175
+ schemathesis.serializer.alias("application/vnd.api+json", "application/json")
176
+
177
+ # Register multiple aliases at once
178
+ schemathesis.serializer.alias(
179
+ ["application/x-json", "text/json"],
180
+ "application/json"
181
+ )
182
+ ```
183
+
184
+ """
185
+ from schemathesis.transport.asgi import ASGI_TRANSPORT
186
+ from schemathesis.transport.requests import REQUESTS_TRANSPORT
187
+ from schemathesis.transport.wsgi import WSGI_TRANSPORT
188
+
189
+ if not source:
190
+ raise ValueError("Source media type cannot be empty")
191
+
192
+ targets = [target] if isinstance(target, str) else target
193
+
194
+ if not targets or any(not t for t in targets):
195
+ raise ValueError("Target media type cannot be empty")
196
+
197
+ # Get serializer from source (use requests transport as reference)
198
+ pair = REQUESTS_TRANSPORT.get_first_matching_media_type(source)
199
+ if pair is None:
200
+ raise ValueError(f"No serializer found for media type: {source}")
201
+
202
+ _, serializer_func = pair
203
+
204
+ # Register for all targets across all transports
205
+ for t in targets:
206
+ REQUESTS_TRANSPORT._serializers[t] = serializer_func
207
+ ASGI_TRANSPORT._serializers[t] = serializer_func
208
+ WSGI_TRANSPORT._serializers[t] = serializer_func
209
+
210
+
211
+ serializer = SerializerRegistry()
212
+ serializer.__doc__ = """Registry for serializers with decorator and aliasing support.
213
+
214
+ Use as a decorator to register custom serializers:
215
+
216
+ @schemathesis.serializer("text/csv")
217
+ def csv_serializer(ctx, value):
218
+ # Convert value to CSV bytes
219
+ return csv_bytes
220
+
221
+ Or use the alias method to reuse built-in serializers:
222
+
223
+ schemathesis.serializer.alias("application/custom+yaml", "application/yaml")
224
+ """
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from schemathesis.core.transport import Response
6
+ from schemathesis.generation.case import Case
7
+ from schemathesis.python import asgi
8
+ from schemathesis.transport.prepare import normalize_base_url
9
+ from schemathesis.transport.requests import REQUESTS_TRANSPORT, RequestsTransport
10
+
11
+ if TYPE_CHECKING:
12
+ import requests
13
+
14
+
15
+ class ASGITransport(RequestsTransport):
16
+ def send(self, case: Case, *, session: requests.Session | None = None, **kwargs: Any) -> Response:
17
+ if kwargs.get("base_url") is None:
18
+ kwargs["base_url"] = normalize_base_url(case.operation.base_url)
19
+ application = kwargs.pop("app", case.operation.app)
20
+
21
+ with asgi.get_client(application) as client:
22
+ return super().send(case, session=client, **kwargs)
23
+
24
+
25
+ ASGI_TRANSPORT = ASGITransport()
26
+ ASGI_TRANSPORT._copy_serializers_from(REQUESTS_TRANSPORT)
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import lru_cache
4
+ from typing import TYPE_CHECKING, Any, Mapping, cast
5
+ from urllib.parse import quote, unquote, urljoin, urlsplit, urlunsplit
6
+
7
+ from schemathesis.config import SanitizationConfig
8
+ from schemathesis.core import SCHEMATHESIS_TEST_CASE_HEADER, NotSet
9
+ from schemathesis.core.errors import InvalidSchema
10
+ from schemathesis.core.output.sanitization import sanitize_url, sanitize_value
11
+ from schemathesis.core.parameters import ParameterLocation
12
+ from schemathesis.core.transport import USER_AGENT
13
+ from schemathesis.generation.meta import CoveragePhaseData, CoverageScenario
14
+
15
+ if TYPE_CHECKING:
16
+ from requests import PreparedRequest
17
+ from requests.structures import CaseInsensitiveDict
18
+
19
+ from schemathesis.generation.case import Case
20
+
21
+
22
+ @lru_cache()
23
+ def get_default_headers() -> CaseInsensitiveDict:
24
+ from requests.utils import default_headers
25
+
26
+ headers = default_headers()
27
+ headers["User-Agent"] = USER_AGENT
28
+ return headers
29
+
30
+
31
+ def prepare_headers(case: Case, headers: dict[str, str] | None = None) -> CaseInsensitiveDict:
32
+ default_headers = get_default_headers().copy()
33
+ if case.headers:
34
+ default_headers.update(case.headers)
35
+ default_headers.setdefault(SCHEMATHESIS_TEST_CASE_HEADER, case.id)
36
+ if headers:
37
+ default_headers.update(headers)
38
+ return default_headers
39
+
40
+
41
+ def get_exclude_headers(case: Case) -> list[str]:
42
+ if (
43
+ case.meta is not None
44
+ and isinstance(case.meta.phase.data, CoveragePhaseData)
45
+ and case.meta.phase.data.scenario == CoverageScenario.MISSING_PARAMETER
46
+ and case.meta.phase.data.parameter_location == ParameterLocation.HEADER
47
+ and case.meta.phase.data.parameter is not None
48
+ ):
49
+ return [case.meta.phase.data.parameter]
50
+ return []
51
+
52
+
53
+ def prepare_url(case: Case, base_url: str | None) -> str:
54
+ """Prepare URL based on case type."""
55
+ from schemathesis.specs.graphql.schemas import GraphQLSchema
56
+
57
+ base_url = base_url or case.operation.base_url
58
+ assert base_url is not None
59
+ path = prepare_path(case.path, case.path_parameters)
60
+
61
+ if isinstance(case.operation.schema, GraphQLSchema):
62
+ parts = list(urlsplit(base_url))
63
+ parts[2] = path
64
+ return urlunsplit(parts)
65
+ else:
66
+ path = path.lstrip("/")
67
+ if not base_url.endswith("/"):
68
+ base_url += "/"
69
+ return unquote(urljoin(base_url, quote(path)))
70
+
71
+
72
+ def prepare_body(case: Case) -> list | dict[str, Any] | str | int | float | bool | bytes | NotSet:
73
+ """Prepare body based on case type."""
74
+ from schemathesis.specs.graphql.schemas import GraphQLSchema
75
+
76
+ if isinstance(case.operation.schema, GraphQLSchema):
77
+ return case.body if isinstance(case.body, (NotSet, bytes)) else {"query": case.body}
78
+ return case.body
79
+
80
+
81
+ def normalize_base_url(base_url: str | None) -> str | None:
82
+ """Normalize base URL by ensuring proper hostname for local URLs.
83
+
84
+ If URL has no hostname (typical for WSGI apps), adds "localhost" as default hostname.
85
+ """
86
+ if base_url is None:
87
+ return None
88
+ parts = urlsplit(base_url)
89
+ if not parts.hostname:
90
+ path = cast(str, parts.path or "")
91
+ return urlunsplit(("http", "localhost", path or "", "", ""))
92
+ return base_url
93
+
94
+
95
+ def prepare_path(path: str, parameters: dict[str, Any] | None) -> str:
96
+ try:
97
+ return path.format(**parameters or {})
98
+ except KeyError as exc:
99
+ # This may happen when a path template has a placeholder for variable "X", but parameter "X" is not defined
100
+ # in the parameters list.
101
+ # When `exc` is formatted, it is the missing key name in quotes. E.g. 'id'
102
+ raise InvalidSchema(f"Path parameter {exc} is not defined") from exc
103
+ except (IndexError, ValueError) as exc:
104
+ # A single unmatched `}` inside the path template may cause this
105
+ raise InvalidSchema(f"Malformed path template: `{path}`\n\n {exc}") from exc
106
+
107
+
108
+ def prepare_request(case: Case, headers: Mapping[str, Any] | None, *, config: SanitizationConfig) -> PreparedRequest:
109
+ import requests
110
+
111
+ from schemathesis.transport.requests import REQUESTS_TRANSPORT
112
+
113
+ base_url = normalize_base_url(case.operation.base_url)
114
+ kwargs = REQUESTS_TRANSPORT.serialize_case(case, base_url=base_url, headers=headers)
115
+ if config.enabled:
116
+ kwargs["url"] = sanitize_url(kwargs["url"], config=config)
117
+ kwargs["headers"] = dict(kwargs["headers"])
118
+ sanitize_value(kwargs["headers"], config=config)
119
+ if kwargs["cookies"]:
120
+ kwargs["cookies"] = dict(kwargs["cookies"])
121
+ sanitize_value(kwargs["cookies"], config=config)
122
+ if kwargs["params"]:
123
+ kwargs["params"] = dict(kwargs["params"])
124
+ sanitize_value(kwargs["params"], config=config)
125
+
126
+ return requests.Request(**kwargs).prepare()
@@ -0,0 +1,278 @@
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, MutableMapping
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 merge_at
13
+ from schemathesis.core.transport import DEFAULT_RESPONSE_TIMEOUT, Response
14
+ from schemathesis.generation.overrides import Override
15
+ from schemathesis.transport import BaseTransport, SerializationContext
16
+ from schemathesis.transport.prepare import get_exclude_headers, prepare_body, prepare_headers, prepare_url
17
+ from schemathesis.transport.serialization import Binary, serialize_binary, serialize_json, serialize_xml, serialize_yaml
18
+
19
+ if TYPE_CHECKING:
20
+ import requests
21
+
22
+ from schemathesis.generation.case import Case
23
+
24
+
25
+ class RequestsTransport(BaseTransport["requests.Session"]):
26
+ def serialize_case(self, case: Case, **kwargs: Any) -> dict[str, Any]:
27
+ base_url = kwargs.get("base_url")
28
+ headers = kwargs.get("headers")
29
+ params = kwargs.get("params")
30
+ cookies = kwargs.get("cookies")
31
+
32
+ final_headers = prepare_headers(case, headers)
33
+
34
+ media_type = case.media_type
35
+
36
+ # Set content type header if needed
37
+ if media_type and media_type != "multipart/form-data" and not isinstance(case.body, NotSet):
38
+ if "content-type" not in final_headers:
39
+ final_headers["Content-Type"] = media_type
40
+
41
+ url = prepare_url(case, base_url)
42
+
43
+ # Handle serialization
44
+ if not isinstance(case.body, NotSet) and media_type is not None:
45
+ serializer = self._get_serializer(media_type)
46
+ context = SerializationContext(case=case)
47
+ extra = serializer(context, prepare_body(case))
48
+ else:
49
+ extra = {}
50
+
51
+ if case._auth is not None:
52
+ extra["auth"] = case._auth
53
+
54
+ # Additional headers from serializer
55
+ additional_headers = extra.pop("headers", None)
56
+ if additional_headers:
57
+ for key, value in additional_headers.items():
58
+ final_headers.setdefault(key, value)
59
+
60
+ params = case.query
61
+
62
+ # Replace empty dictionaries with empty strings, so the parameters actually present in the query string
63
+ if any(value == {} for value in (params or {}).values()):
64
+ params = dict(params)
65
+ for key, value in params.items():
66
+ if value == {}:
67
+ params[key] = ""
68
+
69
+ data = {
70
+ "method": case.method,
71
+ "url": url,
72
+ "cookies": case.cookies,
73
+ "headers": final_headers,
74
+ "params": params,
75
+ **extra,
76
+ }
77
+
78
+ if params is not None:
79
+ merge_at(data, "params", params)
80
+ if cookies is not None:
81
+ merge_at(data, "cookies", cookies)
82
+
83
+ excluded_headers = get_exclude_headers(case)
84
+ for name in excluded_headers:
85
+ data["headers"].pop(name, None)
86
+
87
+ return data
88
+
89
+ def send(self, case: Case, *, session: requests.Session | None = None, **kwargs: Any) -> Response:
90
+ import requests
91
+
92
+ config = case.operation.schema.config
93
+
94
+ max_redirects = kwargs.pop("max_redirects", None) or config.max_redirects_for(operation=case.operation)
95
+ timeout = config.request_timeout_for(operation=case.operation)
96
+ verify = config.tls_verify_for(operation=case.operation)
97
+ cert = config.request_cert_for(operation=case.operation)
98
+
99
+ if session is not None and session.headers:
100
+ # These headers are explicitly provided via config or CLI args.
101
+ # They have lower priority than ones provided via `kwargs`
102
+ headers = kwargs.setdefault("headers", {}) or {}
103
+ for name, value in session.headers.items():
104
+ headers.setdefault(name, value)
105
+ kwargs["headers"] = headers
106
+
107
+ data = self.serialize_case(case, **kwargs)
108
+
109
+ if verify is not None:
110
+ data.setdefault("verify", verify)
111
+ if timeout is not None:
112
+ data.setdefault("timeout", timeout)
113
+ if cert is not None:
114
+ data.setdefault("cert", cert)
115
+
116
+ kwargs.pop("base_url", None)
117
+ for key, value in kwargs.items():
118
+ if key not in ("headers", "cookies", "params") or key not in data:
119
+ data[key] = value
120
+ data.setdefault("timeout", DEFAULT_RESPONSE_TIMEOUT)
121
+
122
+ current_session_headers: MutableMapping[str, Any] = {}
123
+ current_session_auth = None
124
+
125
+ if session is None:
126
+ validate_vanilla_requests_kwargs(data)
127
+ session = requests.Session()
128
+ close_session = True
129
+ else:
130
+ current_session_headers = session.headers
131
+ if isinstance(session.auth, tuple):
132
+ excluded_headers = get_exclude_headers(case)
133
+ if "Authorization" in excluded_headers:
134
+ current_session_auth = session.auth
135
+ session.auth = None
136
+ close_session = False
137
+ if max_redirects is not None:
138
+ session.max_redirects = max_redirects
139
+ session.headers = {}
140
+
141
+ verify = data.get("verify", True)
142
+
143
+ try:
144
+ rate_limit = config.rate_limit_for(operation=case.operation)
145
+ with ratelimit(rate_limit, config.base_url):
146
+ response = session.request(**data)
147
+ return Response.from_requests(
148
+ response,
149
+ verify=verify,
150
+ _override=Override(
151
+ query=kwargs.get("params") or {},
152
+ headers=kwargs.get("headers") or {},
153
+ cookies=kwargs.get("cookies") or {},
154
+ path_parameters={},
155
+ body={},
156
+ ),
157
+ )
158
+ finally:
159
+ session.headers = current_session_headers
160
+ if current_session_auth is not None:
161
+ session.auth = current_session_auth
162
+ if close_session:
163
+ session.close()
164
+
165
+
166
+ def validate_vanilla_requests_kwargs(data: dict[str, Any]) -> None:
167
+ """Check arguments for `requests.Session.request`.
168
+
169
+ Some arguments can be valid for cases like ASGI integration, but at the same time they won't work for the regular
170
+ `requests` calls. In such cases we need to avoid an obscure error message, that comes from `requests`.
171
+ """
172
+ url = data["url"]
173
+ if not urlparse(url).netloc:
174
+ stack = inspect.stack()
175
+ method_name = "call"
176
+ for frame in stack[1:]:
177
+ if frame.function == "call_and_validate":
178
+ method_name = "call_and_validate"
179
+ break
180
+ raise RuntimeError(
181
+ "The `base_url` argument is required when specifying a schema via a file, so Schemathesis knows where to send the data. \n"
182
+ f"Pass `base_url` either to the `schemathesis.openapi.from_*` loader or to the `Case.{method_name}`.\n"
183
+ f"If you use the ASGI integration, please supply your test client "
184
+ f"as the `session` argument to `call`.\nURL: {url}"
185
+ )
186
+
187
+
188
+ REQUESTS_TRANSPORT = RequestsTransport()
189
+
190
+
191
+ @REQUESTS_TRANSPORT.serializer("application/json", "text/json")
192
+ def json_serializer(ctx: SerializationContext, value: Any) -> dict[str, Any]:
193
+ return serialize_json(value)
194
+
195
+
196
+ @REQUESTS_TRANSPORT.serializer(
197
+ "text/yaml", "text/x-yaml", "text/vnd.yaml", "text/yml", "application/yaml", "application/x-yaml"
198
+ )
199
+ def yaml_serializer(ctx: SerializationContext, value: Any) -> dict[str, Any]:
200
+ return serialize_yaml(value)
201
+
202
+
203
+ def _should_coerce_to_bytes(item: Any) -> bool:
204
+ """Whether the item should be converted to bytes."""
205
+ # These types are OK in forms, others should be coerced to bytes
206
+ return isinstance(item, Binary) or not isinstance(item, (bytes, str, int))
207
+
208
+
209
+ def _prepare_form_data(data: dict[str, Any]) -> dict[str, Any]:
210
+ """Make the generated data suitable for sending as multipart.
211
+
212
+ If the schema is loose, Schemathesis can generate data that can't be sent as multipart. In these cases,
213
+ we convert it to bytes and send it as-is, ignoring any conversion errors.
214
+
215
+ NOTE. This behavior might change in the future.
216
+ """
217
+ for name, value in data.items():
218
+ if isinstance(value, list):
219
+ data[name] = [serialize_binary(item) if _should_coerce_to_bytes(item) else item for item in value]
220
+ elif _should_coerce_to_bytes(value):
221
+ data[name] = serialize_binary(value)
222
+ return data
223
+
224
+
225
+ def choose_boundary() -> str:
226
+ """Random boundary name."""
227
+ return binascii.hexlify(os.urandom(16)).decode("ascii")
228
+
229
+
230
+ def _encode_multipart(value: Any, boundary: str) -> bytes:
231
+ """Encode any value as multipart.
232
+
233
+ NOTE. It doesn't aim to be 100% correct multipart payload, but rather a way to send data which is not intended to
234
+ be used as multipart, in cases when the API schema dictates so.
235
+ """
236
+ # For such cases we stringify the value and wrap it to a randomly-generated boundary
237
+ body = BytesIO()
238
+ body.write(f"--{boundary}\r\n".encode())
239
+ body.write(str(value).encode())
240
+ body.write(f"--{boundary}--\r\n".encode("latin-1"))
241
+ return body.getvalue()
242
+
243
+
244
+ @REQUESTS_TRANSPORT.serializer("multipart/form-data", "multipart/mixed")
245
+ def multipart_serializer(ctx: SerializationContext, value: Any) -> dict[str, Any]:
246
+ if isinstance(value, bytes):
247
+ return {"data": value}
248
+ if isinstance(value, dict):
249
+ multipart = _prepare_form_data(value)
250
+ files, data = ctx.case.operation.prepare_multipart(multipart)
251
+ return {"files": files, "data": data}
252
+ # Uncommon schema. For example - `{"type": "string"}`
253
+ boundary = choose_boundary()
254
+ raw_data = _encode_multipart(value, boundary)
255
+ content_type = f"multipart/form-data; boundary={boundary}"
256
+ return {"data": raw_data, "headers": {"Content-Type": content_type}}
257
+
258
+
259
+ @REQUESTS_TRANSPORT.serializer("application/xml", "text/xml")
260
+ def xml_serializer(ctx: SerializationContext, value: Any) -> dict[str, Any]:
261
+ return serialize_xml(ctx.case, value)
262
+
263
+
264
+ @REQUESTS_TRANSPORT.serializer("application/x-www-form-urlencoded")
265
+ def urlencoded_serializer(ctx: SerializationContext, value: Any) -> dict[str, Any]:
266
+ return {"data": value}
267
+
268
+
269
+ @REQUESTS_TRANSPORT.serializer("text/plain")
270
+ def text_serializer(ctx: SerializationContext, value: Any) -> dict[str, Any]:
271
+ if isinstance(value, bytes):
272
+ return {"data": value}
273
+ return {"data": str(value).encode("utf8")}
274
+
275
+
276
+ @REQUESTS_TRANSPORT.serializer("application/octet-stream")
277
+ def binary_serializer(ctx: SerializationContext, value: Any) -> dict[str, Any]:
278
+ return {"data": serialize_binary(value)}