schemathesis 3.25.5__py3-none-any.whl → 4.0.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. schemathesis/__init__.py +27 -65
  2. schemathesis/auths.py +102 -82
  3. schemathesis/checks.py +126 -46
  4. schemathesis/cli/__init__.py +11 -1766
  5. schemathesis/cli/__main__.py +4 -0
  6. schemathesis/cli/commands/__init__.py +37 -0
  7. schemathesis/cli/commands/run/__init__.py +662 -0
  8. schemathesis/cli/commands/run/checks.py +80 -0
  9. schemathesis/cli/commands/run/context.py +117 -0
  10. schemathesis/cli/commands/run/events.py +35 -0
  11. schemathesis/cli/commands/run/executor.py +138 -0
  12. schemathesis/cli/commands/run/filters.py +194 -0
  13. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  14. schemathesis/cli/commands/run/handlers/base.py +18 -0
  15. schemathesis/cli/commands/run/handlers/cassettes.py +494 -0
  16. schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
  17. schemathesis/cli/commands/run/handlers/output.py +746 -0
  18. schemathesis/cli/commands/run/hypothesis.py +105 -0
  19. schemathesis/cli/commands/run/loaders.py +129 -0
  20. schemathesis/cli/{callbacks.py → commands/run/validation.py} +103 -174
  21. schemathesis/cli/constants.py +5 -52
  22. schemathesis/cli/core.py +17 -0
  23. schemathesis/cli/ext/fs.py +14 -0
  24. schemathesis/cli/ext/groups.py +55 -0
  25. schemathesis/cli/{options.py → ext/options.py} +39 -10
  26. schemathesis/cli/hooks.py +36 -0
  27. schemathesis/contrib/__init__.py +1 -3
  28. schemathesis/contrib/openapi/__init__.py +1 -3
  29. schemathesis/contrib/openapi/fill_missing_examples.py +3 -5
  30. schemathesis/core/__init__.py +58 -0
  31. schemathesis/core/compat.py +25 -0
  32. schemathesis/core/control.py +2 -0
  33. schemathesis/core/curl.py +58 -0
  34. schemathesis/core/deserialization.py +65 -0
  35. schemathesis/core/errors.py +370 -0
  36. schemathesis/core/failures.py +285 -0
  37. schemathesis/core/fs.py +19 -0
  38. schemathesis/{_lazy_import.py → core/lazy_import.py} +1 -0
  39. schemathesis/core/loaders.py +104 -0
  40. schemathesis/core/marks.py +66 -0
  41. schemathesis/{transports/content_types.py → core/media_types.py} +17 -13
  42. schemathesis/core/output/__init__.py +69 -0
  43. schemathesis/core/output/sanitization.py +197 -0
  44. schemathesis/core/rate_limit.py +60 -0
  45. schemathesis/core/registries.py +31 -0
  46. schemathesis/{internal → core}/result.py +1 -1
  47. schemathesis/core/transforms.py +113 -0
  48. schemathesis/core/transport.py +108 -0
  49. schemathesis/core/validation.py +38 -0
  50. schemathesis/core/version.py +7 -0
  51. schemathesis/engine/__init__.py +30 -0
  52. schemathesis/engine/config.py +59 -0
  53. schemathesis/engine/context.py +119 -0
  54. schemathesis/engine/control.py +36 -0
  55. schemathesis/engine/core.py +157 -0
  56. schemathesis/engine/errors.py +394 -0
  57. schemathesis/engine/events.py +337 -0
  58. schemathesis/engine/phases/__init__.py +66 -0
  59. schemathesis/{cli → engine/phases}/probes.py +63 -70
  60. schemathesis/engine/phases/stateful/__init__.py +65 -0
  61. schemathesis/engine/phases/stateful/_executor.py +326 -0
  62. schemathesis/engine/phases/stateful/context.py +85 -0
  63. schemathesis/engine/phases/unit/__init__.py +174 -0
  64. schemathesis/engine/phases/unit/_executor.py +321 -0
  65. schemathesis/engine/phases/unit/_pool.py +74 -0
  66. schemathesis/engine/recorder.py +241 -0
  67. schemathesis/errors.py +31 -0
  68. schemathesis/experimental/__init__.py +18 -14
  69. schemathesis/filters.py +103 -14
  70. schemathesis/generation/__init__.py +21 -37
  71. schemathesis/generation/case.py +190 -0
  72. schemathesis/generation/coverage.py +931 -0
  73. schemathesis/generation/hypothesis/__init__.py +30 -0
  74. schemathesis/generation/hypothesis/builder.py +585 -0
  75. schemathesis/generation/hypothesis/examples.py +50 -0
  76. schemathesis/generation/hypothesis/given.py +66 -0
  77. schemathesis/generation/hypothesis/reporting.py +14 -0
  78. schemathesis/generation/hypothesis/strategies.py +16 -0
  79. schemathesis/generation/meta.py +115 -0
  80. schemathesis/generation/modes.py +28 -0
  81. schemathesis/generation/overrides.py +96 -0
  82. schemathesis/generation/stateful/__init__.py +20 -0
  83. schemathesis/{stateful → generation/stateful}/state_machine.py +68 -81
  84. schemathesis/generation/targets.py +69 -0
  85. schemathesis/graphql/__init__.py +15 -0
  86. schemathesis/graphql/checks.py +115 -0
  87. schemathesis/graphql/loaders.py +131 -0
  88. schemathesis/hooks.py +99 -67
  89. schemathesis/openapi/__init__.py +13 -0
  90. schemathesis/openapi/checks.py +412 -0
  91. schemathesis/openapi/generation/__init__.py +0 -0
  92. schemathesis/openapi/generation/filters.py +63 -0
  93. schemathesis/openapi/loaders.py +178 -0
  94. schemathesis/pytest/__init__.py +5 -0
  95. schemathesis/pytest/control_flow.py +7 -0
  96. schemathesis/pytest/lazy.py +273 -0
  97. schemathesis/pytest/loaders.py +12 -0
  98. schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +106 -127
  99. schemathesis/python/__init__.py +0 -0
  100. schemathesis/python/asgi.py +12 -0
  101. schemathesis/python/wsgi.py +12 -0
  102. schemathesis/schemas.py +537 -261
  103. schemathesis/specs/graphql/__init__.py +0 -1
  104. schemathesis/specs/graphql/_cache.py +25 -0
  105. schemathesis/specs/graphql/nodes.py +1 -0
  106. schemathesis/specs/graphql/scalars.py +7 -5
  107. schemathesis/specs/graphql/schemas.py +215 -187
  108. schemathesis/specs/graphql/validation.py +11 -18
  109. schemathesis/specs/openapi/__init__.py +7 -1
  110. schemathesis/specs/openapi/_cache.py +122 -0
  111. schemathesis/specs/openapi/_hypothesis.py +146 -165
  112. schemathesis/specs/openapi/checks.py +565 -67
  113. schemathesis/specs/openapi/converter.py +33 -6
  114. schemathesis/specs/openapi/definitions.py +11 -18
  115. schemathesis/specs/openapi/examples.py +153 -39
  116. schemathesis/specs/openapi/expressions/__init__.py +37 -2
  117. schemathesis/specs/openapi/expressions/context.py +4 -6
  118. schemathesis/specs/openapi/expressions/extractors.py +23 -0
  119. schemathesis/specs/openapi/expressions/lexer.py +20 -18
  120. schemathesis/specs/openapi/expressions/nodes.py +38 -14
  121. schemathesis/specs/openapi/expressions/parser.py +26 -5
  122. schemathesis/specs/openapi/formats.py +45 -0
  123. schemathesis/specs/openapi/links.py +65 -165
  124. schemathesis/specs/openapi/media_types.py +32 -0
  125. schemathesis/specs/openapi/negative/__init__.py +7 -3
  126. schemathesis/specs/openapi/negative/mutations.py +24 -8
  127. schemathesis/specs/openapi/parameters.py +46 -30
  128. schemathesis/specs/openapi/patterns.py +137 -0
  129. schemathesis/specs/openapi/references.py +47 -57
  130. schemathesis/specs/openapi/schemas.py +483 -367
  131. schemathesis/specs/openapi/security.py +25 -7
  132. schemathesis/specs/openapi/serialization.py +11 -6
  133. schemathesis/specs/openapi/stateful/__init__.py +185 -73
  134. schemathesis/specs/openapi/utils.py +6 -1
  135. schemathesis/transport/__init__.py +104 -0
  136. schemathesis/transport/asgi.py +26 -0
  137. schemathesis/transport/prepare.py +99 -0
  138. schemathesis/transport/requests.py +221 -0
  139. schemathesis/{_xml.py → transport/serialization.py} +143 -28
  140. schemathesis/transport/wsgi.py +165 -0
  141. schemathesis-4.0.0a1.dist-info/METADATA +297 -0
  142. schemathesis-4.0.0a1.dist-info/RECORD +152 -0
  143. {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/WHEEL +1 -1
  144. {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/entry_points.txt +1 -1
  145. schemathesis/_compat.py +0 -74
  146. schemathesis/_dependency_versions.py +0 -17
  147. schemathesis/_hypothesis.py +0 -246
  148. schemathesis/_override.py +0 -49
  149. schemathesis/cli/cassettes.py +0 -375
  150. schemathesis/cli/context.py +0 -55
  151. schemathesis/cli/debug.py +0 -26
  152. schemathesis/cli/handlers.py +0 -16
  153. schemathesis/cli/junitxml.py +0 -43
  154. schemathesis/cli/output/__init__.py +0 -1
  155. schemathesis/cli/output/default.py +0 -765
  156. schemathesis/cli/output/short.py +0 -40
  157. schemathesis/cli/sanitization.py +0 -20
  158. schemathesis/code_samples.py +0 -149
  159. schemathesis/constants.py +0 -55
  160. schemathesis/contrib/openapi/formats/__init__.py +0 -9
  161. schemathesis/contrib/openapi/formats/uuid.py +0 -15
  162. schemathesis/contrib/unique_data.py +0 -41
  163. schemathesis/exceptions.py +0 -560
  164. schemathesis/extra/_aiohttp.py +0 -27
  165. schemathesis/extra/_flask.py +0 -10
  166. schemathesis/extra/_server.py +0 -17
  167. schemathesis/failures.py +0 -209
  168. schemathesis/fixups/__init__.py +0 -36
  169. schemathesis/fixups/fast_api.py +0 -41
  170. schemathesis/fixups/utf8_bom.py +0 -29
  171. schemathesis/graphql.py +0 -4
  172. schemathesis/internal/__init__.py +0 -7
  173. schemathesis/internal/copy.py +0 -13
  174. schemathesis/internal/datetime.py +0 -5
  175. schemathesis/internal/deprecation.py +0 -34
  176. schemathesis/internal/jsonschema.py +0 -35
  177. schemathesis/internal/transformation.py +0 -15
  178. schemathesis/internal/validation.py +0 -34
  179. schemathesis/lazy.py +0 -361
  180. schemathesis/loaders.py +0 -120
  181. schemathesis/models.py +0 -1231
  182. schemathesis/parameters.py +0 -86
  183. schemathesis/runner/__init__.py +0 -555
  184. schemathesis/runner/events.py +0 -309
  185. schemathesis/runner/impl/__init__.py +0 -3
  186. schemathesis/runner/impl/core.py +0 -986
  187. schemathesis/runner/impl/solo.py +0 -90
  188. schemathesis/runner/impl/threadpool.py +0 -400
  189. schemathesis/runner/serialization.py +0 -411
  190. schemathesis/sanitization.py +0 -248
  191. schemathesis/serializers.py +0 -315
  192. schemathesis/service/__init__.py +0 -18
  193. schemathesis/service/auth.py +0 -11
  194. schemathesis/service/ci.py +0 -201
  195. schemathesis/service/client.py +0 -100
  196. schemathesis/service/constants.py +0 -38
  197. schemathesis/service/events.py +0 -57
  198. schemathesis/service/hosts.py +0 -107
  199. schemathesis/service/metadata.py +0 -46
  200. schemathesis/service/models.py +0 -49
  201. schemathesis/service/report.py +0 -255
  202. schemathesis/service/serialization.py +0 -184
  203. schemathesis/service/usage.py +0 -65
  204. schemathesis/specs/graphql/loaders.py +0 -344
  205. schemathesis/specs/openapi/filters.py +0 -49
  206. schemathesis/specs/openapi/loaders.py +0 -667
  207. schemathesis/specs/openapi/stateful/links.py +0 -92
  208. schemathesis/specs/openapi/validation.py +0 -25
  209. schemathesis/stateful/__init__.py +0 -133
  210. schemathesis/targets.py +0 -45
  211. schemathesis/throttling.py +0 -41
  212. schemathesis/transports/__init__.py +0 -5
  213. schemathesis/transports/auth.py +0 -15
  214. schemathesis/transports/headers.py +0 -35
  215. schemathesis/transports/responses.py +0 -52
  216. schemathesis/types.py +0 -35
  217. schemathesis/utils.py +0 -169
  218. schemathesis-3.25.5.dist-info/METADATA +0 -356
  219. schemathesis-3.25.5.dist-info/RECORD +0 -134
  220. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
  221. {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/licenses/LICENSE +0 -0
schemathesis/models.py DELETED
@@ -1,1231 +0,0 @@
1
- from __future__ import annotations
2
- import datetime
3
- import inspect
4
- import textwrap
5
- from collections import Counter
6
- from contextlib import contextmanager
7
- from dataclasses import dataclass, field
8
- from enum import Enum
9
- from functools import partial, lru_cache
10
- from itertools import chain
11
- from logging import LogRecord
12
- from typing import (
13
- TYPE_CHECKING,
14
- Any,
15
- Callable,
16
- Generator,
17
- Generic,
18
- Iterator,
19
- NoReturn,
20
- Optional,
21
- Sequence,
22
- Type,
23
- TypeVar,
24
- cast,
25
- )
26
- from urllib.parse import quote, unquote, urljoin, urlparse, urlsplit, urlunsplit
27
-
28
- from urllib3.exceptions import ReadTimeoutError
29
-
30
- from . import failures, serializers
31
- from ._dependency_versions import IS_WERKZEUG_ABOVE_3
32
- from .auths import AuthStorage
33
- from .code_samples import CodeSampleStyle
34
- from .generation import DataGenerationMethod, GenerationConfig
35
- from .constants import (
36
- DEFAULT_RESPONSE_TIMEOUT,
37
- SCHEMATHESIS_TEST_CASE_HEADER,
38
- SERIALIZERS_SUGGESTION_MESSAGE,
39
- USER_AGENT,
40
- NOT_SET,
41
- )
42
- from .exceptions import (
43
- maybe_set_assertion_message,
44
- CheckFailed,
45
- FailureContext,
46
- OperationSchemaError,
47
- SerializationNotPossible,
48
- deduplicate_failed_checks,
49
- get_grouped_exception,
50
- get_timeout_error,
51
- prepare_response_payload,
52
- SkipTest,
53
- )
54
- from .internal.deprecation import deprecated_property
55
- from .internal.copy import fast_deepcopy
56
- from .hooks import GLOBAL_HOOK_DISPATCHER, HookContext, HookDispatcher, dispatch
57
- from .parameters import Parameter, ParameterSet, PayloadAlternatives
58
- from .sanitization import sanitize_request, sanitize_response
59
- from .serializers import Serializer, SerializerContext
60
- from .transports import serialize_payload
61
- from .types import Body, Cookies, FormData, Headers, NotSet, PathParameters, Query
62
- from .generation import generate_random_case_id
63
-
64
- if TYPE_CHECKING:
65
- import werkzeug
66
- import unittest
67
- from requests.structures import CaseInsensitiveDict
68
- from hypothesis import strategies as st
69
- import requests.auth
70
- from .transports.responses import GenericResponse, WSGIResponse
71
- from .schemas import BaseSchema
72
- from .stateful import Stateful, StatefulTest
73
-
74
-
75
- @dataclass
76
- class CaseSource:
77
- """Data sources, used to generate a test case."""
78
-
79
- case: Case
80
- response: GenericResponse
81
- elapsed: float
82
-
83
- def partial_deepcopy(self) -> CaseSource:
84
- return self.__class__(case=self.case.partial_deepcopy(), response=self.response, elapsed=self.elapsed)
85
-
86
-
87
- def cant_serialize(media_type: str) -> NoReturn: # type: ignore
88
- """Reject the current example if we don't know how to send this data to the application."""
89
- from hypothesis import note, event, reject
90
-
91
- event_text = f"Can't serialize data to `{media_type}`."
92
- note(f"{event_text} {SERIALIZERS_SUGGESTION_MESSAGE}")
93
- event(event_text)
94
- reject() # type: ignore
95
-
96
-
97
- @lru_cache
98
- def get_request_signature() -> inspect.Signature:
99
- import requests
100
-
101
- return inspect.signature(requests.Request)
102
-
103
-
104
- @dataclass()
105
- class PreparedRequestData:
106
- method: str
107
- url: str
108
- body: str | bytes | None
109
- headers: Headers
110
-
111
-
112
- def prepare_request_data(kwargs: dict[str, Any]) -> PreparedRequestData:
113
- """Prepare request data for generating code samples."""
114
- import requests
115
-
116
- kwargs = {key: value for key, value in kwargs.items() if key in get_request_signature().parameters}
117
- request = requests.Request(**kwargs).prepare()
118
- return PreparedRequestData(
119
- method=str(request.method), url=str(request.url), body=request.body, headers=dict(request.headers)
120
- )
121
-
122
-
123
- @dataclass(repr=False)
124
- class Case:
125
- """A single test case parameters."""
126
-
127
- operation: APIOperation
128
- # Unique test case identifier
129
- id: str = field(default_factory=generate_random_case_id, compare=False)
130
- path_parameters: PathParameters | None = None
131
- headers: CaseInsensitiveDict | None = None
132
- cookies: Cookies | None = None
133
- query: Query | None = None
134
- # By default, there is no body, but we can't use `None` as the default value because it clashes with `null`
135
- # which is a valid payload.
136
- body: Body | NotSet = NOT_SET
137
- # The media type for cases with a payload. For example, "application/json"
138
- media_type: str | None = None
139
- source: CaseSource | None = None
140
-
141
- # The way the case was generated (None for manually crafted ones)
142
- data_generation_method: DataGenerationMethod | None = None
143
- _auth: requests.auth.AuthBase | None = None
144
-
145
- def __repr__(self) -> str:
146
- parts = [f"{self.__class__.__name__}("]
147
- first = True
148
- for name in ("path_parameters", "headers", "cookies", "query", "body"):
149
- value = getattr(self, name)
150
- if value is not None and not isinstance(value, NotSet):
151
- if first:
152
- first = False
153
- else:
154
- parts.append(", ")
155
- parts.extend((name, "=", repr(value)))
156
- return "".join(parts) + ")"
157
-
158
- def __hash__(self) -> int:
159
- return hash(self.as_curl_command({SCHEMATHESIS_TEST_CASE_HEADER: "0"}))
160
-
161
- @deprecated_property(removed_in="4.0", replacement="operation")
162
- def endpoint(self) -> APIOperation:
163
- return self.operation
164
-
165
- @property
166
- def path(self) -> str:
167
- return self.operation.path
168
-
169
- @property
170
- def full_path(self) -> str:
171
- return self.operation.full_path
172
-
173
- @property
174
- def method(self) -> str:
175
- return self.operation.method.upper()
176
-
177
- @property
178
- def base_url(self) -> str | None:
179
- return self.operation.base_url
180
-
181
- @property
182
- def app(self) -> Any:
183
- return self.operation.app
184
-
185
- def set_source(self, response: GenericResponse, case: Case, elapsed: float) -> None:
186
- self.source = CaseSource(case=case, response=response, elapsed=elapsed)
187
-
188
- @property
189
- def formatted_path(self) -> str:
190
- try:
191
- return self.path.format(**self.path_parameters or {})
192
- except KeyError as exc:
193
- # This may happen when a path template has a placeholder for variable "X", but parameter "X" is not defined
194
- # in the parameters list.
195
- # When `exc` is formatted, it is the missing key name in quotes. E.g. 'id'
196
- raise OperationSchemaError(f"Path parameter {exc} is not defined") from exc
197
- except (IndexError, ValueError) as exc:
198
- # A single unmatched `}` inside the path template may cause this
199
- raise OperationSchemaError(f"Malformed path template: `{self.path}`\n\n {exc}") from exc
200
-
201
- def get_full_base_url(self) -> str | None:
202
- """Create a full base url, adding "localhost" for WSGI apps."""
203
- parts = urlsplit(self.base_url)
204
- if not parts.hostname:
205
- path = cast(str, parts.path or "")
206
- return urlunsplit(("http", "localhost", path or "", "", ""))
207
- return self.base_url
208
-
209
- def prepare_code_sample_data(self, headers: dict[str, Any] | None) -> PreparedRequestData:
210
- base_url = self.get_full_base_url()
211
- kwargs = self.as_requests_kwargs(base_url, headers=headers)
212
- return prepare_request_data(kwargs)
213
-
214
- def get_code_to_reproduce(
215
- self,
216
- headers: dict[str, Any] | None = None,
217
- request: requests.PreparedRequest | None = None,
218
- verify: bool = True,
219
- ) -> str:
220
- """Construct a Python code to reproduce this case with `requests`."""
221
- if request is not None:
222
- request_data = prepare_request_data(
223
- {
224
- "method": request.method,
225
- "url": request.url,
226
- "headers": request.headers,
227
- "data": request.body,
228
- }
229
- )
230
- else:
231
- request_data = self.prepare_code_sample_data(headers)
232
- return CodeSampleStyle.python.generate(
233
- method=request_data.method,
234
- url=request_data.url,
235
- body=request_data.body,
236
- headers=dict(self.headers) if self.headers is not None else None,
237
- verify=verify,
238
- extra_headers=request_data.headers,
239
- )
240
-
241
- def as_curl_command(self, headers: dict[str, Any] | None = None, verify: bool = True) -> str:
242
- """Construct a curl command for a given case."""
243
- request_data = self.prepare_code_sample_data(headers)
244
- return CodeSampleStyle.curl.generate(
245
- method=request_data.method,
246
- url=request_data.url,
247
- body=request_data.body,
248
- headers=dict(self.headers) if self.headers is not None else None,
249
- verify=verify,
250
- extra_headers=request_data.headers,
251
- )
252
-
253
- def _get_base_url(self, base_url: str | None = None) -> str:
254
- if base_url is None:
255
- if self.base_url is not None:
256
- base_url = self.base_url
257
- else:
258
- raise ValueError(
259
- "Base URL is required as `base_url` argument in `call` or should be specified "
260
- "in the schema constructor as a part of Schema URL."
261
- )
262
- return base_url
263
-
264
- def _get_headers(self, headers: dict[str, str] | None = None) -> CaseInsensitiveDict:
265
- from requests.structures import CaseInsensitiveDict
266
-
267
- final_headers = self.headers.copy() if self.headers is not None else CaseInsensitiveDict()
268
- if headers:
269
- final_headers.update(headers)
270
- final_headers.setdefault("User-Agent", USER_AGENT)
271
- final_headers.setdefault(SCHEMATHESIS_TEST_CASE_HEADER, self.id)
272
- return final_headers
273
-
274
- def _get_serializer(self) -> Serializer | None:
275
- """Get a serializer for the payload, if there is any."""
276
- if self.media_type is not None:
277
- media_type = serializers.get_first_matching_media_type(self.media_type)
278
- if media_type is None:
279
- # This media type is set manually. Otherwise, it should have been rejected during the data generation
280
- raise SerializationNotPossible.for_media_type(self.media_type)
281
- # SAFETY: It is safe to assume that serializer will be found, because `media_type` returned above
282
- # is registered. This intentionally ignores cases with concurrent serializers registry modification.
283
- cls = cast(Type[serializers.Serializer], serializers.get(media_type))
284
- return cls()
285
- return None
286
-
287
- def as_requests_kwargs(self, base_url: str | None = None, headers: dict[str, str] | None = None) -> dict[str, Any]:
288
- """Convert the case into a dictionary acceptable by requests."""
289
- final_headers = self._get_headers(headers)
290
- if self.media_type and self.media_type != "multipart/form-data" and not isinstance(self.body, NotSet):
291
- # `requests` will handle multipart form headers with the proper `boundary` value.
292
- if "content-type" not in {header.lower() for header in final_headers}:
293
- final_headers["Content-Type"] = self.media_type
294
- base_url = self._get_base_url(base_url)
295
- formatted_path = self.formatted_path.lstrip("/")
296
- if not base_url.endswith("/"):
297
- base_url += "/"
298
- url = unquote(urljoin(base_url, quote(formatted_path)))
299
- extra: dict[str, Any]
300
- serializer = self._get_serializer()
301
- if serializer is not None and not isinstance(self.body, NotSet):
302
- context = SerializerContext(case=self)
303
- extra = serializer.as_requests(context, self.body)
304
- else:
305
- extra = {}
306
- if self._auth is not None:
307
- extra["auth"] = self._auth
308
- additional_headers = extra.pop("headers", None)
309
- if additional_headers:
310
- # Additional headers, needed for the serializer
311
- for key, value in additional_headers.items():
312
- final_headers.setdefault(key, value)
313
- return {
314
- "method": self.method,
315
- "url": url,
316
- "cookies": self.cookies,
317
- "headers": final_headers,
318
- "params": self.query,
319
- **extra,
320
- }
321
-
322
- def call(
323
- self,
324
- base_url: str | None = None,
325
- session: requests.Session | None = None,
326
- headers: dict[str, Any] | None = None,
327
- params: dict[str, Any] | None = None,
328
- cookies: dict[str, Any] | None = None,
329
- **kwargs: Any,
330
- ) -> requests.Response:
331
- import requests
332
-
333
- """Make a network call with `requests`."""
334
- hook_context = HookContext(operation=self.operation)
335
- dispatch("before_call", hook_context, self)
336
- data = self.as_requests_kwargs(base_url, headers)
337
- data.update(kwargs)
338
- if params is not None:
339
- _merge_dict_to(data, "params", params)
340
- if cookies is not None:
341
- _merge_dict_to(data, "cookies", cookies)
342
- data.setdefault("timeout", DEFAULT_RESPONSE_TIMEOUT / 1000)
343
- if session is None:
344
- validate_vanilla_requests_kwargs(data)
345
- session = requests.Session()
346
- close_session = True
347
- else:
348
- close_session = False
349
- verify = data.get("verify", True)
350
- try:
351
- with self.operation.schema.ratelimit():
352
- response = session.request(**data) # type: ignore
353
- except (requests.Timeout, requests.ConnectionError) as exc:
354
- if isinstance(exc, requests.ConnectionError):
355
- if not isinstance(exc.args[0], ReadTimeoutError):
356
- raise
357
- req = requests.Request(
358
- method=data["method"].upper(),
359
- url=data["url"],
360
- headers=data["headers"],
361
- files=data.get("files"),
362
- data=data.get("data") or {},
363
- json=data.get("json"),
364
- params=data.get("params") or {},
365
- auth=data.get("auth"),
366
- cookies=data["cookies"],
367
- hooks=data.get("hooks"),
368
- )
369
- request = session.prepare_request(req)
370
- else:
371
- request = cast(requests.PreparedRequest, exc.request)
372
- timeout = 1000 * data["timeout"] # It is defined and not empty, since the exception happened
373
- code_message = self._get_code_message(self.operation.schema.code_sample_style, request, verify=verify)
374
- message = f"The server failed to respond within the specified limit of {timeout:.2f}ms"
375
- raise get_timeout_error(timeout)(
376
- f"\n\n1. {failures.RequestTimeout.title}\n\n{message}\n\n{code_message}",
377
- context=failures.RequestTimeout(message=message, timeout=timeout),
378
- ) from None
379
- response.verify = verify # type: ignore[attr-defined]
380
- dispatch("after_call", hook_context, self, response)
381
- if close_session:
382
- session.close()
383
- return response
384
-
385
- def as_werkzeug_kwargs(self, headers: dict[str, str] | None = None) -> dict[str, Any]:
386
- """Convert the case into a dictionary acceptable by werkzeug.Client."""
387
- final_headers = self._get_headers(headers)
388
- if self.media_type and not isinstance(self.body, NotSet):
389
- # If we need to send a payload, then the Content-Type header should be set
390
- final_headers["Content-Type"] = self.media_type
391
- extra: dict[str, Any]
392
- serializer = self._get_serializer()
393
- if serializer is not None and not isinstance(self.body, NotSet):
394
- context = SerializerContext(case=self)
395
- extra = serializer.as_werkzeug(context, self.body)
396
- else:
397
- extra = {}
398
- return {
399
- "method": self.method,
400
- "path": self.operation.schema.get_full_path(self.formatted_path),
401
- # Convert to a regular dictionary, as we use `CaseInsensitiveDict` which is not supported by Werkzeug
402
- "headers": dict(final_headers),
403
- "query_string": self.query,
404
- **extra,
405
- }
406
-
407
- def call_wsgi(
408
- self,
409
- app: Any = None,
410
- headers: dict[str, str] | None = None,
411
- query_string: dict[str, str] | None = None,
412
- **kwargs: Any,
413
- ) -> WSGIResponse:
414
- from .transports.responses import WSGIResponse
415
- import werkzeug
416
- import requests
417
-
418
- application = app or self.app
419
- if application is None:
420
- raise RuntimeError(
421
- "WSGI application instance is required. "
422
- "Please, set `app` argument in the schema constructor or pass it to `call_wsgi`"
423
- )
424
- hook_context = HookContext(operation=self.operation)
425
- dispatch("before_call", hook_context, self)
426
- data = self.as_werkzeug_kwargs(headers)
427
- if query_string is not None:
428
- _merge_dict_to(data, "query_string", query_string)
429
- client = werkzeug.Client(application, WSGIResponse)
430
- with cookie_handler(client, self.cookies), self.operation.schema.ratelimit():
431
- response = client.open(**data, **kwargs)
432
- requests_kwargs = self.as_requests_kwargs(base_url=self.get_full_base_url(), headers=headers)
433
- response.request = requests.Request(**requests_kwargs).prepare()
434
- dispatch("after_call", hook_context, self, response)
435
- return response
436
-
437
- def call_asgi(
438
- self,
439
- app: Any = None,
440
- base_url: str | None = None,
441
- headers: dict[str, str] | None = None,
442
- **kwargs: Any,
443
- ) -> requests.Response:
444
- from starlette_testclient import TestClient as ASGIClient
445
-
446
- application = app or self.app
447
- if application is None:
448
- raise RuntimeError(
449
- "ASGI application instance is required. "
450
- "Please, set `app` argument in the schema constructor or pass it to `call_asgi`"
451
- )
452
- if base_url is None:
453
- base_url = self.get_full_base_url()
454
-
455
- with ASGIClient(application) as client:
456
- return self.call(base_url=base_url, session=client, headers=headers, **kwargs)
457
-
458
- def validate_response(
459
- self,
460
- response: GenericResponse,
461
- checks: tuple[CheckFunction, ...] = (),
462
- additional_checks: tuple[CheckFunction, ...] = (),
463
- excluded_checks: tuple[CheckFunction, ...] = (),
464
- code_sample_style: str | None = None,
465
- ) -> None:
466
- """Validate application response.
467
-
468
- By default, all available checks will be applied.
469
-
470
- :param response: Application response.
471
- :param checks: A tuple of check functions that accept ``response`` and ``case``.
472
- :param additional_checks: A tuple of additional checks that will be executed after ones from the ``checks``
473
- argument.
474
- :param excluded_checks: Checks excluded from the default ones.
475
- :param code_sample_style: Controls the style of code samples for failure reproduction.
476
- """
477
- __tracebackhide__ = True
478
- from .checks import ALL_CHECKS
479
- from .transports.responses import get_payload, get_reason
480
-
481
- checks = checks or ALL_CHECKS
482
- checks = tuple(check for check in checks if check not in excluded_checks)
483
- additional_checks = tuple(check for check in additional_checks if check not in excluded_checks)
484
- failed_checks = []
485
- for check in chain(checks, additional_checks):
486
- copied_case = self.partial_deepcopy()
487
- try:
488
- check(response, copied_case)
489
- except AssertionError as exc:
490
- maybe_set_assertion_message(exc, check.__name__)
491
- failed_checks.append(exc)
492
- failed_checks = list(deduplicate_failed_checks(failed_checks))
493
- if failed_checks:
494
- exception_cls = get_grouped_exception(self.operation.verbose_name, *failed_checks)
495
- formatted = ""
496
- for idx, failed in enumerate(failed_checks, 1):
497
- if isinstance(failed, CheckFailed) and failed.context is not None:
498
- title = failed.context.title
499
- if failed.context.message:
500
- message = failed.context.message
501
- else:
502
- message = None
503
- else:
504
- title, message = failed.args
505
- formatted += "\n\n"
506
- formatted += f"{idx}. {title}"
507
- if message is not None:
508
- formatted += "\n\n"
509
- formatted += textwrap.indent(message, prefix=" ")
510
-
511
- status_code = response.status_code
512
- reason = get_reason(status_code)
513
- formatted += f"\n\n[{response.status_code}] {reason}:"
514
- payload = get_payload(response)
515
- if not payload:
516
- formatted += "\n\n <EMPTY>"
517
- else:
518
- payload = prepare_response_payload(payload)
519
- payload = textwrap.indent(f"\n`{payload}`", prefix=" ")
520
- formatted += f"\n{payload}"
521
- code_sample_style = (
522
- CodeSampleStyle.from_str(code_sample_style)
523
- if code_sample_style is not None
524
- else self.operation.schema.code_sample_style
525
- )
526
- verify = getattr(response, "verify", True)
527
- if self.operation.schema.sanitize_output:
528
- sanitize_request(response.request)
529
- sanitize_response(response)
530
- code_message = self._get_code_message(code_sample_style, response.request, verify=verify)
531
- raise exception_cls(
532
- f"{formatted}\n\n" f"{code_message}",
533
- causes=tuple(failed_checks),
534
- )
535
-
536
- def _get_code_message(
537
- self, code_sample_style: CodeSampleStyle, request: requests.PreparedRequest, verify: bool
538
- ) -> str:
539
- if code_sample_style == CodeSampleStyle.python:
540
- code = self.get_code_to_reproduce(request=request, verify=verify)
541
- elif code_sample_style == CodeSampleStyle.curl:
542
- code = self.as_curl_command(headers=dict(request.headers), verify=verify)
543
- else:
544
- raise ValueError(f"Unknown code sample style: {code_sample_style.name}")
545
- return f"Reproduce with: \n\n {code}\n"
546
-
547
- def call_and_validate(
548
- self,
549
- base_url: str | None = None,
550
- session: requests.Session | None = None,
551
- headers: dict[str, Any] | None = None,
552
- checks: tuple[CheckFunction, ...] = (),
553
- code_sample_style: str | None = None,
554
- **kwargs: Any,
555
- ) -> requests.Response:
556
- __tracebackhide__ = True
557
- response = self.call(base_url, session, headers, **kwargs)
558
- self.validate_response(response, checks, code_sample_style=code_sample_style)
559
- return response
560
-
561
- def get_full_url(self) -> str:
562
- """Make a full URL to the current API operation, including query parameters."""
563
- import requests
564
-
565
- base_url = self.base_url or "http://127.0.0.1"
566
- kwargs = self.as_requests_kwargs(base_url)
567
- request = requests.Request(**kwargs)
568
- prepared = requests.Session().prepare_request(request) # type: ignore
569
- return cast(str, prepared.url)
570
-
571
- def partial_deepcopy(self) -> Case:
572
- return self.__class__(
573
- operation=self.operation.partial_deepcopy(),
574
- data_generation_method=self.data_generation_method,
575
- media_type=self.media_type,
576
- source=self.source if self.source is None else self.source.partial_deepcopy(),
577
- path_parameters=fast_deepcopy(self.path_parameters),
578
- headers=fast_deepcopy(self.headers),
579
- cookies=fast_deepcopy(self.cookies),
580
- query=fast_deepcopy(self.query),
581
- body=fast_deepcopy(self.body),
582
- )
583
-
584
-
585
- def _merge_dict_to(data: dict[str, Any], data_key: str, new: dict[str, Any]) -> None:
586
- original = data[data_key] or {}
587
- for key, value in new.items():
588
- original[key] = value
589
- data[data_key] = original
590
-
591
-
592
- def validate_vanilla_requests_kwargs(data: dict[str, Any]) -> None:
593
- """Check arguments for `requests.Session.request`.
594
-
595
- Some arguments can be valid for cases like ASGI integration, but at the same time they won't work for the regular
596
- `requests` calls. In such cases we need to avoid an obscure error message, that comes from `requests`.
597
- """
598
- url = data["url"]
599
- if not urlparse(url).netloc:
600
- raise RuntimeError(
601
- "The URL should be absolute, so Schemathesis knows where to send the data. \n"
602
- f"If you use the ASGI integration, please supply your test client "
603
- f"as the `session` argument to `call`.\nURL: {url}"
604
- )
605
-
606
-
607
- @contextmanager
608
- def cookie_handler(client: werkzeug.Client, cookies: Cookies | None) -> Generator[None, None, None]:
609
- """Set cookies required for a call."""
610
- if not cookies:
611
- yield
612
- else:
613
- for key, value in cookies.items():
614
- if IS_WERKZEUG_ABOVE_3:
615
- client.set_cookie(key=key, value=value, domain="localhost")
616
- else:
617
- client.set_cookie("localhost", key=key, value=value)
618
- yield
619
- for key in cookies:
620
- if IS_WERKZEUG_ABOVE_3:
621
- client.delete_cookie(key=key, domain="localhost")
622
- else:
623
- client.delete_cookie("localhost", key=key)
624
-
625
-
626
- P = TypeVar("P", bound=Parameter)
627
- D = TypeVar("D", bound=dict)
628
-
629
-
630
- @dataclass
631
- class OperationDefinition(Generic[P, D]):
632
- """A wrapper to store not resolved API operation definitions.
633
-
634
- To prevent recursion errors we need to store definitions without resolving references. But operation definitions
635
- itself can be behind a reference (when there is a ``$ref`` in ``paths`` values), therefore we need to store this
636
- scope change to have a proper reference resolving later.
637
- """
638
-
639
- raw: D
640
- resolved: D
641
- scope: str
642
- parameters: Sequence[P]
643
-
644
- def __contains__(self, item: str | int) -> bool:
645
- return item in self.resolved
646
-
647
- def __getitem__(self, item: str | int) -> None | bool | float | str | list | dict[str, Any]:
648
- return self.resolved[item]
649
-
650
- def get(self, item: str | int, default: Any = None) -> None | bool | float | str | list | dict[str, Any]:
651
- return self.resolved.get(item, default)
652
-
653
-
654
- C = TypeVar("C", bound=Case)
655
-
656
-
657
- @dataclass(eq=False)
658
- class APIOperation(Generic[P, C]):
659
- """A single operation defined in an API.
660
-
661
- You can get one via a ``schema`` instance.
662
-
663
- .. code-block:: python
664
-
665
- # Get the POST /items operation
666
- operation = schema["/items"]["POST"]
667
-
668
- """
669
-
670
- # `path` does not contain `basePath`
671
- # Example <scheme>://<host>/<basePath>/users - "/users" is path
672
- # https://swagger.io/docs/specification/2-0/api-host-and-base-path/
673
- path: str
674
- method: str
675
- definition: OperationDefinition = field(repr=False)
676
- schema: BaseSchema
677
- verbose_name: str = None # type: ignore
678
- app: Any = None
679
- base_url: str | None = None
680
- path_parameters: ParameterSet[P] = field(default_factory=ParameterSet)
681
- headers: ParameterSet[P] = field(default_factory=ParameterSet)
682
- cookies: ParameterSet[P] = field(default_factory=ParameterSet)
683
- query: ParameterSet[P] = field(default_factory=ParameterSet)
684
- body: PayloadAlternatives[P] = field(default_factory=PayloadAlternatives)
685
- case_cls: type[C] = Case # type: ignore
686
-
687
- def __post_init__(self) -> None:
688
- if self.verbose_name is None:
689
- self.verbose_name = f"{self.method.upper()} {self.full_path}" # type: ignore
690
-
691
- @property
692
- def full_path(self) -> str:
693
- return self.schema.get_full_path(self.path)
694
-
695
- @property
696
- def links(self) -> dict[str, dict[str, Any]]:
697
- return self.schema.get_links(self)
698
-
699
- @property
700
- def tags(self) -> list[str] | None:
701
- return self.schema.get_tags(self)
702
-
703
- def iter_parameters(self) -> Iterator[P]:
704
- """Iterate over all operation's parameters."""
705
- return chain(self.path_parameters, self.headers, self.cookies, self.query)
706
-
707
- def _lookup_container(self, location: str) -> ParameterSet[P] | PayloadAlternatives[P] | None:
708
- return {
709
- "path": self.path_parameters,
710
- "header": self.headers,
711
- "cookie": self.cookies,
712
- "query": self.query,
713
- "body": self.body,
714
- }.get(location)
715
-
716
- def add_parameter(self, parameter: P) -> None:
717
- """Add a new processed parameter to an API operation.
718
-
719
- :param parameter: A parameter that will be used with this operation.
720
- :rtype: None
721
- """
722
- # If the parameter has a typo, then by default, there will be an error from `jsonschema` earlier.
723
- # But if the user wants to skip schema validation, we choose to ignore a malformed parameter.
724
- # In this case, we still might generate some tests for an API operation, but without this parameter,
725
- # which is better than skip the whole operation from testing.
726
- container = self._lookup_container(parameter.location)
727
- if container is not None:
728
- container.add(parameter)
729
-
730
- def get_parameter(self, name: str, location: str) -> P | None:
731
- container = self._lookup_container(location)
732
- if container is not None:
733
- return container.get(name)
734
- return None
735
-
736
- def as_strategy(
737
- self,
738
- hooks: HookDispatcher | None = None,
739
- auth_storage: AuthStorage | None = None,
740
- data_generation_method: DataGenerationMethod = DataGenerationMethod.default(),
741
- generation_config: GenerationConfig | None = None,
742
- **kwargs: Any,
743
- ) -> st.SearchStrategy:
744
- """Turn this API operation into a Hypothesis strategy."""
745
- strategy = self.schema.get_case_strategy(
746
- self, hooks, auth_storage, data_generation_method, generation_config=generation_config, **kwargs
747
- )
748
-
749
- def _apply_hooks(dispatcher: HookDispatcher, _strategy: st.SearchStrategy[Case]) -> st.SearchStrategy[Case]:
750
- context = HookContext(self)
751
- for hook in dispatcher.get_all_by_name("before_generate_case"):
752
- _strategy = hook(context, _strategy)
753
- for hook in dispatcher.get_all_by_name("filter_case"):
754
- hook = partial(hook, context)
755
- _strategy = _strategy.filter(hook)
756
- for hook in dispatcher.get_all_by_name("map_case"):
757
- hook = partial(hook, context)
758
- _strategy = _strategy.map(hook)
759
- for hook in dispatcher.get_all_by_name("flatmap_case"):
760
- hook = partial(hook, context)
761
- _strategy = _strategy.flatmap(hook)
762
- return _strategy
763
-
764
- strategy = _apply_hooks(GLOBAL_HOOK_DISPATCHER, strategy)
765
- strategy = _apply_hooks(self.schema.hooks, strategy)
766
- if hooks is not None:
767
- strategy = _apply_hooks(hooks, strategy)
768
- return strategy
769
-
770
- def get_security_requirements(self) -> list[str]:
771
- return self.schema.get_security_requirements(self)
772
-
773
- def get_strategies_from_examples(self) -> list[st.SearchStrategy[Case]]:
774
- """Get examples from the API operation."""
775
- return self.schema.get_strategies_from_examples(self)
776
-
777
- def get_stateful_tests(self, response: GenericResponse, stateful: Stateful | None) -> Sequence[StatefulTest]:
778
- return self.schema.get_stateful_tests(response, self, stateful)
779
-
780
- def get_parameter_serializer(self, location: str) -> Callable | None:
781
- """Get a function that serializes parameters for the given location.
782
-
783
- It handles serializing data into various `collectionFormat` options and similar.
784
- Note that payload is handled by this function - it is handled by serializers.
785
- """
786
- return self.schema.get_parameter_serializer(self, location)
787
-
788
- def prepare_multipart(self, form_data: FormData) -> tuple[list | None, dict[str, Any] | None]:
789
- return self.schema.prepare_multipart(form_data, self)
790
-
791
- def get_request_payload_content_types(self) -> list[str]:
792
- return self.schema.get_request_payload_content_types(self)
793
-
794
- def partial_deepcopy(self) -> APIOperation:
795
- return self.__class__(
796
- path=self.path, # string, immutable
797
- method=self.method, # string, immutable
798
- definition=fast_deepcopy(self.definition),
799
- schema=self.schema.clone(), # shallow copy
800
- verbose_name=self.verbose_name, # string, immutable
801
- app=self.app, # not deepcopyable
802
- base_url=self.base_url, # string, immutable
803
- path_parameters=fast_deepcopy(self.path_parameters),
804
- headers=fast_deepcopy(self.headers),
805
- cookies=fast_deepcopy(self.cookies),
806
- query=fast_deepcopy(self.query),
807
- body=fast_deepcopy(self.body),
808
- )
809
-
810
- def clone(self, **components: Any) -> APIOperation:
811
- """Create a new instance of this API operation with updated components."""
812
- return self.__class__(
813
- path=self.path,
814
- method=self.method,
815
- verbose_name=self.verbose_name,
816
- definition=self.definition,
817
- schema=self.schema,
818
- app=self.app,
819
- base_url=self.base_url,
820
- path_parameters=components["path_parameters"],
821
- query=components["query"],
822
- headers=components["headers"],
823
- cookies=components["cookies"],
824
- body=components["body"],
825
- )
826
-
827
- def make_case(
828
- self,
829
- *,
830
- path_parameters: PathParameters | None = None,
831
- headers: Headers | None = None,
832
- cookies: Cookies | None = None,
833
- query: Query | None = None,
834
- body: Body | NotSet = NOT_SET,
835
- media_type: str | None = None,
836
- ) -> C:
837
- """Create a new example for this API operation.
838
-
839
- The main use case is constructing Case instances completely manually, without data generation.
840
- """
841
- return self.schema.make_case(
842
- case_cls=self.case_cls,
843
- operation=self,
844
- path_parameters=path_parameters,
845
- headers=headers,
846
- cookies=cookies,
847
- query=query,
848
- body=body,
849
- media_type=media_type,
850
- )
851
-
852
- @property
853
- def operation_reference(self) -> str:
854
- path = self.path.replace("~", "~0").replace("/", "~1")
855
- return f"#/paths/{path}/{self.method}"
856
-
857
- def validate_response(self, response: GenericResponse) -> bool | None:
858
- """Validate API response for conformance.
859
-
860
- :raises CheckFailed: If the response does not conform to the API schema.
861
- """
862
- return self.schema.validate_response(self, response)
863
-
864
- def is_response_valid(self, response: GenericResponse) -> bool:
865
- """Validate API response for conformance."""
866
- try:
867
- self.validate_response(response)
868
- return True
869
- except CheckFailed:
870
- return False
871
-
872
- def get_raw_payload_schema(self, media_type: str) -> dict[str, Any] | None:
873
- return self.schema._get_payload_schema(self.definition.raw, media_type)
874
-
875
- def get_resolved_payload_schema(self, media_type: str) -> dict[str, Any] | None:
876
- return self.schema._get_payload_schema(self.definition.resolved, media_type)
877
-
878
-
879
- # backward-compatibility
880
- Endpoint = APIOperation
881
-
882
-
883
- class Status(str, Enum):
884
- """Status of an action or multiple actions."""
885
-
886
- success = "success"
887
- failure = "failure"
888
- error = "error"
889
- skip = "skip"
890
-
891
-
892
- @dataclass(repr=False)
893
- class Check:
894
- """Single check run result."""
895
-
896
- name: str
897
- value: Status
898
- response: GenericResponse | None
899
- elapsed: float
900
- example: Case
901
- message: str | None = None
902
- # Failure-specific context
903
- context: FailureContext | None = None
904
- request: requests.PreparedRequest | None = None
905
-
906
-
907
- @dataclass(repr=False)
908
- class Request:
909
- """Request data extracted from `Case`."""
910
-
911
- method: str
912
- uri: str
913
- body: str | None
914
- headers: Headers
915
-
916
- @classmethod
917
- def from_case(cls, case: Case, session: requests.Session) -> Request:
918
- """Create a new `Request` instance from `Case`."""
919
- import requests
920
-
921
- base_url = case.get_full_base_url()
922
- kwargs = case.as_requests_kwargs(base_url)
923
- request = requests.Request(**kwargs)
924
- prepared = session.prepare_request(request) # type: ignore
925
- return cls.from_prepared_request(prepared)
926
-
927
- @classmethod
928
- def from_prepared_request(cls, prepared: requests.PreparedRequest) -> Request:
929
- """A prepared request version is already stored in `requests.Response`."""
930
- body = prepared.body
931
-
932
- if isinstance(body, str):
933
- # can be a string for `application/x-www-form-urlencoded`
934
- body = body.encode("utf-8")
935
-
936
- # these values have `str` type at this point
937
- uri = cast(str, prepared.url)
938
- method = cast(str, prepared.method)
939
- return cls(
940
- uri=uri,
941
- method=method,
942
- headers={key: [value] for (key, value) in prepared.headers.items()},
943
- body=serialize_payload(body) if body is not None else body,
944
- )
945
-
946
-
947
- @dataclass(repr=False)
948
- class Response:
949
- """Unified response data."""
950
-
951
- status_code: int
952
- message: str
953
- headers: dict[str, list[str]]
954
- body: str | None
955
- encoding: str | None
956
- http_version: str
957
- elapsed: float
958
- verify: bool
959
-
960
- @classmethod
961
- def from_requests(cls, response: requests.Response) -> Response:
962
- """Create a response from requests.Response."""
963
- headers = {name: response.raw.headers.getlist(name) for name in response.raw.headers.keys()}
964
- # Similar to http.client:319 (HTTP version detection in stdlib's `http` package)
965
- http_version = "1.0" if response.raw.version == 10 else "1.1"
966
-
967
- def is_empty(_response: requests.Response) -> bool:
968
- # Assume the response is empty if:
969
- # - no `Content-Length` header
970
- # - no chunks when iterating over its content
971
- return "Content-Length" not in headers and list(_response.iter_content()) == []
972
-
973
- body = None if is_empty(response) else serialize_payload(response.content)
974
- return cls(
975
- status_code=response.status_code,
976
- message=response.reason,
977
- body=body,
978
- encoding=response.encoding,
979
- headers=headers,
980
- http_version=http_version,
981
- elapsed=response.elapsed.total_seconds(),
982
- verify=getattr(response, "verify", True),
983
- )
984
-
985
- @classmethod
986
- def from_wsgi(cls, response: WSGIResponse, elapsed: float) -> Response:
987
- """Create a response from WSGI response."""
988
- from .transports.responses import get_reason
989
-
990
- message = get_reason(response.status_code)
991
- headers = {name: response.headers.getlist(name) for name in response.headers.keys()}
992
- # Note, this call ensures that `response.response` is a sequence, which is needed for comparison
993
- data = response.get_data()
994
- body = None if response.response == [] else serialize_payload(data)
995
- encoding: str | None
996
- if body is not None:
997
- # Werkzeug <3.0 had `charset` attr, newer versions always have UTF-8
998
- encoding = response.mimetype_params.get("charset", getattr(response, "charset", "utf-8"))
999
- else:
1000
- encoding = None
1001
- return cls(
1002
- status_code=response.status_code,
1003
- message=message,
1004
- body=body,
1005
- encoding=encoding,
1006
- headers=headers,
1007
- http_version="1.1",
1008
- elapsed=elapsed,
1009
- verify=True,
1010
- )
1011
-
1012
-
1013
- @dataclass
1014
- class Interaction:
1015
- """A single interaction with the target app."""
1016
-
1017
- request: Request
1018
- response: Response
1019
- checks: list[Check]
1020
- status: Status
1021
- data_generation_method: DataGenerationMethod
1022
- recorded_at: str = field(default_factory=lambda: datetime.datetime.now().isoformat())
1023
-
1024
- @classmethod
1025
- def from_requests(cls, case: Case, response: requests.Response, status: Status, checks: list[Check]) -> Interaction:
1026
- return cls(
1027
- request=Request.from_prepared_request(response.request),
1028
- response=Response.from_requests(response),
1029
- status=status,
1030
- checks=checks,
1031
- data_generation_method=cast(DataGenerationMethod, case.data_generation_method),
1032
- )
1033
-
1034
- @classmethod
1035
- def from_wsgi(
1036
- cls,
1037
- case: Case,
1038
- response: WSGIResponse,
1039
- headers: dict[str, Any],
1040
- elapsed: float,
1041
- status: Status,
1042
- checks: list[Check],
1043
- ) -> Interaction:
1044
- import requests
1045
-
1046
- session = requests.Session()
1047
- session.headers.update(headers)
1048
- return cls(
1049
- request=Request.from_case(case, session),
1050
- response=Response.from_wsgi(response, elapsed),
1051
- status=status,
1052
- checks=checks,
1053
- data_generation_method=cast(DataGenerationMethod, case.data_generation_method),
1054
- )
1055
-
1056
-
1057
- @dataclass(repr=False)
1058
- class TestResult:
1059
- """Result of a single test."""
1060
-
1061
- __test__ = False
1062
-
1063
- method: str
1064
- path: str
1065
- verbose_name: str
1066
- data_generation_method: list[DataGenerationMethod]
1067
- checks: list[Check] = field(default_factory=list)
1068
- errors: list[Exception] = field(default_factory=list)
1069
- interactions: list[Interaction] = field(default_factory=list)
1070
- logs: list[LogRecord] = field(default_factory=list)
1071
- is_errored: bool = False
1072
- is_flaky: bool = False
1073
- is_skipped: bool = False
1074
- skip_reason: str | None = None
1075
- is_executed: bool = False
1076
- # DEPRECATED: Seed is the same per test run
1077
- seed: int | None = None
1078
-
1079
- def mark_errored(self) -> None:
1080
- self.is_errored = True
1081
-
1082
- def mark_flaky(self) -> None:
1083
- self.is_flaky = True
1084
-
1085
- def mark_skipped(self, exc: SkipTest | unittest.case.SkipTest | None) -> None:
1086
- self.is_skipped = True
1087
- if exc is not None:
1088
- self.skip_reason = str(exc)
1089
-
1090
- def mark_executed(self) -> None:
1091
- self.is_executed = True
1092
-
1093
- @property
1094
- def has_errors(self) -> bool:
1095
- return bool(self.errors)
1096
-
1097
- @property
1098
- def has_failures(self) -> bool:
1099
- return any(check.value == Status.failure for check in self.checks)
1100
-
1101
- @property
1102
- def has_logs(self) -> bool:
1103
- return bool(self.logs)
1104
-
1105
- def add_success(self, name: str, example: Case, response: GenericResponse, elapsed: float) -> Check:
1106
- check = Check(
1107
- name=name, value=Status.success, response=response, elapsed=elapsed, example=example, request=None
1108
- )
1109
- self.checks.append(check)
1110
- return check
1111
-
1112
- def add_failure(
1113
- self,
1114
- name: str,
1115
- example: Case,
1116
- response: GenericResponse | None,
1117
- elapsed: float,
1118
- message: str,
1119
- context: FailureContext | None,
1120
- request: requests.PreparedRequest | None = None,
1121
- ) -> Check:
1122
- check = Check(
1123
- name=name,
1124
- value=Status.failure,
1125
- response=response,
1126
- elapsed=elapsed,
1127
- example=example,
1128
- message=message,
1129
- context=context,
1130
- request=request,
1131
- )
1132
- self.checks.append(check)
1133
- return check
1134
-
1135
- def add_error(self, exception: Exception) -> None:
1136
- self.errors.append(exception)
1137
-
1138
- def store_requests_response(
1139
- self, case: Case, response: requests.Response, status: Status, checks: list[Check]
1140
- ) -> None:
1141
- self.interactions.append(Interaction.from_requests(case, response, status, checks))
1142
-
1143
- def store_wsgi_response(
1144
- self,
1145
- case: Case,
1146
- response: WSGIResponse,
1147
- headers: dict[str, Any],
1148
- elapsed: float,
1149
- status: Status,
1150
- checks: list[Check],
1151
- ) -> None:
1152
- self.interactions.append(Interaction.from_wsgi(case, response, headers, elapsed, status, checks))
1153
-
1154
-
1155
- @dataclass(repr=False)
1156
- class TestResultSet:
1157
- """Set of multiple test results."""
1158
-
1159
- __test__ = False
1160
-
1161
- seed: int | None
1162
- results: list[TestResult] = field(default_factory=list)
1163
- generic_errors: list[OperationSchemaError] = field(default_factory=list)
1164
- warnings: list[str] = field(default_factory=list)
1165
-
1166
- def __iter__(self) -> Iterator[TestResult]:
1167
- return iter(self.results)
1168
-
1169
- @property
1170
- def is_empty(self) -> bool:
1171
- """If the result set contains no results."""
1172
- return len(self.results) == 0 and len(self.generic_errors) == 0
1173
-
1174
- @property
1175
- def has_failures(self) -> bool:
1176
- """If any result has any failures."""
1177
- return any(result.has_failures for result in self)
1178
-
1179
- @property
1180
- def has_errors(self) -> bool:
1181
- """If any result has any errors."""
1182
- return self.errored_count > 0
1183
-
1184
- @property
1185
- def has_logs(self) -> bool:
1186
- """If any result has any captured logs."""
1187
- return any(result.has_logs for result in self)
1188
-
1189
- def _count(self, predicate: Callable) -> int:
1190
- return sum(1 for result in self if predicate(result))
1191
-
1192
- @property
1193
- def passed_count(self) -> int:
1194
- return self._count(lambda result: not result.has_errors and not result.is_skipped and not result.has_failures)
1195
-
1196
- @property
1197
- def skipped_count(self) -> int:
1198
- return self._count(lambda result: result.is_skipped)
1199
-
1200
- @property
1201
- def failed_count(self) -> int:
1202
- return self._count(lambda result: result.has_failures and not result.is_errored)
1203
-
1204
- @property
1205
- def errored_count(self) -> int:
1206
- return self._count(lambda result: result.has_errors or result.is_errored) + len(self.generic_errors)
1207
-
1208
- @property
1209
- def total(self) -> dict[str, dict[str | Status, int]]:
1210
- """An aggregated statistic about test results."""
1211
- output: dict[str, dict[str | Status, int]] = {}
1212
- for item in self.results:
1213
- for check in item.checks:
1214
- output.setdefault(check.name, Counter())
1215
- output[check.name][check.value] += 1
1216
- output[check.name]["total"] += 1
1217
- # Avoid using Counter, since its behavior could harm in other places:
1218
- # `if not total["unknown"]:` - this will lead to the branch execution
1219
- # It is better to let it fail if there is a wrong key
1220
- return {key: dict(value) for key, value in output.items()}
1221
-
1222
- def append(self, item: TestResult) -> None:
1223
- """Add a new item to the results list."""
1224
- self.results.append(item)
1225
-
1226
- def add_warning(self, warning: str) -> None:
1227
- """Add a new warning to the warnings list."""
1228
- self.warnings.append(warning)
1229
-
1230
-
1231
- CheckFunction = Callable[["GenericResponse", Case], Optional[bool]]