schemathesis 3.39.15__py3-none-any.whl → 4.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. schemathesis/__init__.py +41 -79
  2. schemathesis/auths.py +111 -122
  3. schemathesis/checks.py +169 -60
  4. schemathesis/cli/__init__.py +15 -2117
  5. schemathesis/cli/commands/__init__.py +85 -0
  6. schemathesis/cli/commands/data.py +10 -0
  7. schemathesis/cli/commands/run/__init__.py +590 -0
  8. schemathesis/cli/commands/run/context.py +204 -0
  9. schemathesis/cli/commands/run/events.py +60 -0
  10. schemathesis/cli/commands/run/executor.py +157 -0
  11. schemathesis/cli/commands/run/filters.py +53 -0
  12. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  13. schemathesis/cli/commands/run/handlers/base.py +18 -0
  14. schemathesis/cli/commands/run/handlers/cassettes.py +474 -0
  15. schemathesis/cli/commands/run/handlers/junitxml.py +55 -0
  16. schemathesis/cli/commands/run/handlers/output.py +1628 -0
  17. schemathesis/cli/commands/run/loaders.py +114 -0
  18. schemathesis/cli/commands/run/validation.py +246 -0
  19. schemathesis/cli/constants.py +5 -58
  20. schemathesis/cli/core.py +19 -0
  21. schemathesis/cli/ext/fs.py +16 -0
  22. schemathesis/cli/ext/groups.py +84 -0
  23. schemathesis/cli/{options.py → ext/options.py} +36 -34
  24. schemathesis/config/__init__.py +189 -0
  25. schemathesis/config/_auth.py +51 -0
  26. schemathesis/config/_checks.py +268 -0
  27. schemathesis/config/_diff_base.py +99 -0
  28. schemathesis/config/_env.py +21 -0
  29. schemathesis/config/_error.py +156 -0
  30. schemathesis/config/_generation.py +149 -0
  31. schemathesis/config/_health_check.py +24 -0
  32. schemathesis/config/_operations.py +327 -0
  33. schemathesis/config/_output.py +171 -0
  34. schemathesis/config/_parameters.py +19 -0
  35. schemathesis/config/_phases.py +187 -0
  36. schemathesis/config/_projects.py +527 -0
  37. schemathesis/config/_rate_limit.py +17 -0
  38. schemathesis/config/_report.py +120 -0
  39. schemathesis/config/_validator.py +9 -0
  40. schemathesis/config/_warnings.py +25 -0
  41. schemathesis/config/schema.json +885 -0
  42. schemathesis/core/__init__.py +67 -0
  43. schemathesis/core/compat.py +32 -0
  44. schemathesis/core/control.py +2 -0
  45. schemathesis/core/curl.py +58 -0
  46. schemathesis/core/deserialization.py +65 -0
  47. schemathesis/core/errors.py +459 -0
  48. schemathesis/core/failures.py +315 -0
  49. schemathesis/core/fs.py +19 -0
  50. schemathesis/core/hooks.py +20 -0
  51. schemathesis/core/loaders.py +104 -0
  52. schemathesis/core/marks.py +66 -0
  53. schemathesis/{transports/content_types.py → core/media_types.py} +14 -12
  54. schemathesis/core/output/__init__.py +46 -0
  55. schemathesis/core/output/sanitization.py +54 -0
  56. schemathesis/{throttling.py → core/rate_limit.py} +16 -17
  57. schemathesis/core/registries.py +31 -0
  58. schemathesis/core/transforms.py +113 -0
  59. schemathesis/core/transport.py +223 -0
  60. schemathesis/core/validation.py +54 -0
  61. schemathesis/core/version.py +7 -0
  62. schemathesis/engine/__init__.py +28 -0
  63. schemathesis/engine/context.py +118 -0
  64. schemathesis/engine/control.py +36 -0
  65. schemathesis/engine/core.py +169 -0
  66. schemathesis/engine/errors.py +464 -0
  67. schemathesis/engine/events.py +258 -0
  68. schemathesis/engine/phases/__init__.py +88 -0
  69. schemathesis/{runner → engine/phases}/probes.py +52 -68
  70. schemathesis/engine/phases/stateful/__init__.py +68 -0
  71. schemathesis/engine/phases/stateful/_executor.py +356 -0
  72. schemathesis/engine/phases/stateful/context.py +85 -0
  73. schemathesis/engine/phases/unit/__init__.py +212 -0
  74. schemathesis/engine/phases/unit/_executor.py +416 -0
  75. schemathesis/engine/phases/unit/_pool.py +82 -0
  76. schemathesis/engine/recorder.py +247 -0
  77. schemathesis/errors.py +43 -0
  78. schemathesis/filters.py +17 -98
  79. schemathesis/generation/__init__.py +5 -33
  80. schemathesis/generation/case.py +317 -0
  81. schemathesis/generation/coverage.py +282 -175
  82. schemathesis/generation/hypothesis/__init__.py +36 -0
  83. schemathesis/generation/hypothesis/builder.py +800 -0
  84. schemathesis/generation/{_hypothesis.py → hypothesis/examples.py} +2 -11
  85. schemathesis/generation/hypothesis/given.py +66 -0
  86. schemathesis/generation/hypothesis/reporting.py +14 -0
  87. schemathesis/generation/hypothesis/strategies.py +16 -0
  88. schemathesis/generation/meta.py +115 -0
  89. schemathesis/generation/metrics.py +93 -0
  90. schemathesis/generation/modes.py +20 -0
  91. schemathesis/generation/overrides.py +116 -0
  92. schemathesis/generation/stateful/__init__.py +37 -0
  93. schemathesis/generation/stateful/state_machine.py +278 -0
  94. schemathesis/graphql/__init__.py +15 -0
  95. schemathesis/graphql/checks.py +109 -0
  96. schemathesis/graphql/loaders.py +284 -0
  97. schemathesis/hooks.py +80 -101
  98. schemathesis/openapi/__init__.py +13 -0
  99. schemathesis/openapi/checks.py +455 -0
  100. schemathesis/openapi/generation/__init__.py +0 -0
  101. schemathesis/openapi/generation/filters.py +72 -0
  102. schemathesis/openapi/loaders.py +313 -0
  103. schemathesis/pytest/__init__.py +5 -0
  104. schemathesis/pytest/control_flow.py +7 -0
  105. schemathesis/pytest/lazy.py +281 -0
  106. schemathesis/pytest/loaders.py +36 -0
  107. schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +128 -108
  108. schemathesis/python/__init__.py +0 -0
  109. schemathesis/python/asgi.py +12 -0
  110. schemathesis/python/wsgi.py +12 -0
  111. schemathesis/schemas.py +537 -273
  112. schemathesis/specs/graphql/__init__.py +0 -1
  113. schemathesis/specs/graphql/_cache.py +1 -2
  114. schemathesis/specs/graphql/scalars.py +42 -6
  115. schemathesis/specs/graphql/schemas.py +141 -137
  116. schemathesis/specs/graphql/validation.py +11 -17
  117. schemathesis/specs/openapi/__init__.py +6 -1
  118. schemathesis/specs/openapi/_cache.py +1 -2
  119. schemathesis/specs/openapi/_hypothesis.py +142 -156
  120. schemathesis/specs/openapi/checks.py +368 -257
  121. schemathesis/specs/openapi/converter.py +4 -4
  122. schemathesis/specs/openapi/definitions.py +1 -1
  123. schemathesis/specs/openapi/examples.py +23 -21
  124. schemathesis/specs/openapi/expressions/__init__.py +31 -19
  125. schemathesis/specs/openapi/expressions/extractors.py +1 -4
  126. schemathesis/specs/openapi/expressions/lexer.py +1 -1
  127. schemathesis/specs/openapi/expressions/nodes.py +36 -41
  128. schemathesis/specs/openapi/expressions/parser.py +1 -1
  129. schemathesis/specs/openapi/formats.py +35 -7
  130. schemathesis/specs/openapi/media_types.py +53 -12
  131. schemathesis/specs/openapi/negative/__init__.py +7 -4
  132. schemathesis/specs/openapi/negative/mutations.py +6 -5
  133. schemathesis/specs/openapi/parameters.py +7 -10
  134. schemathesis/specs/openapi/patterns.py +94 -31
  135. schemathesis/specs/openapi/references.py +12 -53
  136. schemathesis/specs/openapi/schemas.py +238 -308
  137. schemathesis/specs/openapi/security.py +1 -1
  138. schemathesis/specs/openapi/serialization.py +12 -6
  139. schemathesis/specs/openapi/stateful/__init__.py +268 -133
  140. schemathesis/specs/openapi/stateful/control.py +87 -0
  141. schemathesis/specs/openapi/stateful/links.py +209 -0
  142. schemathesis/transport/__init__.py +142 -0
  143. schemathesis/transport/asgi.py +26 -0
  144. schemathesis/transport/prepare.py +124 -0
  145. schemathesis/transport/requests.py +244 -0
  146. schemathesis/{_xml.py → transport/serialization.py} +69 -11
  147. schemathesis/transport/wsgi.py +171 -0
  148. schemathesis-4.0.0.dist-info/METADATA +204 -0
  149. schemathesis-4.0.0.dist-info/RECORD +164 -0
  150. {schemathesis-3.39.15.dist-info → schemathesis-4.0.0.dist-info}/entry_points.txt +1 -1
  151. {schemathesis-3.39.15.dist-info → schemathesis-4.0.0.dist-info}/licenses/LICENSE +1 -1
  152. schemathesis/_compat.py +0 -74
  153. schemathesis/_dependency_versions.py +0 -19
  154. schemathesis/_hypothesis.py +0 -712
  155. schemathesis/_override.py +0 -50
  156. schemathesis/_patches.py +0 -21
  157. schemathesis/_rate_limiter.py +0 -7
  158. schemathesis/cli/callbacks.py +0 -466
  159. schemathesis/cli/cassettes.py +0 -561
  160. schemathesis/cli/context.py +0 -75
  161. schemathesis/cli/debug.py +0 -27
  162. schemathesis/cli/handlers.py +0 -19
  163. schemathesis/cli/junitxml.py +0 -124
  164. schemathesis/cli/output/__init__.py +0 -1
  165. schemathesis/cli/output/default.py +0 -920
  166. schemathesis/cli/output/short.py +0 -59
  167. schemathesis/cli/reporting.py +0 -79
  168. schemathesis/cli/sanitization.py +0 -26
  169. schemathesis/code_samples.py +0 -151
  170. schemathesis/constants.py +0 -54
  171. schemathesis/contrib/__init__.py +0 -11
  172. schemathesis/contrib/openapi/__init__.py +0 -11
  173. schemathesis/contrib/openapi/fill_missing_examples.py +0 -24
  174. schemathesis/contrib/openapi/formats/__init__.py +0 -9
  175. schemathesis/contrib/openapi/formats/uuid.py +0 -16
  176. schemathesis/contrib/unique_data.py +0 -41
  177. schemathesis/exceptions.py +0 -571
  178. schemathesis/experimental/__init__.py +0 -109
  179. schemathesis/extra/_aiohttp.py +0 -28
  180. schemathesis/extra/_flask.py +0 -13
  181. schemathesis/extra/_server.py +0 -18
  182. schemathesis/failures.py +0 -284
  183. schemathesis/fixups/__init__.py +0 -37
  184. schemathesis/fixups/fast_api.py +0 -41
  185. schemathesis/fixups/utf8_bom.py +0 -28
  186. schemathesis/generation/_methods.py +0 -44
  187. schemathesis/graphql.py +0 -3
  188. schemathesis/internal/__init__.py +0 -7
  189. schemathesis/internal/checks.py +0 -86
  190. schemathesis/internal/copy.py +0 -32
  191. schemathesis/internal/datetime.py +0 -5
  192. schemathesis/internal/deprecation.py +0 -37
  193. schemathesis/internal/diff.py +0 -15
  194. schemathesis/internal/extensions.py +0 -27
  195. schemathesis/internal/jsonschema.py +0 -36
  196. schemathesis/internal/output.py +0 -68
  197. schemathesis/internal/transformation.py +0 -26
  198. schemathesis/internal/validation.py +0 -34
  199. schemathesis/lazy.py +0 -474
  200. schemathesis/loaders.py +0 -122
  201. schemathesis/models.py +0 -1341
  202. schemathesis/parameters.py +0 -90
  203. schemathesis/runner/__init__.py +0 -605
  204. schemathesis/runner/events.py +0 -389
  205. schemathesis/runner/impl/__init__.py +0 -3
  206. schemathesis/runner/impl/context.py +0 -88
  207. schemathesis/runner/impl/core.py +0 -1280
  208. schemathesis/runner/impl/solo.py +0 -80
  209. schemathesis/runner/impl/threadpool.py +0 -391
  210. schemathesis/runner/serialization.py +0 -544
  211. schemathesis/sanitization.py +0 -252
  212. schemathesis/serializers.py +0 -328
  213. schemathesis/service/__init__.py +0 -18
  214. schemathesis/service/auth.py +0 -11
  215. schemathesis/service/ci.py +0 -202
  216. schemathesis/service/client.py +0 -133
  217. schemathesis/service/constants.py +0 -38
  218. schemathesis/service/events.py +0 -61
  219. schemathesis/service/extensions.py +0 -224
  220. schemathesis/service/hosts.py +0 -111
  221. schemathesis/service/metadata.py +0 -71
  222. schemathesis/service/models.py +0 -258
  223. schemathesis/service/report.py +0 -255
  224. schemathesis/service/serialization.py +0 -173
  225. schemathesis/service/usage.py +0 -66
  226. schemathesis/specs/graphql/loaders.py +0 -364
  227. schemathesis/specs/openapi/expressions/context.py +0 -16
  228. schemathesis/specs/openapi/links.py +0 -389
  229. schemathesis/specs/openapi/loaders.py +0 -707
  230. schemathesis/specs/openapi/stateful/statistic.py +0 -198
  231. schemathesis/specs/openapi/stateful/types.py +0 -14
  232. schemathesis/specs/openapi/validation.py +0 -26
  233. schemathesis/stateful/__init__.py +0 -147
  234. schemathesis/stateful/config.py +0 -97
  235. schemathesis/stateful/context.py +0 -135
  236. schemathesis/stateful/events.py +0 -274
  237. schemathesis/stateful/runner.py +0 -309
  238. schemathesis/stateful/sink.py +0 -68
  239. schemathesis/stateful/state_machine.py +0 -328
  240. schemathesis/stateful/statistic.py +0 -22
  241. schemathesis/stateful/validation.py +0 -100
  242. schemathesis/targets.py +0 -77
  243. schemathesis/transports/__init__.py +0 -369
  244. schemathesis/transports/asgi.py +0 -7
  245. schemathesis/transports/auth.py +0 -38
  246. schemathesis/transports/headers.py +0 -36
  247. schemathesis/transports/responses.py +0 -57
  248. schemathesis/types.py +0 -44
  249. schemathesis/utils.py +0 -164
  250. schemathesis-3.39.15.dist-info/METADATA +0 -293
  251. schemathesis-3.39.15.dist-info/RECORD +0 -160
  252. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
  253. /schemathesis/{_lazy_import.py → core/lazy_import.py} +0 -0
  254. /schemathesis/{internal → core}/result.py +0 -0
  255. {schemathesis-3.39.15.dist-info → schemathesis-4.0.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,315 @@
1
+ from __future__ import annotations
2
+
3
+ import http.client
4
+ import textwrap
5
+ import traceback
6
+ from collections.abc import Sequence
7
+ from dataclasses import dataclass
8
+ from enum import Enum, auto
9
+ from json import JSONDecodeError
10
+ from typing import Any, Callable
11
+
12
+ from schemathesis.config import OutputConfig
13
+ from schemathesis.core.compat import BaseExceptionGroup
14
+ from schemathesis.core.output import prepare_response_payload
15
+ from schemathesis.core.transport import Response
16
+
17
+
18
+ class Severity(Enum):
19
+ # For server errors, security issues like ignored auth
20
+ CRITICAL = auto()
21
+ # For schema violations
22
+ HIGH = auto()
23
+ # For content type issues, header problems
24
+ MEDIUM = auto()
25
+ # For performance issues, minor inconsistencies
26
+ LOW = auto()
27
+
28
+ def __lt__(self, other: Severity) -> bool:
29
+ # Lower values are more severe
30
+ return self.value < other.value
31
+
32
+
33
+ @dataclass
34
+ class Failure(AssertionError):
35
+ """API check failure."""
36
+
37
+ __slots__ = ("operation", "title", "message", "case_id", "severity")
38
+
39
+ def __init__(
40
+ self,
41
+ *,
42
+ operation: str,
43
+ title: str,
44
+ message: str,
45
+ case_id: str | None = None,
46
+ severity: Severity = Severity.MEDIUM,
47
+ ) -> None:
48
+ self.operation = operation
49
+ self.title = title
50
+ self.message = message
51
+ self.case_id = case_id
52
+ self.severity = severity
53
+
54
+ def __str__(self) -> str:
55
+ if not self.message:
56
+ return self.title
57
+ return f"{self.title}\n\n{self.message}"
58
+
59
+ def __lt__(self, other: Failure) -> bool:
60
+ return (
61
+ self.severity,
62
+ self.__class__.__name__,
63
+ self.message,
64
+ ) < (other.severity, other.__class__.__name__, other.message)
65
+
66
+ # Comparison & hashing is done purely on classes to simplify keeping the minimized failure during shrinking
67
+ def __hash__(self) -> int:
68
+ return hash(self.__class__)
69
+
70
+ def __eq__(self, other: object, /) -> bool:
71
+ if not isinstance(other, Failure):
72
+ return NotImplemented
73
+ return type(self) is type(other) and self.operation == other.operation and self._unique_key == other._unique_key
74
+
75
+ @property
76
+ def _unique_key(self) -> Any:
77
+ return self.message
78
+
79
+
80
+ def get_origin(exception: BaseException, seen: tuple[BaseException, ...] = ()) -> tuple:
81
+ filename, lineno = None, None
82
+ if tb := exception.__traceback__:
83
+ filename, lineno, *_ = traceback.extract_tb(tb)[-1]
84
+ seen = (*seen, exception)
85
+ context = ()
86
+ if exception.__context__ is not None and exception.__context__ not in seen:
87
+ context = get_origin(exception.__context__, seen=seen)
88
+ return (
89
+ type(exception),
90
+ filename,
91
+ lineno,
92
+ context,
93
+ (
94
+ tuple(get_origin(exc, seen=seen) for exc in exception.exceptions if exc not in seen)
95
+ if isinstance(exception, BaseExceptionGroup)
96
+ else ()
97
+ ),
98
+ )
99
+
100
+
101
+ class CustomFailure(Failure):
102
+ __slots__ = ("operation", "title", "message", "exception", "case_id", "severity", "origin")
103
+
104
+ def __init__(
105
+ self,
106
+ *,
107
+ operation: str,
108
+ title: str,
109
+ message: str,
110
+ exception: AssertionError,
111
+ case_id: str | None = None,
112
+ severity: Severity = Severity.MEDIUM,
113
+ ) -> None:
114
+ self.operation = operation
115
+ self.title = title
116
+ self.message = message
117
+ self.exception = exception
118
+ self.case_id = case_id
119
+ self.severity = severity
120
+ self.origin = get_origin(exception)
121
+
122
+ @property
123
+ def _unique_key(self) -> Any:
124
+ return self.origin
125
+
126
+
127
+ class ResponseTimeExceeded(Failure):
128
+ """Response took longer than expected."""
129
+
130
+ __slots__ = ("operation", "elapsed", "deadline", "title", "message", "case_id", "severity")
131
+
132
+ def __init__(
133
+ self,
134
+ *,
135
+ operation: str,
136
+ elapsed: float,
137
+ deadline: float,
138
+ message: str,
139
+ title: str = "Response time limit exceeded",
140
+ case_id: str | None = None,
141
+ ) -> None:
142
+ self.operation = operation
143
+ self.elapsed = elapsed
144
+ self.deadline = deadline
145
+ self.title = title
146
+ self.message = message
147
+ self.case_id = case_id
148
+ self.severity = Severity.LOW
149
+
150
+ @property
151
+ def _unique_key(self) -> str:
152
+ return self.title
153
+
154
+
155
+ class ServerError(Failure):
156
+ """Server responded with an error."""
157
+
158
+ __slots__ = ("operation", "status_code", "title", "message", "case_id", "severity")
159
+
160
+ def __init__(
161
+ self,
162
+ *,
163
+ operation: str,
164
+ status_code: int,
165
+ title: str = "Server error",
166
+ message: str = "",
167
+ case_id: str | None = None,
168
+ ) -> None:
169
+ self.operation = operation
170
+ self.status_code = status_code
171
+ self.title = title
172
+ self.message = message
173
+ self.case_id = case_id
174
+ self.severity = Severity.CRITICAL
175
+
176
+ @property
177
+ def _unique_key(self) -> str:
178
+ return str(self.status_code)
179
+
180
+
181
+ class MalformedJson(Failure):
182
+ """Failed to deserialize JSON."""
183
+
184
+ __slots__ = (
185
+ "operation",
186
+ "validation_message",
187
+ "document",
188
+ "position",
189
+ "lineno",
190
+ "colno",
191
+ "message",
192
+ "title",
193
+ "case_id",
194
+ "severity",
195
+ )
196
+
197
+ def __init__(
198
+ self,
199
+ *,
200
+ operation: str,
201
+ validation_message: str,
202
+ document: str,
203
+ position: int,
204
+ lineno: int,
205
+ colno: int,
206
+ message: str,
207
+ title: str = "JSON deserialization error",
208
+ case_id: str | None = None,
209
+ ) -> None:
210
+ self.operation = operation
211
+ self.validation_message = validation_message
212
+ self.document = document
213
+ self.position = position
214
+ self.lineno = lineno
215
+ self.colno = colno
216
+ self.message = message
217
+ self.title = title
218
+ self.case_id = case_id
219
+ self.severity = Severity.MEDIUM
220
+
221
+ @property
222
+ def _unique_key(self) -> Any:
223
+ return self.title
224
+
225
+ @classmethod
226
+ def from_exception(cls, *, operation: str, exc: JSONDecodeError) -> MalformedJson:
227
+ message = f"Response must be valid JSON with 'Content-Type: application/json' header:\n\n {exc}"
228
+ return cls(
229
+ operation=operation,
230
+ message=message,
231
+ validation_message=exc.msg,
232
+ document=exc.doc,
233
+ position=exc.pos,
234
+ lineno=exc.lineno,
235
+ colno=exc.colno,
236
+ )
237
+
238
+
239
+ class FailureGroup(BaseExceptionGroup):
240
+ """Multiple distinct check failures."""
241
+
242
+ exceptions: Sequence[Failure]
243
+
244
+ def __init__(self, exceptions: Sequence[Failure], message: str = "", /) -> None:
245
+ super().__init__(message, exceptions)
246
+
247
+ def __new__(cls, failures: Sequence[Failure], message: str | None = None) -> FailureGroup:
248
+ if message is None:
249
+ message = failure_report_title(failures)
250
+ return super().__new__(cls, message, list(failures))
251
+
252
+
253
+ class MessageBlock(str, Enum):
254
+ CASE_ID = "case_id"
255
+ FAILURE = "failure"
256
+ STATUS = "status"
257
+ CURL = "curl"
258
+
259
+
260
+ BlockFormatter = Callable[[MessageBlock, str], str]
261
+
262
+
263
+ def failure_report_title(failures: Sequence[Failure]) -> str:
264
+ message = f"Schemathesis found {len(failures)} distinct failure"
265
+ if len(failures) > 1:
266
+ message += "s"
267
+ return message
268
+
269
+
270
+ def format_failures(
271
+ *,
272
+ case_id: str | None,
273
+ response: Response | None,
274
+ failures: Sequence[Failure],
275
+ curl: str,
276
+ formatter: BlockFormatter | None = None,
277
+ config: OutputConfig,
278
+ ) -> str:
279
+ """Format failure information with custom styling."""
280
+ formatter = formatter or (lambda _, x: x)
281
+
282
+ if case_id is not None:
283
+ output = formatter(MessageBlock.CASE_ID, f"{case_id}\n")
284
+ else:
285
+ output = ""
286
+
287
+ # Failures
288
+ for idx, failure in enumerate(failures):
289
+ output += formatter(MessageBlock.FAILURE, f"\n- {failure.title}")
290
+ if failure.message:
291
+ output += "\n\n"
292
+ output += textwrap.indent(failure.message, " ")
293
+ if idx != len(failures):
294
+ output += "\n"
295
+
296
+ # Response status
297
+ if isinstance(response, Response):
298
+ reason = http.client.responses.get(response.status_code, "Unknown")
299
+ output += formatter(MessageBlock.STATUS, f"\n[{response.status_code}] {reason}:\n")
300
+ # Response payload
301
+ if response.content is None or not response.content:
302
+ output += "\n <EMPTY>"
303
+ else:
304
+ try:
305
+ payload = prepare_response_payload(response.text, config=config)
306
+ output += textwrap.indent(f"\n`{payload}`", prefix=" ")
307
+ except UnicodeDecodeError:
308
+ output += "\n <BINARY>"
309
+ else:
310
+ output += "\n <NO RESPONSE>"
311
+
312
+ # cURL
313
+ output += "\n" + formatter(MessageBlock.CURL, f"\nReproduce with: \n\n {curl}")
314
+
315
+ return output
@@ -0,0 +1,19 @@
1
+ import os
2
+ import pathlib
3
+
4
+
5
+ def ensure_parent(path: os.PathLike, fail_silently: bool = True) -> None:
6
+ # Try to create the parent dir
7
+ try:
8
+ pathlib.Path(path).parent.mkdir(mode=0o755, parents=True, exist_ok=True)
9
+ except OSError:
10
+ if not fail_silently:
11
+ raise
12
+
13
+
14
+ def file_exists(path: str) -> bool:
15
+ try:
16
+ return pathlib.Path(path).is_file()
17
+ except OSError:
18
+ # For example, path could be too long
19
+ return False
@@ -0,0 +1,20 @@
1
+ import os
2
+ import sys
3
+
4
+ from schemathesis.core.errors import HookError
5
+
6
+ HOOKS_MODULE_ENV_VAR = "SCHEMATHESIS_HOOKS"
7
+
8
+
9
+ def load_from_env() -> None:
10
+ hooks = os.getenv(HOOKS_MODULE_ENV_VAR)
11
+ if hooks:
12
+ load_from_path(hooks)
13
+
14
+
15
+ def load_from_path(module_path: str) -> None:
16
+ try:
17
+ sys.path.append(os.getcwd()) # fix ModuleNotFoundError module in cwd
18
+ __import__(module_path)
19
+ except Exception as exc:
20
+ raise HookError(module_path) from exc
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import http.client
4
+ from typing import TYPE_CHECKING, Any, Callable, NoReturn
5
+
6
+ from schemathesis.core.errors import LoaderError, LoaderErrorKind, get_request_error_extras, get_request_error_message
7
+ from schemathesis.core.transport import DEFAULT_RESPONSE_TIMEOUT, USER_AGENT
8
+
9
+ if TYPE_CHECKING:
10
+ import requests
11
+
12
+
13
+ def prepare_request_kwargs(kwargs: dict[str, Any]) -> None:
14
+ """Prepare common request kwargs."""
15
+ headers = kwargs.setdefault("headers", {})
16
+ if "user-agent" not in {header.lower() for header in headers}:
17
+ kwargs["headers"]["User-Agent"] = USER_AGENT
18
+
19
+
20
+ def handle_request_error(exc: requests.RequestException) -> NoReturn:
21
+ """Handle request-level errors."""
22
+ import requests
23
+
24
+ url = exc.request.url if exc.request is not None else None
25
+ if isinstance(exc, requests.exceptions.SSLError):
26
+ kind = LoaderErrorKind.CONNECTION_SSL
27
+ elif isinstance(exc, requests.exceptions.ConnectionError):
28
+ kind = LoaderErrorKind.CONNECTION_OTHER
29
+ else:
30
+ kind = LoaderErrorKind.NETWORK_OTHER
31
+ raise LoaderError(
32
+ message=get_request_error_message(exc),
33
+ kind=kind,
34
+ url=url,
35
+ extras=get_request_error_extras(exc),
36
+ ) from exc
37
+
38
+
39
+ def raise_for_status(response: requests.Response) -> requests.Response:
40
+ """Handle response status codes."""
41
+ status_code = response.status_code
42
+ if status_code < 400:
43
+ return response
44
+
45
+ reason = http.client.responses.get(status_code, "Unknown")
46
+ if status_code >= 500:
47
+ message = f"Failed to load schema due to server error (HTTP {status_code} {reason})"
48
+ kind = LoaderErrorKind.HTTP_SERVER_ERROR
49
+ else:
50
+ message = f"Failed to load schema due to client error (HTTP {status_code} {reason})"
51
+ kind = (
52
+ LoaderErrorKind.HTTP_FORBIDDEN
53
+ if status_code == 403
54
+ else LoaderErrorKind.HTTP_NOT_FOUND
55
+ if status_code == 404
56
+ else LoaderErrorKind.HTTP_CLIENT_ERROR
57
+ )
58
+ raise LoaderError(message=message, kind=kind, url=response.request.url, extras=[])
59
+
60
+
61
+ def make_request(func: Callable[..., requests.Response], url: str, **kwargs: Any) -> requests.Response:
62
+ """Make HTTP request with error handling."""
63
+ import requests
64
+
65
+ try:
66
+ response = func(url, **kwargs)
67
+ return raise_for_status(response)
68
+ except requests.RequestException as exc:
69
+ handle_request_error(exc)
70
+
71
+
72
+ WAIT_FOR_SCHEMA_INTERVAL = 0.05
73
+
74
+
75
+ def load_from_url(
76
+ func: Callable[..., requests.Response],
77
+ *,
78
+ url: str,
79
+ wait_for_schema: float | None = None,
80
+ **kwargs: Any,
81
+ ) -> requests.Response:
82
+ """Load schema from URL with retries."""
83
+ import backoff
84
+ import requests
85
+
86
+ kwargs.setdefault("timeout", DEFAULT_RESPONSE_TIMEOUT)
87
+ prepare_request_kwargs(kwargs)
88
+
89
+ if wait_for_schema is not None:
90
+ func = backoff.on_exception(
91
+ backoff.constant,
92
+ requests.exceptions.ConnectionError,
93
+ max_time=wait_for_schema,
94
+ interval=WAIT_FOR_SCHEMA_INTERVAL,
95
+ )(func)
96
+
97
+ return make_request(func, url, **kwargs)
98
+
99
+
100
+ def require_relative_url(url: str) -> None:
101
+ """Raise an error if the URL is not relative."""
102
+ # Deliberately simplistic approach
103
+ if "://" in url or url.startswith("//"):
104
+ raise ValueError("Schema path should be relative for WSGI/ASGI loaders")
@@ -0,0 +1,66 @@
1
+ """A lightweight mechanism to attach Schemathesis-specific metadata to test functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Callable, Generic, TypeVar
7
+
8
+ from schemathesis.core import NOT_SET, NotSet
9
+
10
+ METADATA_ATTR = "_schemathesis_metadata"
11
+
12
+
13
+ @dataclass
14
+ class SchemathesisMetadata:
15
+ """Container for all Schemathesis-specific data attached to test functions."""
16
+
17
+
18
+ T = TypeVar("T")
19
+
20
+
21
+ class Mark(Generic[T]):
22
+ """Access to specific attributes in SchemathesisMetadata."""
23
+
24
+ def __init__(
25
+ self, *, attr_name: str, default: T | Callable[[], T] | None = None, check: Callable[[T], bool] | None = None
26
+ ) -> None:
27
+ self.attr_name = attr_name
28
+ self._default = default
29
+ self._check = check
30
+
31
+ def _get_default(self) -> T | None:
32
+ if callable(self._default):
33
+ return self._default()
34
+ return self._default
35
+
36
+ def _check_value(self, value: T) -> bool:
37
+ if self._check is not None:
38
+ return self._check(value)
39
+ return True
40
+
41
+ def get(self, func: Callable) -> T | None:
42
+ """Get marker value if it's set."""
43
+ metadata = getattr(func, METADATA_ATTR, None)
44
+ if metadata is None:
45
+ return self._get_default()
46
+ value = getattr(metadata, self.attr_name, NOT_SET)
47
+ if value is NOT_SET:
48
+ return self._get_default()
49
+ assert not isinstance(value, NotSet)
50
+ if self._check_value(value):
51
+ return value
52
+ return self._get_default()
53
+
54
+ def set(self, func: Callable, value: T) -> None:
55
+ """Set marker value, creating metadata if needed."""
56
+ if not hasattr(func, METADATA_ATTR):
57
+ setattr(func, METADATA_ATTR, SchemathesisMetadata())
58
+ metadata = getattr(func, METADATA_ATTR)
59
+ setattr(metadata, self.attr_name, value)
60
+
61
+ def is_set(self, func: Callable) -> bool:
62
+ """Check if function has metadata with this marker set."""
63
+ metadata = getattr(func, METADATA_ATTR, None)
64
+ if metadata is None:
65
+ return False
66
+ return hasattr(metadata, self.attr_name)
@@ -1,6 +1,8 @@
1
1
  from functools import lru_cache
2
2
  from typing import Generator, Tuple
3
3
 
4
+ from schemathesis.core.errors import MalformedMediaType
5
+
4
6
 
5
7
  def _parseparam(s: str) -> Generator[str, None, None]:
6
8
  while s[:1] == ";":
@@ -15,7 +17,7 @@ def _parseparam(s: str) -> Generator[str, None, None]:
15
17
  s = s[end:]
16
18
 
17
19
 
18
- def parse_header(line: str) -> Tuple[str, dict]:
20
+ def _parse_header(line: str) -> Tuple[str, dict]:
19
21
  parts = _parseparam(";" + line)
20
22
  key = parts.__next__()
21
23
  pdict = {}
@@ -32,36 +34,36 @@ def parse_header(line: str) -> Tuple[str, dict]:
32
34
 
33
35
 
34
36
  @lru_cache
35
- def parse_content_type(content_type: str) -> Tuple[str, str]:
37
+ def parse(media_type: str) -> Tuple[str, str]:
36
38
  """Parse Content Type and return main type and subtype."""
37
39
  try:
38
- content_type, _ = parse_header(content_type)
39
- main_type, sub_type = content_type.split("/", 1)
40
+ media_type, _ = _parse_header(media_type)
41
+ main_type, sub_type = media_type.split("/", 1)
40
42
  except ValueError as exc:
41
- raise ValueError(f"Malformed media type: `{content_type}`") from exc
43
+ raise MalformedMediaType(f"Malformed media type: `{media_type}`") from exc
42
44
  return main_type.lower(), sub_type.lower()
43
45
 
44
46
 
45
- def is_json_media_type(value: str) -> bool:
47
+ def is_json(value: str) -> bool:
46
48
  """Detect whether the content type is JSON-compatible.
47
49
 
48
50
  For example - ``application/problem+json`` matches.
49
51
  """
50
- main, sub = parse_content_type(value)
52
+ main, sub = parse(value)
51
53
  return main == "application" and (sub == "json" or sub.endswith("+json"))
52
54
 
53
55
 
54
- def is_yaml_media_type(value: str) -> bool:
56
+ def is_yaml(value: str) -> bool:
55
57
  """Detect whether the content type is YAML-compatible."""
56
58
  return value in ("text/yaml", "text/x-yaml", "application/x-yaml", "text/vnd.yaml")
57
59
 
58
60
 
59
- def is_plain_text_media_type(value: str) -> bool:
61
+ def is_plain_text(value: str) -> bool:
60
62
  """Detect variations of the ``text/plain`` media type."""
61
- return parse_content_type(value) == ("text", "plain")
63
+ return parse(value) == ("text", "plain")
62
64
 
63
65
 
64
- def is_xml_media_type(value: str) -> bool:
66
+ def is_xml(value: str) -> bool:
65
67
  """Detect variations of the ``application/xml`` media type."""
66
- _, sub = parse_content_type(value)
68
+ _, sub = parse(value)
67
69
  return sub == "xml" or sub.endswith("+xml")
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ if TYPE_CHECKING:
7
+ from schemathesis.config import OutputConfig
8
+
9
+ TRUNCATED = "// Output truncated..."
10
+
11
+
12
+ def truncate_json(data: Any, *, config: OutputConfig, max_lines: int | None = None) -> str:
13
+ # Convert JSON to string with indentation
14
+ indent = 4
15
+ serialized = json.dumps(data, indent=indent)
16
+ if not config.truncation.enabled:
17
+ return serialized
18
+
19
+ max_lines = max_lines if max_lines is not None else config.truncation.max_lines
20
+ # Split string by lines
21
+ lines = [
22
+ line[: config.truncation.max_width - 3] + "..." if len(line) > config.truncation.max_width else line
23
+ for line in serialized.split("\n")
24
+ ]
25
+
26
+ if len(lines) <= max_lines:
27
+ return "\n".join(lines)
28
+
29
+ truncated_lines = lines[: max_lines - 1]
30
+ indentation = " " * indent
31
+ truncated_lines.append(f"{indentation}{TRUNCATED}")
32
+ truncated_lines.append(lines[-1])
33
+
34
+ return "\n".join(truncated_lines)
35
+
36
+
37
+ def prepare_response_payload(payload: str, *, config: OutputConfig) -> str:
38
+ if payload.endswith("\r\n"):
39
+ payload = payload[:-2]
40
+ elif payload.endswith("\n"):
41
+ payload = payload[:-1]
42
+ if not config.truncation.enabled:
43
+ return payload
44
+ if len(payload) > config.truncation.max_payload_size:
45
+ payload = payload[: config.truncation.max_payload_size] + f" {TRUNCATED}"
46
+ return payload
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import MutableMapping, MutableSequence
4
+ from typing import Any
5
+ from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
6
+
7
+ from schemathesis.config import SanitizationConfig
8
+
9
+
10
+ def sanitize_value(item: Any, *, config: SanitizationConfig) -> None:
11
+ """Sanitize sensitive values within a given item.
12
+
13
+ This function is recursive and will sanitize sensitive data within nested
14
+ dictionaries and lists as well.
15
+ """
16
+ if isinstance(item, MutableMapping):
17
+ for key in list(item.keys()):
18
+ lower_key = key.lower()
19
+ if lower_key in config.keys_to_sanitize or any(marker in lower_key for marker in config.sensitive_markers):
20
+ if isinstance(item[key], list):
21
+ item[key] = [config.replacement]
22
+ else:
23
+ item[key] = config.replacement
24
+ for value in item.values():
25
+ if isinstance(value, (MutableMapping, MutableSequence)):
26
+ sanitize_value(value, config=config)
27
+ elif isinstance(item, MutableSequence):
28
+ for value in item:
29
+ if isinstance(value, (MutableMapping, MutableSequence)):
30
+ sanitize_value(value, config=config)
31
+
32
+
33
+ def sanitize_url(url: str, *, config: SanitizationConfig) -> str:
34
+ """Sanitize sensitive parts of a given URL.
35
+
36
+ This function will sanitize the authority and query parameters in the URL.
37
+ """
38
+ parsed = urlsplit(url)
39
+
40
+ # Sanitize authority
41
+ netloc_parts = parsed.netloc.split("@")
42
+ if len(netloc_parts) > 1:
43
+ netloc = f"{config.replacement}@{netloc_parts[-1]}"
44
+ else:
45
+ netloc = parsed.netloc
46
+
47
+ # Sanitize query parameters
48
+ query = parse_qs(parsed.query, keep_blank_values=True)
49
+ sanitize_value(query, config=config)
50
+ sanitized_query = urlencode(query, doseq=True)
51
+
52
+ # Reconstruct the URL
53
+ sanitized_url_parts = parsed._replace(netloc=netloc, query=sanitized_query)
54
+ return urlunsplit(sanitized_url_parts)