schemathesis 3.39.16__py3-none-any.whl → 4.0.0__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 (255) hide show
  1. schemathesis/__init__.py +41 -79
  2. schemathesis/auths.py +111 -122
  3. schemathesis/checks.py +169 -60
  4. schemathesis/cli/__init__.py +15 -2117
  5. schemathesis/cli/commands/__init__.py +85 -0
  6. schemathesis/cli/commands/data.py +10 -0
  7. schemathesis/cli/commands/run/__init__.py +590 -0
  8. schemathesis/cli/commands/run/context.py +204 -0
  9. schemathesis/cli/commands/run/events.py +60 -0
  10. schemathesis/cli/commands/run/executor.py +157 -0
  11. schemathesis/cli/commands/run/filters.py +53 -0
  12. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  13. schemathesis/cli/commands/run/handlers/base.py +18 -0
  14. schemathesis/cli/commands/run/handlers/cassettes.py +474 -0
  15. schemathesis/cli/commands/run/handlers/junitxml.py +55 -0
  16. schemathesis/cli/commands/run/handlers/output.py +1628 -0
  17. schemathesis/cli/commands/run/loaders.py +114 -0
  18. schemathesis/cli/commands/run/validation.py +246 -0
  19. schemathesis/cli/constants.py +5 -58
  20. schemathesis/cli/core.py +19 -0
  21. schemathesis/cli/ext/fs.py +16 -0
  22. schemathesis/cli/ext/groups.py +84 -0
  23. schemathesis/cli/{options.py → ext/options.py} +36 -34
  24. schemathesis/config/__init__.py +189 -0
  25. schemathesis/config/_auth.py +51 -0
  26. schemathesis/config/_checks.py +268 -0
  27. schemathesis/config/_diff_base.py +99 -0
  28. schemathesis/config/_env.py +21 -0
  29. schemathesis/config/_error.py +156 -0
  30. schemathesis/config/_generation.py +149 -0
  31. schemathesis/config/_health_check.py +24 -0
  32. schemathesis/config/_operations.py +327 -0
  33. schemathesis/config/_output.py +171 -0
  34. schemathesis/config/_parameters.py +19 -0
  35. schemathesis/config/_phases.py +187 -0
  36. schemathesis/config/_projects.py +527 -0
  37. schemathesis/config/_rate_limit.py +17 -0
  38. schemathesis/config/_report.py +120 -0
  39. schemathesis/config/_validator.py +9 -0
  40. schemathesis/config/_warnings.py +25 -0
  41. schemathesis/config/schema.json +885 -0
  42. schemathesis/core/__init__.py +67 -0
  43. schemathesis/core/compat.py +32 -0
  44. schemathesis/core/control.py +2 -0
  45. schemathesis/core/curl.py +58 -0
  46. schemathesis/core/deserialization.py +65 -0
  47. schemathesis/core/errors.py +459 -0
  48. schemathesis/core/failures.py +315 -0
  49. schemathesis/core/fs.py +19 -0
  50. schemathesis/core/hooks.py +20 -0
  51. schemathesis/core/loaders.py +104 -0
  52. schemathesis/core/marks.py +66 -0
  53. schemathesis/{transports/content_types.py → core/media_types.py} +14 -12
  54. schemathesis/core/output/__init__.py +46 -0
  55. schemathesis/core/output/sanitization.py +54 -0
  56. schemathesis/{throttling.py → core/rate_limit.py} +16 -17
  57. schemathesis/core/registries.py +31 -0
  58. schemathesis/core/transforms.py +113 -0
  59. schemathesis/core/transport.py +223 -0
  60. schemathesis/core/validation.py +54 -0
  61. schemathesis/core/version.py +7 -0
  62. schemathesis/engine/__init__.py +28 -0
  63. schemathesis/engine/context.py +118 -0
  64. schemathesis/engine/control.py +36 -0
  65. schemathesis/engine/core.py +169 -0
  66. schemathesis/engine/errors.py +464 -0
  67. schemathesis/engine/events.py +258 -0
  68. schemathesis/engine/phases/__init__.py +88 -0
  69. schemathesis/{runner → engine/phases}/probes.py +52 -68
  70. schemathesis/engine/phases/stateful/__init__.py +68 -0
  71. schemathesis/engine/phases/stateful/_executor.py +356 -0
  72. schemathesis/engine/phases/stateful/context.py +85 -0
  73. schemathesis/engine/phases/unit/__init__.py +212 -0
  74. schemathesis/engine/phases/unit/_executor.py +416 -0
  75. schemathesis/engine/phases/unit/_pool.py +82 -0
  76. schemathesis/engine/recorder.py +247 -0
  77. schemathesis/errors.py +43 -0
  78. schemathesis/filters.py +17 -98
  79. schemathesis/generation/__init__.py +5 -33
  80. schemathesis/generation/case.py +317 -0
  81. schemathesis/generation/coverage.py +282 -175
  82. schemathesis/generation/hypothesis/__init__.py +36 -0
  83. schemathesis/generation/hypothesis/builder.py +800 -0
  84. schemathesis/generation/{_hypothesis.py → hypothesis/examples.py} +2 -11
  85. schemathesis/generation/hypothesis/given.py +66 -0
  86. schemathesis/generation/hypothesis/reporting.py +14 -0
  87. schemathesis/generation/hypothesis/strategies.py +16 -0
  88. schemathesis/generation/meta.py +115 -0
  89. schemathesis/generation/metrics.py +93 -0
  90. schemathesis/generation/modes.py +20 -0
  91. schemathesis/generation/overrides.py +116 -0
  92. schemathesis/generation/stateful/__init__.py +37 -0
  93. schemathesis/generation/stateful/state_machine.py +278 -0
  94. schemathesis/graphql/__init__.py +15 -0
  95. schemathesis/graphql/checks.py +109 -0
  96. schemathesis/graphql/loaders.py +284 -0
  97. schemathesis/hooks.py +80 -101
  98. schemathesis/openapi/__init__.py +13 -0
  99. schemathesis/openapi/checks.py +455 -0
  100. schemathesis/openapi/generation/__init__.py +0 -0
  101. schemathesis/openapi/generation/filters.py +72 -0
  102. schemathesis/openapi/loaders.py +313 -0
  103. schemathesis/pytest/__init__.py +5 -0
  104. schemathesis/pytest/control_flow.py +7 -0
  105. schemathesis/pytest/lazy.py +281 -0
  106. schemathesis/pytest/loaders.py +36 -0
  107. schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +128 -108
  108. schemathesis/python/__init__.py +0 -0
  109. schemathesis/python/asgi.py +12 -0
  110. schemathesis/python/wsgi.py +12 -0
  111. schemathesis/schemas.py +537 -273
  112. schemathesis/specs/graphql/__init__.py +0 -1
  113. schemathesis/specs/graphql/_cache.py +1 -2
  114. schemathesis/specs/graphql/scalars.py +42 -6
  115. schemathesis/specs/graphql/schemas.py +141 -137
  116. schemathesis/specs/graphql/validation.py +11 -17
  117. schemathesis/specs/openapi/__init__.py +6 -1
  118. schemathesis/specs/openapi/_cache.py +1 -2
  119. schemathesis/specs/openapi/_hypothesis.py +142 -156
  120. schemathesis/specs/openapi/checks.py +368 -257
  121. schemathesis/specs/openapi/converter.py +4 -4
  122. schemathesis/specs/openapi/definitions.py +1 -1
  123. schemathesis/specs/openapi/examples.py +23 -21
  124. schemathesis/specs/openapi/expressions/__init__.py +31 -19
  125. schemathesis/specs/openapi/expressions/extractors.py +1 -4
  126. schemathesis/specs/openapi/expressions/lexer.py +1 -1
  127. schemathesis/specs/openapi/expressions/nodes.py +36 -41
  128. schemathesis/specs/openapi/expressions/parser.py +1 -1
  129. schemathesis/specs/openapi/formats.py +35 -7
  130. schemathesis/specs/openapi/media_types.py +53 -12
  131. schemathesis/specs/openapi/negative/__init__.py +7 -4
  132. schemathesis/specs/openapi/negative/mutations.py +6 -5
  133. schemathesis/specs/openapi/parameters.py +7 -10
  134. schemathesis/specs/openapi/patterns.py +94 -31
  135. schemathesis/specs/openapi/references.py +12 -53
  136. schemathesis/specs/openapi/schemas.py +233 -307
  137. schemathesis/specs/openapi/security.py +1 -1
  138. schemathesis/specs/openapi/serialization.py +12 -6
  139. schemathesis/specs/openapi/stateful/__init__.py +268 -133
  140. schemathesis/specs/openapi/stateful/control.py +87 -0
  141. schemathesis/specs/openapi/stateful/links.py +209 -0
  142. schemathesis/transport/__init__.py +142 -0
  143. schemathesis/transport/asgi.py +26 -0
  144. schemathesis/transport/prepare.py +124 -0
  145. schemathesis/transport/requests.py +244 -0
  146. schemathesis/{_xml.py → transport/serialization.py} +69 -11
  147. schemathesis/transport/wsgi.py +171 -0
  148. schemathesis-4.0.0.dist-info/METADATA +204 -0
  149. schemathesis-4.0.0.dist-info/RECORD +164 -0
  150. {schemathesis-3.39.16.dist-info → schemathesis-4.0.0.dist-info}/entry_points.txt +1 -1
  151. {schemathesis-3.39.16.dist-info → schemathesis-4.0.0.dist-info}/licenses/LICENSE +1 -1
  152. schemathesis/_compat.py +0 -74
  153. schemathesis/_dependency_versions.py +0 -19
  154. schemathesis/_hypothesis.py +0 -717
  155. schemathesis/_override.py +0 -50
  156. schemathesis/_patches.py +0 -21
  157. schemathesis/_rate_limiter.py +0 -7
  158. schemathesis/cli/callbacks.py +0 -466
  159. schemathesis/cli/cassettes.py +0 -561
  160. schemathesis/cli/context.py +0 -75
  161. schemathesis/cli/debug.py +0 -27
  162. schemathesis/cli/handlers.py +0 -19
  163. schemathesis/cli/junitxml.py +0 -124
  164. schemathesis/cli/output/__init__.py +0 -1
  165. schemathesis/cli/output/default.py +0 -920
  166. schemathesis/cli/output/short.py +0 -59
  167. schemathesis/cli/reporting.py +0 -79
  168. schemathesis/cli/sanitization.py +0 -26
  169. schemathesis/code_samples.py +0 -151
  170. schemathesis/constants.py +0 -54
  171. schemathesis/contrib/__init__.py +0 -11
  172. schemathesis/contrib/openapi/__init__.py +0 -11
  173. schemathesis/contrib/openapi/fill_missing_examples.py +0 -24
  174. schemathesis/contrib/openapi/formats/__init__.py +0 -9
  175. schemathesis/contrib/openapi/formats/uuid.py +0 -16
  176. schemathesis/contrib/unique_data.py +0 -41
  177. schemathesis/exceptions.py +0 -571
  178. schemathesis/experimental/__init__.py +0 -109
  179. schemathesis/extra/_aiohttp.py +0 -28
  180. schemathesis/extra/_flask.py +0 -13
  181. schemathesis/extra/_server.py +0 -18
  182. schemathesis/failures.py +0 -284
  183. schemathesis/fixups/__init__.py +0 -37
  184. schemathesis/fixups/fast_api.py +0 -41
  185. schemathesis/fixups/utf8_bom.py +0 -28
  186. schemathesis/generation/_methods.py +0 -44
  187. schemathesis/graphql.py +0 -3
  188. schemathesis/internal/__init__.py +0 -7
  189. schemathesis/internal/checks.py +0 -86
  190. schemathesis/internal/copy.py +0 -32
  191. schemathesis/internal/datetime.py +0 -5
  192. schemathesis/internal/deprecation.py +0 -37
  193. schemathesis/internal/diff.py +0 -15
  194. schemathesis/internal/extensions.py +0 -27
  195. schemathesis/internal/jsonschema.py +0 -36
  196. schemathesis/internal/output.py +0 -68
  197. schemathesis/internal/transformation.py +0 -26
  198. schemathesis/internal/validation.py +0 -34
  199. schemathesis/lazy.py +0 -474
  200. schemathesis/loaders.py +0 -122
  201. schemathesis/models.py +0 -1341
  202. schemathesis/parameters.py +0 -90
  203. schemathesis/runner/__init__.py +0 -605
  204. schemathesis/runner/events.py +0 -389
  205. schemathesis/runner/impl/__init__.py +0 -3
  206. schemathesis/runner/impl/context.py +0 -88
  207. schemathesis/runner/impl/core.py +0 -1280
  208. schemathesis/runner/impl/solo.py +0 -80
  209. schemathesis/runner/impl/threadpool.py +0 -391
  210. schemathesis/runner/serialization.py +0 -544
  211. schemathesis/sanitization.py +0 -252
  212. schemathesis/serializers.py +0 -328
  213. schemathesis/service/__init__.py +0 -18
  214. schemathesis/service/auth.py +0 -11
  215. schemathesis/service/ci.py +0 -202
  216. schemathesis/service/client.py +0 -133
  217. schemathesis/service/constants.py +0 -38
  218. schemathesis/service/events.py +0 -61
  219. schemathesis/service/extensions.py +0 -224
  220. schemathesis/service/hosts.py +0 -111
  221. schemathesis/service/metadata.py +0 -71
  222. schemathesis/service/models.py +0 -258
  223. schemathesis/service/report.py +0 -255
  224. schemathesis/service/serialization.py +0 -173
  225. schemathesis/service/usage.py +0 -66
  226. schemathesis/specs/graphql/loaders.py +0 -364
  227. schemathesis/specs/openapi/expressions/context.py +0 -16
  228. schemathesis/specs/openapi/links.py +0 -389
  229. schemathesis/specs/openapi/loaders.py +0 -707
  230. schemathesis/specs/openapi/stateful/statistic.py +0 -198
  231. schemathesis/specs/openapi/stateful/types.py +0 -14
  232. schemathesis/specs/openapi/validation.py +0 -26
  233. schemathesis/stateful/__init__.py +0 -147
  234. schemathesis/stateful/config.py +0 -97
  235. schemathesis/stateful/context.py +0 -135
  236. schemathesis/stateful/events.py +0 -274
  237. schemathesis/stateful/runner.py +0 -309
  238. schemathesis/stateful/sink.py +0 -68
  239. schemathesis/stateful/state_machine.py +0 -328
  240. schemathesis/stateful/statistic.py +0 -22
  241. schemathesis/stateful/validation.py +0 -100
  242. schemathesis/targets.py +0 -77
  243. schemathesis/transports/__init__.py +0 -369
  244. schemathesis/transports/asgi.py +0 -7
  245. schemathesis/transports/auth.py +0 -38
  246. schemathesis/transports/headers.py +0 -36
  247. schemathesis/transports/responses.py +0 -57
  248. schemathesis/types.py +0 -44
  249. schemathesis/utils.py +0 -164
  250. schemathesis-3.39.16.dist-info/METADATA +0 -293
  251. schemathesis-3.39.16.dist-info/RECORD +0 -160
  252. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
  253. /schemathesis/{_lazy_import.py → core/lazy_import.py} +0 -0
  254. /schemathesis/{internal → core}/result.py +0 -0
  255. {schemathesis-3.39.16.dist-info → schemathesis-4.0.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,209 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from functools import lru_cache
5
+ from typing import Any, Callable, Generator, Literal, cast
6
+
7
+ from schemathesis.core import NOT_SET, NotSet
8
+ from schemathesis.core.errors import InvalidTransition, OperationNotFound, TransitionValidationError
9
+ from schemathesis.core.result import Err, Ok, Result
10
+ from schemathesis.generation.stateful.state_machine import ExtractedParam, StepOutput, Transition
11
+ from schemathesis.schemas import APIOperation
12
+ from schemathesis.specs.openapi import expressions
13
+ from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
14
+ from schemathesis.specs.openapi.references import RECURSION_DEPTH_LIMIT
15
+
16
+ SCHEMATHESIS_LINK_EXTENSION = "x-schemathesis"
17
+ ParameterLocation = Literal["path", "query", "header", "cookie", "body"]
18
+
19
+
20
+ @dataclass
21
+ class NormalizedParameter:
22
+ """Processed link parameter with resolved container information."""
23
+
24
+ location: ParameterLocation | None
25
+ name: str
26
+ expression: str
27
+ container_name: str
28
+
29
+ __slots__ = ("location", "name", "expression", "container_name")
30
+
31
+
32
+ @dataclass(repr=False)
33
+ class OpenApiLink:
34
+ """Represents an OpenAPI link between operations."""
35
+
36
+ name: str
37
+ status_code: str
38
+ source: APIOperation
39
+ target: APIOperation
40
+ parameters: list[NormalizedParameter]
41
+ body: dict[str, Any] | NotSet
42
+ merge_body: bool
43
+
44
+ __slots__ = ("name", "status_code", "source", "target", "parameters", "body", "merge_body", "_cached_extract")
45
+
46
+ def __init__(self, name: str, status_code: str, definition: dict[str, Any], source: APIOperation):
47
+ from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
48
+
49
+ self.name = name
50
+ self.status_code = status_code
51
+ self.source = source
52
+ assert isinstance(source.schema, BaseOpenAPISchema)
53
+ errors = []
54
+
55
+ get_operation: Callable[[str], APIOperation]
56
+ if "operationId" in definition:
57
+ operation_reference = definition["operationId"]
58
+ get_operation = source.schema.get_operation_by_id
59
+ else:
60
+ operation_reference = definition["operationRef"]
61
+ get_operation = source.schema.get_operation_by_reference
62
+
63
+ try:
64
+ self.target = get_operation(operation_reference)
65
+ target = self.target.label
66
+ except OperationNotFound:
67
+ target = operation_reference
68
+ errors.append(TransitionValidationError(f"Operation '{operation_reference}' not found"))
69
+
70
+ extension = definition.get(SCHEMATHESIS_LINK_EXTENSION)
71
+ self.parameters = self._normalize_parameters(definition.get("parameters", {}), errors)
72
+ self.body = definition.get("requestBody", NOT_SET)
73
+ self.merge_body = extension.get("merge_body", True) if extension else True
74
+
75
+ if errors:
76
+ raise InvalidTransition(
77
+ name=self.name,
78
+ source=self.source.label,
79
+ target=target,
80
+ status_code=self.status_code,
81
+ errors=errors,
82
+ )
83
+
84
+ self._cached_extract = lru_cache(8)(self._extract_impl)
85
+
86
+ def _normalize_parameters(
87
+ self, parameters: dict[str, str], errors: list[TransitionValidationError]
88
+ ) -> list[NormalizedParameter]:
89
+ """Process link parameters and resolve their container locations.
90
+
91
+ Handles both explicit locations (e.g., "path.id") and implicit ones resolved from target operation.
92
+ """
93
+ result = []
94
+ for parameter, expression in parameters.items():
95
+ location: ParameterLocation | None
96
+ try:
97
+ # The parameter name is prefixed with its location. Example: `path.id`
98
+ _location, name = tuple(parameter.split("."))
99
+ location = cast(ParameterLocation, _location)
100
+ except ValueError:
101
+ location = None
102
+ name = parameter
103
+
104
+ if isinstance(expression, str):
105
+ try:
106
+ parsed = expressions.parser.parse(expression)
107
+ # Find NonBodyRequest nodes that reference source parameters
108
+ for node in parsed:
109
+ if isinstance(node, expressions.nodes.NonBodyRequest):
110
+ # Check if parameter exists in source operation
111
+ if not any(
112
+ p.name == node.parameter and p.location == node.location
113
+ for p in self.source.iter_parameters()
114
+ ):
115
+ errors.append(
116
+ TransitionValidationError(
117
+ f"Expression `{expression}` references non-existent {node.location} parameter "
118
+ f"`{node.parameter}` in `{self.source.label}`"
119
+ )
120
+ )
121
+ except Exception as exc:
122
+ errors.append(TransitionValidationError(str(exc)))
123
+
124
+ if hasattr(self, "target"):
125
+ try:
126
+ container_name = self._get_parameter_container(location, name)
127
+ except TransitionValidationError as exc:
128
+ errors.append(exc)
129
+ continue
130
+ else:
131
+ continue
132
+ result.append(NormalizedParameter(location, name, expression, container_name))
133
+ return result
134
+
135
+ def _get_parameter_container(self, location: ParameterLocation | None, name: str) -> str:
136
+ """Resolve parameter container either from explicit location or by looking up in target operation."""
137
+ if location:
138
+ return LOCATION_TO_CONTAINER[location]
139
+
140
+ for param in self.target.iter_parameters():
141
+ if param.name == name:
142
+ return LOCATION_TO_CONTAINER[param.location]
143
+ raise TransitionValidationError(f"Parameter `{name}` is not defined in API operation `{self.target.label}`")
144
+
145
+ def extract(self, output: StepOutput) -> Transition:
146
+ return self._cached_extract(StepOutputWrapper(output))
147
+
148
+ def _extract_impl(self, wrapper: StepOutputWrapper) -> Transition:
149
+ output = wrapper.output
150
+ return Transition(
151
+ id=f"{self.source.label} -> [{self.status_code}] {self.name} -> {self.target.label}",
152
+ parent_id=output.case.id,
153
+ parameters=self.extract_parameters(output),
154
+ request_body=self.extract_body(output),
155
+ )
156
+
157
+ def extract_parameters(self, output: StepOutput) -> dict[str, dict[str, ExtractedParam]]:
158
+ """Extract parameters using runtime expressions.
159
+
160
+ Returns a two-level dictionary: container -> parameter name -> extracted value
161
+ """
162
+ extracted: dict[str, dict[str, ExtractedParam]] = {}
163
+ for parameter in self.parameters:
164
+ container = extracted.setdefault(parameter.container_name, {})
165
+ value: Result[Any, Exception]
166
+ try:
167
+ value = Ok(expressions.evaluate(parameter.expression, output))
168
+ except Exception as exc:
169
+ value = Err(exc)
170
+ container[parameter.name] = ExtractedParam(definition=parameter.expression, value=value)
171
+ return extracted
172
+
173
+ def extract_body(self, output: StepOutput) -> ExtractedParam | None:
174
+ if not isinstance(self.body, NotSet):
175
+ value: Result[Any, Exception]
176
+ try:
177
+ value = Ok(expressions.evaluate(self.body, output, evaluate_nested=True))
178
+ except Exception as exc:
179
+ value = Err(exc)
180
+ return ExtractedParam(definition=self.body, value=value)
181
+ return None
182
+
183
+
184
+ @dataclass
185
+ class StepOutputWrapper:
186
+ """Wrapper for StepOutput that uses only case_id for hash-based caching."""
187
+
188
+ output: StepOutput
189
+ __slots__ = ("output",)
190
+
191
+ def __hash__(self) -> int:
192
+ return hash(self.output.case.id)
193
+
194
+ def __eq__(self, other: object) -> bool:
195
+ assert isinstance(other, StepOutputWrapper)
196
+ return self.output.case.id == other.output.case.id
197
+
198
+
199
+ def get_all_links(
200
+ operation: APIOperation,
201
+ ) -> Generator[tuple[str, Result[OpenApiLink, InvalidTransition]], None, None]:
202
+ for status_code, definition in operation.definition.raw["responses"].items():
203
+ definition = operation.schema.resolver.resolve_all(definition, RECURSION_DEPTH_LIMIT - 8) # type: ignore[attr-defined]
204
+ for name, link_definition in definition.get(operation.schema.links_field, {}).items(): # type: ignore
205
+ try:
206
+ link = OpenApiLink(name, status_code, link_definition, operation)
207
+ yield status_code, Ok(link)
208
+ except InvalidTransition as exc:
209
+ yield status_code, Err(exc)
@@ -0,0 +1,142 @@
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
+ def serializer(*media_types: str) -> Callable[[_Serializer], None]:
117
+ """Register a serializer for specified media types on HTTP, ASGI, and WSGI transports.
118
+
119
+ Args:
120
+ *media_types: One or more MIME types (e.g., "application/json") this serializer handles.
121
+
122
+ Returns:
123
+ A decorator that wraps a function taking `(ctx: SerializationContext, value: Any)` and returning `bytes` for serialized body and `None` for omitting request body.
124
+
125
+ """
126
+
127
+ def register(func: _Serializer) -> None:
128
+ from schemathesis.transport.asgi import ASGI_TRANSPORT
129
+ from schemathesis.transport.requests import REQUESTS_TRANSPORT
130
+ from schemathesis.transport.wsgi import WSGI_TRANSPORT
131
+
132
+ @ASGI_TRANSPORT.serializer(*media_types)
133
+ @REQUESTS_TRANSPORT.serializer(*media_types)
134
+ @WSGI_TRANSPORT.serializer(*media_types)
135
+ def inner(ctx: SerializationContext, value: Any) -> dict[str, bytes]:
136
+ result = {}
137
+ serialized = func(ctx, value)
138
+ if serialized is not None:
139
+ result["data"] = serialized
140
+ return result
141
+
142
+ return register
@@ -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,124 @@
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.transport import USER_AGENT
12
+ from schemathesis.generation.meta import CoveragePhaseData
13
+
14
+ if TYPE_CHECKING:
15
+ from requests import PreparedRequest
16
+ from requests.structures import CaseInsensitiveDict
17
+
18
+ from schemathesis.generation.case import Case
19
+
20
+
21
+ @lru_cache()
22
+ def get_default_headers() -> CaseInsensitiveDict:
23
+ from requests.utils import default_headers
24
+
25
+ headers = default_headers()
26
+ headers["User-Agent"] = USER_AGENT
27
+ return headers
28
+
29
+
30
+ def prepare_headers(case: Case, headers: dict[str, str] | None = None) -> CaseInsensitiveDict:
31
+ default_headers = get_default_headers().copy()
32
+ if case.headers:
33
+ default_headers.update(case.headers)
34
+ default_headers.setdefault(SCHEMATHESIS_TEST_CASE_HEADER, case.id)
35
+ if headers:
36
+ default_headers.update(headers)
37
+ for header in get_exclude_headers(case):
38
+ default_headers.pop(header, None)
39
+ return default_headers
40
+
41
+
42
+ def get_exclude_headers(case: Case) -> list[str]:
43
+ if (
44
+ case.meta is not None
45
+ and isinstance(case.meta.phase.data, CoveragePhaseData)
46
+ and case.meta.phase.data.description.startswith("Missing")
47
+ and case.meta.phase.data.description.endswith("at header")
48
+ and case.meta.phase.data.parameter is not None
49
+ ):
50
+ return [case.meta.phase.data.parameter]
51
+ return []
52
+
53
+
54
+ def prepare_url(case: Case, base_url: str | None) -> str:
55
+ """Prepare URL based on case type."""
56
+ from schemathesis.specs.graphql.schemas import GraphQLSchema
57
+
58
+ base_url = base_url or case.operation.base_url
59
+ assert base_url is not None
60
+ path = prepare_path(case.path, case.path_parameters)
61
+
62
+ if isinstance(case.operation.schema, GraphQLSchema):
63
+ parts = list(urlsplit(base_url))
64
+ parts[2] = path
65
+ return urlunsplit(parts)
66
+ else:
67
+ path = path.lstrip("/")
68
+ if not base_url.endswith("/"):
69
+ base_url += "/"
70
+ return unquote(urljoin(base_url, quote(path)))
71
+
72
+
73
+ def prepare_body(case: Case) -> list | dict[str, Any] | str | int | float | bool | bytes | NotSet:
74
+ """Prepare body based on case type."""
75
+ from schemathesis.specs.graphql.schemas import GraphQLSchema
76
+
77
+ if isinstance(case.operation.schema, GraphQLSchema):
78
+ return case.body if isinstance(case.body, (NotSet, bytes)) else {"query": case.body}
79
+ return case.body
80
+
81
+
82
+ def normalize_base_url(base_url: str | None) -> str | None:
83
+ """Normalize base URL by ensuring proper hostname for local URLs.
84
+
85
+ If URL has no hostname (typical for WSGI apps), adds "localhost" as default hostname.
86
+ """
87
+ if base_url is None:
88
+ return None
89
+ parts = urlsplit(base_url)
90
+ if not parts.hostname:
91
+ path = cast(str, parts.path or "")
92
+ return urlunsplit(("http", "localhost", path or "", "", ""))
93
+ return base_url
94
+
95
+
96
+ def prepare_path(path: str, parameters: dict[str, Any] | None) -> str:
97
+ try:
98
+ return path.format(**parameters or {})
99
+ except KeyError as exc:
100
+ # This may happen when a path template has a placeholder for variable "X", but parameter "X" is not defined
101
+ # in the parameters list.
102
+ # When `exc` is formatted, it is the missing key name in quotes. E.g. 'id'
103
+ raise InvalidSchema(f"Path parameter {exc} is not defined") from exc
104
+ except (IndexError, ValueError) as exc:
105
+ # A single unmatched `}` inside the path template may cause this
106
+ raise InvalidSchema(f"Malformed path template: `{path}`\n\n {exc}") from exc
107
+
108
+
109
+ def prepare_request(case: Case, headers: Mapping[str, Any] | None, *, config: SanitizationConfig) -> PreparedRequest:
110
+ import requests
111
+
112
+ from schemathesis.transport.requests import REQUESTS_TRANSPORT
113
+
114
+ base_url = normalize_base_url(case.operation.base_url)
115
+ kwargs = REQUESTS_TRANSPORT.serialize_case(case, base_url=base_url, headers=headers)
116
+ if config.enabled:
117
+ kwargs["url"] = sanitize_url(kwargs["url"], config=config)
118
+ sanitize_value(kwargs["headers"], config=config)
119
+ if kwargs["cookies"]:
120
+ sanitize_value(kwargs["cookies"], config=config)
121
+ if kwargs["params"]:
122
+ sanitize_value(kwargs["params"], config=config)
123
+
124
+ return requests.Request(**kwargs).prepare()