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
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import enum
4
+ from dataclasses import dataclass
5
+
6
+ SCHEMATHESIS_TEST_CASE_HEADER = "X-Schemathesis-TestCaseId"
7
+ HYPOTHESIS_IN_MEMORY_DATABASE_IDENTIFIER = ":memory:"
8
+ INTERNAL_BUFFER_SIZE = 32 * 1024
9
+ DEFAULT_STATEFUL_STEP_COUNT = 6
10
+ INJECTED_PATH_PARAMETER_KEY = "x-schemathesis-injected"
11
+
12
+
13
+ class NotSet: ...
14
+
15
+
16
+ NOT_SET = NotSet()
17
+
18
+
19
+ class SpecificationFeature(str, enum.Enum):
20
+ """Features that Schemathesis can provide for different specifications."""
21
+
22
+ SCHEMA_ANALYSIS = "schema_analysis"
23
+ STATEFUL_TESTING = "stateful_testing"
24
+ COVERAGE = "coverage_tests"
25
+ EXAMPLES = "example_tests"
26
+
27
+
28
+ @dataclass
29
+ class Specification:
30
+ kind: SpecificationKind
31
+ version: str
32
+
33
+ __slots__ = ("kind", "version")
34
+
35
+ @classmethod
36
+ def openapi(cls, version: str) -> Specification:
37
+ return cls(kind=SpecificationKind.OPENAPI, version=version)
38
+
39
+ @classmethod
40
+ def graphql(cls, version: str) -> Specification:
41
+ return cls(kind=SpecificationKind.GRAPHQL, version=version)
42
+
43
+ @property
44
+ def name(self) -> str:
45
+ name = {SpecificationKind.GRAPHQL: "GraphQL", SpecificationKind.OPENAPI: "Open API"}[self.kind]
46
+ return f"{name} {self.version}".strip()
47
+
48
+ def supports_feature(self, feature: SpecificationFeature) -> bool:
49
+ """Check if Schemathesis supports a given feature for this specification."""
50
+ if self.kind == SpecificationKind.OPENAPI:
51
+ return feature in {
52
+ SpecificationFeature.SCHEMA_ANALYSIS,
53
+ SpecificationFeature.STATEFUL_TESTING,
54
+ SpecificationFeature.COVERAGE,
55
+ SpecificationFeature.EXAMPLES,
56
+ }
57
+ return False
58
+
59
+
60
+ class SpecificationKind(str, enum.Enum):
61
+ """Specification of the given schema."""
62
+
63
+ OPENAPI = "openapi"
64
+ GRAPHQL = "graphql"
65
+
66
+
67
+ def string_to_boolean(value: str) -> str | bool:
68
+ if value.lower() in ("y", "yes", "t", "true", "on", "1"):
69
+ return True
70
+ if value.lower() in ("n", "no", "f", "false", "off", "0"):
71
+ return False
72
+ return value
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Protocol, TypeVar
4
+
5
+ from schemathesis.core.parameters import ParameterLocation
6
+
7
+ T = TypeVar("T", covariant=True)
8
+
9
+
10
+ class ResponsesContainer(Protocol[T]):
11
+ def find_by_status_code(self, status_code: int) -> T | None: ... # pragma: no cover
12
+ def add(self, status_code: str, definition: dict[str, Any]) -> T: ... # pragma: no cover
13
+
14
+
15
+ class OperationParameter(Protocol):
16
+ """API parameter at a specific location (query, header, body, etc.)."""
17
+
18
+ definition: Any
19
+ """Raw parameter definition from the API spec."""
20
+
21
+ @property
22
+ def location(self) -> ParameterLocation:
23
+ """Location: "query", "header", "body", etc."""
24
+ ... # pragma: no cover
25
+
26
+ @property
27
+ def name(self) -> str:
28
+ """Parameter name."""
29
+ ... # pragma: no cover
30
+
31
+ @property
32
+ def is_required(self) -> bool:
33
+ """True if required."""
34
+ ... # pragma: no cover
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import warnings
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from jsonschema import RefResolutionError, RefResolver
8
+
9
+ try:
10
+ BaseExceptionGroup = BaseExceptionGroup
11
+ except NameError:
12
+ from exceptiongroup import BaseExceptionGroup
13
+
14
+
15
+ def __getattr__(name: str) -> type[RefResolutionError] | type[RefResolver] | type[BaseExceptionGroup]:
16
+ with warnings.catch_warnings():
17
+ warnings.simplefilter("ignore", DeprecationWarning)
18
+ if name == "RefResolutionError":
19
+ # `jsonschema` is pinned, this warning is not useful for the end user
20
+ from jsonschema import RefResolutionError
21
+
22
+ return RefResolutionError
23
+ if name == "RefResolver":
24
+ from jsonschema import RefResolver
25
+
26
+ return RefResolver
27
+ if name == "BaseExceptionGroup":
28
+ return BaseExceptionGroup
29
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
30
+
31
+
32
+ __all__ = ["BaseExceptionGroup", "RefResolutionError"]
@@ -0,0 +1,2 @@
1
+ class SkipTest(BaseException):
2
+ """Raised when the current test should be skipped and the executor then continues normally."""
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from functools import lru_cache
5
+ from shlex import quote
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from schemathesis.core import SCHEMATHESIS_TEST_CASE_HEADER
9
+ from schemathesis.core.shell import escape_for_shell, has_non_printable
10
+
11
+ if TYPE_CHECKING:
12
+ from requests.models import CaseInsensitiveDict
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class CurlCommand:
17
+ """Result of generating a curl command."""
18
+
19
+ command: str
20
+ """The curl command string."""
21
+
22
+ warnings: list[str]
23
+ """Warnings about non-printable characters or shell compatibility."""
24
+
25
+ __slots__ = ("command", "warnings")
26
+
27
+
28
+ def _escape_and_quote(value: str, warnings: list[str], ctx: str) -> str:
29
+ """Escape value for shell, adding warnings if needed."""
30
+ if has_non_printable(value):
31
+ escape_result = escape_for_shell(value)
32
+ if escape_result.needs_warning:
33
+ warnings.append(f"{ctx} contains non-printable characters. Actual value: {escape_result.original_bytes!r}")
34
+ return escape_result.escaped_value
35
+ return quote(value)
36
+
37
+
38
+ def generate(
39
+ *,
40
+ method: str,
41
+ url: str,
42
+ body: str | bytes | None,
43
+ verify: bool,
44
+ headers: dict[str, Any],
45
+ known_generated_headers: dict[str, Any] | None,
46
+ ) -> CurlCommand:
47
+ """Generate a code snippet for making HTTP requests."""
48
+ _filter_headers(headers, known_generated_headers or {})
49
+ warnings: list[str] = []
50
+ command = f"curl -X {method}"
51
+
52
+ # Process headers with shell-aware escaping
53
+ for key, value in headers.items():
54
+ # To send an empty header with cURL we need to use `;`, otherwise empty header is ignored
55
+ if not value:
56
+ header = f"{key};"
57
+ else:
58
+ header = f"{key}: {value}"
59
+
60
+ escaped_header = _escape_and_quote(header, warnings, f"Header '{key}'")
61
+ command += f" -H {escaped_header}"
62
+
63
+ # Process body with shell-aware escaping
64
+ if body:
65
+ if isinstance(body, bytes):
66
+ body = body.decode("utf-8", errors="replace")
67
+
68
+ escaped_body = _escape_and_quote(body, warnings, "Request body")
69
+ command += f" -d {escaped_body}"
70
+
71
+ if not verify:
72
+ command += " --insecure"
73
+
74
+ command += f" {quote(url)}"
75
+
76
+ return CurlCommand(command=command, warnings=warnings)
77
+
78
+
79
+ def _filter_headers(headers: dict[str, Any], known_generated_headers: dict[str, Any]) -> None:
80
+ for key in list(headers):
81
+ if key not in known_generated_headers and key in get_excluded_headers():
82
+ del headers[key]
83
+
84
+
85
+ @lru_cache
86
+ def get_excluded_headers() -> CaseInsensitiveDict:
87
+ from requests.structures import CaseInsensitiveDict
88
+ from requests.utils import default_headers
89
+
90
+ # These headers are added automatically by Schemathesis or `requests`.
91
+ # Do not show them in code samples to make them more readable
92
+
93
+ return CaseInsensitiveDict(
94
+ {
95
+ "Content-Length": None,
96
+ "Transfer-Encoding": None,
97
+ SCHEMATHESIS_TEST_CASE_HEADER: None,
98
+ **default_headers(),
99
+ }
100
+ )
@@ -0,0 +1,210 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from functools import lru_cache
6
+ from typing import TYPE_CHECKING, Any, BinaryIO, Callable, Dict, Iterator, TextIO
7
+
8
+ from schemathesis.core import media_types
9
+ from schemathesis.core.transport import Response
10
+
11
+ if TYPE_CHECKING:
12
+ import yaml
13
+
14
+ from schemathesis.generation.case import Case
15
+ from schemathesis.schemas import APIOperation
16
+
17
+
18
+ @dataclass
19
+ class DeserializationContext:
20
+ """Context passed to deserializers.
21
+
22
+ Attributes:
23
+ operation: The API operation being tested.
24
+ case: The generated test case (`None` when validating responses directly).
25
+
26
+ """
27
+
28
+ operation: APIOperation
29
+ case: Case | None
30
+
31
+ __slots__ = ("operation", "case")
32
+
33
+
34
+ ResponseDeserializer = Callable[[DeserializationContext, Response], Any]
35
+
36
+ _DESERIALIZERS: Dict[str, ResponseDeserializer] = {}
37
+
38
+
39
+ def _iter_matching_deserializers(media_type: str) -> Iterator[tuple[str, ResponseDeserializer]]:
40
+ main, sub = media_types.parse(media_type)
41
+ checks = [
42
+ media_types.is_json,
43
+ media_types.is_xml,
44
+ media_types.is_plain_text,
45
+ media_types.is_yaml,
46
+ ]
47
+ for registered_media_type, deserializer in _DESERIALIZERS.items():
48
+ if any(check(media_type) and check(registered_media_type) for check in checks):
49
+ yield registered_media_type, deserializer
50
+ else:
51
+ target_main, target_sub = media_types.parse(registered_media_type)
52
+ if main in ("*", target_main) and sub in ("*", target_sub):
53
+ yield registered_media_type, deserializer
54
+
55
+
56
+ def has_deserializer(media_type: str) -> bool:
57
+ """Check if a deserializer is registered or built-in for the given media type.
58
+
59
+ Args:
60
+ media_type: The media type to check (e.g., "application/msgpack")
61
+
62
+ Returns:
63
+ True if a deserializer is available (either registered or built-in like JSON/YAML/XML)
64
+
65
+ """
66
+ return (
67
+ media_types.is_json(media_type)
68
+ or media_types.is_yaml(media_type)
69
+ or media_types.is_xml(media_type)
70
+ or media_types.is_plain_text(media_type)
71
+ or any(_iter_matching_deserializers(media_type))
72
+ )
73
+
74
+
75
+ def register_deserializer(func: ResponseDeserializer, *media_types: str) -> ResponseDeserializer:
76
+ for media_type in media_types:
77
+ _DESERIALIZERS[media_type] = func
78
+ return func
79
+
80
+
81
+ def unregister_deserializer(*media_types: str) -> None:
82
+ for media_type in media_types:
83
+ _DESERIALIZERS.pop(media_type, None)
84
+
85
+
86
+ def deserializer(*media_types: str) -> Callable[[ResponseDeserializer], ResponseDeserializer]:
87
+ """Register a deserializer for custom response media types.
88
+
89
+ Converts API responses with custom content types (MessagePack, domain-specific formats, etc.)
90
+ into Python objects for schema validation. Built-in formats (JSON, YAML) work automatically.
91
+
92
+ Args:
93
+ *media_types: One or more MIME types (e.g., "application/msgpack", "application/vnd.custom+json")
94
+ this deserializer handles. Wildcards are supported (e.g., "application/*").
95
+
96
+ Returns:
97
+ A decorator that wraps a function taking `(ctx: DeserializationContext, response: Response)`
98
+ and returning the deserialized Python object for schema validation.
99
+
100
+ Example:
101
+ >>> import schemathesis
102
+ >>> import msgpack
103
+ >>>
104
+ >>> @schemathesis.deserializer("application/msgpack", "application/x-msgpack")
105
+ ... def deserialize_msgpack(ctx, response):
106
+ ... try:
107
+ ... return msgpack.unpackb(response.content, raw=False)
108
+ ... except Exception as exc:
109
+ ... raise ValueError(f"Invalid MessagePack: {exc}")
110
+
111
+ Notes:
112
+ - Raise appropriate exceptions if deserialization fails; Schemathesis will report them
113
+ - `ctx.operation` provides access to the API operation being tested (always available)
114
+ - `ctx.case` provides the generated test case (None when validating responses directly)
115
+ - Responses with unsupported media types are silently skipped during validation
116
+ - Handle unexpected data defensively, especially during negative testing
117
+
118
+ """
119
+
120
+ def decorator(func: ResponseDeserializer) -> ResponseDeserializer:
121
+ return register_deserializer(func, *media_types)
122
+
123
+ return decorator
124
+
125
+
126
+ @lru_cache
127
+ def get_yaml_loader() -> type[yaml.SafeLoader]:
128
+ """Create a YAML loader, that doesn't parse specific tokens into Python objects."""
129
+ import yaml
130
+
131
+ try:
132
+ from yaml import CSafeLoader as SafeLoader
133
+ except ImportError:
134
+ from yaml import SafeLoader # type: ignore[assignment]
135
+
136
+ cls: type[yaml.SafeLoader] = type("YAMLLoader", (SafeLoader,), {})
137
+ cls.yaml_implicit_resolvers = {
138
+ key: [(tag, regexp) for tag, regexp in mapping if tag != "tag:yaml.org,2002:timestamp"]
139
+ for key, mapping in cls.yaml_implicit_resolvers.copy().items()
140
+ }
141
+
142
+ # Fix pyyaml scientific notation parse bug
143
+ # See PR: https://github.com/yaml/pyyaml/pull/174 for upstream fix
144
+ cls.add_implicit_resolver( # type: ignore[no-untyped-call]
145
+ "tag:yaml.org,2002:float",
146
+ re.compile(
147
+ r"""^(?:[-+]?(?:[0-9][0-9_]*)\.[0-9_]*(?:[eE][-+]?[0-9]+)?
148
+ |[-+]?(?:[0-9][0-9_]*)(?:[eE][-+]?[0-9]+)
149
+ |\.[0-9_]+(?:[eE][-+]?[0-9]+)?
150
+ |[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\.[0-9_]*
151
+ |[-+]?\.(?:inf|Inf|INF)
152
+ |\.(?:nan|NaN|NAN))$""",
153
+ re.VERBOSE,
154
+ ),
155
+ list("-+0123456789."),
156
+ )
157
+
158
+ def construct_mapping(self: SafeLoader, node: yaml.Node, deep: bool = False) -> dict[str, Any]:
159
+ if isinstance(node, yaml.MappingNode):
160
+ self.flatten_mapping(node)
161
+ mapping = {}
162
+ for key_node, value_node in node.value:
163
+ # If the key has a tag different from `str` - use its string value.
164
+ # With this change all integer keys or YAML 1.1 boolean-ish values like "on" / "off" will not be cast to
165
+ # a different type
166
+ if key_node.tag != "tag:yaml.org,2002:str":
167
+ key = key_node.value
168
+ else:
169
+ key = self.construct_object(key_node, deep) # type: ignore[no-untyped-call]
170
+ mapping[key] = self.construct_object(value_node, deep) # type: ignore[no-untyped-call]
171
+ return mapping
172
+
173
+ cls.construct_mapping = construct_mapping # type: ignore[method-assign,assignment]
174
+ return cls
175
+
176
+
177
+ def deserialize_yaml(stream: str | bytes | TextIO | BinaryIO) -> Any:
178
+ import yaml
179
+
180
+ return yaml.load(stream, get_yaml_loader())
181
+
182
+
183
+ def deserializers() -> dict[str, ResponseDeserializer]:
184
+ """Return a snapshot of the registered deserializers."""
185
+ return dict(_DESERIALIZERS)
186
+
187
+
188
+ def deserialize_response(
189
+ response: Response,
190
+ content_type: str,
191
+ *,
192
+ context: DeserializationContext,
193
+ ) -> Any:
194
+ for _, deserializer in _iter_matching_deserializers(content_type):
195
+ return deserializer(context, response)
196
+ raise NotImplementedError(
197
+ f"Unsupported Content-Type: {content_type!r}. "
198
+ f"Registered deserializers: {', '.join(sorted(_DESERIALIZERS)) or 'none'}."
199
+ )
200
+
201
+
202
+ @deserializer("application/json")
203
+ def _deserialize_json(_ctx: DeserializationContext, response: Response) -> Any:
204
+ return response.json()
205
+
206
+
207
+ @deserializer(*media_types.YAML_MEDIA_TYPES)
208
+ def _deserialize_yaml(_ctx: DeserializationContext, response: Response) -> Any:
209
+ encoding = response.encoding or "utf-8"
210
+ return deserialize_yaml(response.content.decode(encoding))