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