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