schemathesis 3.15.4__py3-none-any.whl → 4.4.2__py3-none-any.whl

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