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
@@ -1,152 +1,251 @@
1
- # pylint: disable=too-many-ancestors
2
- import itertools
3
- from collections import defaultdict
4
- from contextlib import ExitStack, contextmanager
5
- from copy import deepcopy
1
+ from __future__ import annotations
2
+
3
+ import string
4
+ from contextlib import contextmanager, suppress
5
+ from dataclasses import dataclass
6
6
  from difflib import get_close_matches
7
- from hashlib import sha1
7
+ from functools import cached_property, lru_cache
8
8
  from json import JSONDecodeError
9
- from threading import RLock
10
- from typing import (
11
- Any,
12
- Callable,
13
- ClassVar,
14
- Dict,
15
- Generator,
16
- Iterable,
17
- List,
18
- Optional,
19
- Sequence,
20
- Tuple,
21
- Type,
22
- TypeVar,
23
- Union,
24
- )
9
+ from types import SimpleNamespace
10
+ from typing import TYPE_CHECKING, Any, Callable, Generator, Iterator, Mapping, NoReturn, Sequence, cast
25
11
  from urllib.parse import urlsplit
26
12
 
27
- import attr
28
13
  import jsonschema
29
- import requests
30
- from hypothesis.strategies import SearchStrategy
14
+ from packaging import version
31
15
  from requests.structures import CaseInsensitiveDict
32
16
 
33
- from ... import failures
34
- from ...constants import HTTP_METHODS, DataGenerationMethod
35
- from ...exceptions import (
17
+ from schemathesis.core import INJECTED_PATH_PARAMETER_KEY, NOT_SET, NotSet, Specification, deserialization, media_types
18
+ from schemathesis.core.adapter import OperationParameter, ResponsesContainer
19
+ from schemathesis.core.compat import RefResolutionError
20
+ from schemathesis.core.errors import (
21
+ SCHEMA_ERROR_SUGGESTION,
22
+ InfiniteRecursiveReference,
36
23
  InvalidSchema,
37
- UsageError,
38
- get_missing_content_type_error,
39
- get_response_parsing_error,
40
- get_schema_validation_error,
24
+ OperationNotFound,
25
+ SchemaLocation,
41
26
  )
42
- from ...hooks import HookContext, HookDispatcher
43
- from ...models import APIOperation, Case, OperationDefinition
44
- from ...schemas import BaseSchema
45
- from ...stateful import APIStateMachine, Stateful, StatefulTest
46
- from ...types import Body, Cookies, FormData, Headers, NotSet, PathParameters, Query
47
- from ...utils import (
48
- NOT_SET,
49
- Err,
50
- GenericResponse,
51
- Ok,
52
- Result,
53
- get_response_payload,
54
- is_json_media_type,
55
- traverse_schema,
27
+ from schemathesis.core.failures import Failure, FailureGroup, MalformedJson
28
+ from schemathesis.core.parameters import ParameterLocation
29
+ from schemathesis.core.result import Err, Ok, Result
30
+ from schemathesis.core.transport import Response
31
+ from schemathesis.generation.case import Case
32
+ from schemathesis.generation.meta import CaseMetadata
33
+ from schemathesis.openapi.checks import JsonSchemaError, MissingContentType
34
+ from schemathesis.specs.openapi import adapter
35
+ from schemathesis.specs.openapi.adapter import OpenApiResponses
36
+ from schemathesis.specs.openapi.adapter.parameters import (
37
+ COMBINED_FORM_DATA_MARKER,
38
+ OpenApiParameter,
39
+ OpenApiParameterSet,
56
40
  )
57
- from . import links, serialization
58
- from ._hypothesis import get_case_strategy
59
- from .converter import to_json_schema, to_json_schema_recursive
41
+ from schemathesis.specs.openapi.adapter.protocol import SpecificationAdapter
42
+ from schemathesis.specs.openapi.adapter.security import OpenApiSecurityParameters
43
+ from schemathesis.specs.openapi.analysis import OpenAPIAnalysis
44
+
45
+ from ...generation import GenerationMode
46
+ from ...hooks import HookContext, HookDispatcher
47
+ from ...schemas import APIOperation, APIOperationMap, ApiStatistic, BaseSchema, OperationDefinition
48
+ from . import serialization
49
+ from ._hypothesis import openapi_cases
50
+ from .definitions import OPENAPI_30_VALIDATOR, OPENAPI_31_VALIDATOR, SWAGGER_20_VALIDATOR
60
51
  from .examples import get_strategies_from_examples
61
- from .filters import (
62
- should_skip_by_operation_id,
63
- should_skip_by_tag,
64
- should_skip_deprecated,
65
- should_skip_endpoint,
66
- should_skip_method,
67
- )
68
- from .parameters import (
69
- OpenAPI20Body,
70
- OpenAPI20CompositeBody,
71
- OpenAPI20Parameter,
72
- OpenAPI30Body,
73
- OpenAPI30Parameter,
74
- OpenAPIParameter,
75
- )
76
- from .references import RECURSION_DEPTH_LIMIT, ConvertingResolver, InliningResolver
77
- from .security import BaseSecurityProcessor, OpenAPISecurityProcessor, SwaggerSecurityProcessor
52
+ from .references import ReferenceResolver
78
53
  from .stateful import create_state_machine
79
54
 
80
- SCHEMA_ERROR_MESSAGE = "Schema parsing failed. Please check your schema."
81
- SCHEMA_PARSING_ERRORS = (KeyError, AttributeError, jsonschema.exceptions.RefResolutionError)
55
+ if TYPE_CHECKING:
56
+ from hypothesis.strategies import SearchStrategy
57
+
58
+ from schemathesis.auths import AuthStorage
59
+ from schemathesis.generation.stateful import APIStateMachine
60
+
61
+ HTTP_METHODS = frozenset({"get", "put", "post", "delete", "options", "head", "patch", "trace"})
62
+ SCHEMA_PARSING_ERRORS = (KeyError, AttributeError, RefResolutionError, InvalidSchema, InfiniteRecursiveReference)
63
+
64
+
65
+ @lru_cache()
66
+ def get_template_fields(template: str) -> set[str]:
67
+ """Extract named placeholders from a string template."""
68
+ try:
69
+ parameters = {name for _, name, _, _ in string.Formatter().parse(template) if name is not None}
70
+ # Check for malformed params to avoid injecting them - they will be checked later on in the workflow
71
+ template.format(**dict.fromkeys(parameters, ""))
72
+ return parameters
73
+ except (ValueError, IndexError):
74
+ return set()
82
75
 
83
76
 
84
- @attr.s(eq=False, repr=False)
77
+ @dataclass(eq=False, repr=False)
85
78
  class BaseOpenAPISchema(BaseSchema):
86
- nullable_name: str
87
- links_field: str
88
- security: BaseSecurityProcessor
89
- component_locations: ClassVar[Tuple[Tuple[str, ...], ...]] = ()
90
- _operations_by_id: Dict[str, APIOperation]
91
- _inline_reference_cache: Dict[str, Any]
92
- # Inline references cache can be populated from multiple threads, therefore we need some synchronisation to avoid
93
- # excessive resolving
94
- _inline_reference_cache_lock: RLock
95
-
96
- def __attrs_post_init__(self) -> None:
97
- self._inline_reference_cache = {}
98
- self._inline_reference_cache_lock = RLock()
99
-
100
- @property # pragma: no mutate
101
- def spec_version(self) -> str:
102
- raise NotImplementedError
79
+ adapter: SpecificationAdapter = None # type: ignore[assignment]
103
80
 
104
- def get_stateful_tests(
105
- self, response: GenericResponse, operation: APIOperation, stateful: Optional[Stateful]
106
- ) -> Sequence[StatefulTest]:
107
- if stateful == Stateful.links:
108
- return links.get_links(response, operation, field=self.links_field)
109
- return []
81
+ def __post_init__(self) -> None:
82
+ super().__post_init__()
83
+ self.analysis = OpenAPIAnalysis(self)
84
+
85
+ @property
86
+ def specification(self) -> Specification:
87
+ raise NotImplementedError
110
88
 
111
89
  def __repr__(self) -> str:
112
90
  info = self.raw_schema["info"]
113
- return f"{self.__class__.__name__} for {info['title']} ({info['version']})"
114
-
115
- def _should_skip(self, method: str, definition: Dict[str, Any]) -> bool:
116
- return (
117
- method not in HTTP_METHODS
118
- or should_skip_method(method, self.method)
119
- or should_skip_deprecated(definition.get("deprecated", False), self.skip_deprecated_operations)
120
- or should_skip_by_tag(definition.get("tags"), self.tag)
121
- or should_skip_by_operation_id(definition.get("operationId"), self.operation_id)
122
- )
91
+ return f"<{self.__class__.__name__} for {info['title']} {info['version']}>"
92
+
93
+ def __iter__(self) -> Iterator[str]:
94
+ paths = self._get_paths()
95
+ if paths is None:
96
+ return iter(())
97
+ return iter(paths)
98
+
99
+ @cached_property
100
+ def default_media_types(self) -> list[str]:
101
+ raise NotImplementedError
102
+
103
+ def _get_paths(self) -> Mapping[str, Any] | None:
104
+ paths = self.raw_schema.get("paths")
105
+ if paths is None:
106
+ return None
107
+ assert isinstance(paths, Mapping)
108
+ return cast(Mapping[str, Any], paths)
109
+
110
+ def _get_operation_map(self, path: str) -> APIOperationMap:
111
+ paths = self._get_paths()
112
+ if paths is None:
113
+ raise KeyError(path)
114
+ path_item = paths[path]
115
+ with in_scope(self.resolver, self.location or ""):
116
+ scope, path_item = self._resolve_path_item(path_item)
117
+ self.dispatch_hook("before_process_path", HookContext(), path, path_item)
118
+ map = APIOperationMap(self, {})
119
+ map._data = MethodMap(map, scope, path, CaseInsensitiveDict(path_item))
120
+ return map
121
+
122
+ def find_operation_by_label(self, label: str) -> APIOperation | None:
123
+ method, path = label.split(" ", maxsplit=1)
124
+ return self[path][method]
125
+
126
+ def on_missing_operation(self, item: str, exc: KeyError) -> NoReturn:
127
+ matches = get_close_matches(item, list(self))
128
+ self._on_missing_operation(item, exc, matches)
129
+
130
+ def _on_missing_operation(self, item: str, exc: KeyError | None, matches: list[str]) -> NoReturn:
131
+ message = f"`{item}` not found"
132
+ if matches:
133
+ message += f". Did you mean `{matches[0]}`?"
134
+ raise OperationNotFound(message=message, item=item) from exc
135
+
136
+ def _should_skip(
137
+ self,
138
+ path: str,
139
+ method: str,
140
+ definition: dict[str, Any],
141
+ _ctx_cache: SimpleNamespace = SimpleNamespace(
142
+ operation=APIOperation(
143
+ method="",
144
+ path="",
145
+ label="",
146
+ definition=OperationDefinition(raw=None),
147
+ schema=None, # type: ignore[arg-type]
148
+ responses=None,
149
+ security=None,
150
+ )
151
+ ),
152
+ ) -> bool:
153
+ if method not in HTTP_METHODS:
154
+ return True
155
+ if self.filter_set.is_empty():
156
+ return False
157
+ # Attribute assignment is way faster than creating a new namespace every time
158
+ operation = _ctx_cache.operation
159
+ operation.method = method
160
+ operation.path = path
161
+ operation.label = f"{method.upper()} {path}"
162
+ operation.definition.raw = definition
163
+ operation.schema = self
164
+ return not self.filter_set.match(_ctx_cache)
165
+
166
+ def _measure_statistic(self) -> ApiStatistic:
167
+ statistic = ApiStatistic()
168
+ paths = self._get_paths()
169
+ if paths is None:
170
+ return statistic
123
171
 
124
- @property
125
- def operations_count(self) -> int:
126
- try:
127
- paths = self.raw_schema["paths"]
128
- except KeyError:
129
- return 0
130
- total = 0
131
172
  resolve = self.resolver.resolve
132
- for path, methods in paths.items():
133
- full_path = self.get_full_path(path)
134
- if should_skip_endpoint(full_path, self.endpoint):
173
+ resolve_path_item = self._resolve_path_item
174
+ should_skip = self._should_skip
175
+ links_keyword = self.adapter.links_keyword
176
+
177
+ # For operationId lookup
178
+ selected_operations_by_id: set[str] = set()
179
+ # Tuples of (method, path)
180
+ selected_operations_by_path: set[tuple[str, str]] = set()
181
+ collected_links: list[dict] = []
182
+
183
+ for path, path_item in paths.items():
184
+ try:
185
+ scope, path_item = resolve_path_item(path_item)
186
+ self.resolver.push_scope(scope)
187
+ try:
188
+ for method, definition in path_item.items():
189
+ if method not in HTTP_METHODS or not definition:
190
+ continue
191
+ statistic.operations.total += 1
192
+ is_selected = not should_skip(path, method, definition)
193
+ if is_selected:
194
+ statistic.operations.selected += 1
195
+ # Store both identifiers
196
+ if "operationId" in definition:
197
+ selected_operations_by_id.add(definition["operationId"])
198
+ selected_operations_by_path.add((method, path))
199
+ for response in definition.get("responses", {}).values():
200
+ if "$ref" in response:
201
+ _, response = resolve(response["$ref"])
202
+ defined_links = response.get(links_keyword)
203
+ if defined_links is not None:
204
+ statistic.links.total += len(defined_links)
205
+ if is_selected:
206
+ collected_links.extend(defined_links.values())
207
+ finally:
208
+ self.resolver.pop_scope()
209
+ except SCHEMA_PARSING_ERRORS:
135
210
  continue
211
+
212
+ def is_link_selected(link: dict) -> bool:
213
+ if "$ref" in link:
214
+ _, link = resolve(link["$ref"])
215
+
216
+ if "operationId" in link:
217
+ return link["operationId"] in selected_operations_by_id
218
+ else:
219
+ try:
220
+ scope, _ = resolve(link["operationRef"])
221
+ path, method = scope.rsplit("/", maxsplit=2)[-2:]
222
+ path = path.replace("~1", "/").replace("~0", "~")
223
+ return (method, path) in selected_operations_by_path
224
+ except Exception:
225
+ return False
226
+
227
+ for link in collected_links:
228
+ if is_link_selected(link):
229
+ statistic.links.selected += 1
230
+
231
+ return statistic
232
+
233
+ def _operation_iter(self) -> Iterator[tuple[str, str, dict[str, Any]]]:
234
+ paths = self._get_paths()
235
+ if paths is None:
236
+ return
237
+ resolve = self.resolver.resolve
238
+ should_skip = self._should_skip
239
+ for path, path_item in paths.items():
136
240
  try:
137
- if "$ref" in methods:
138
- _, resolved_methods = resolve(methods["$ref"])
139
- else:
140
- resolved_methods = methods
141
- # Straightforward iteration is faster than converting to a set & calculating length.
142
- for method, definition in resolved_methods.items():
143
- if self._should_skip(method, definition):
241
+ if "$ref" in path_item:
242
+ _, path_item = resolve(path_item["$ref"])
243
+ for method, definition in path_item.items():
244
+ if should_skip(path, method, definition):
144
245
  continue
145
- total += 1
246
+ yield (method, path, definition)
146
247
  except SCHEMA_PARSING_ERRORS:
147
- # Ignore errors
148
248
  continue
149
- return total
150
249
 
151
250
  def get_all_operations(self) -> Generator[Result[APIOperation, InvalidSchema], None, None]:
152
251
  """Iterate over all operations defined in the API.
@@ -164,411 +263,355 @@ class BaseOpenAPISchema(BaseSchema):
164
263
  In both cases, Schemathesis lets the callee decide what to do with these variants. It allows it to test valid
165
264
  operations and show errors for invalid ones.
166
265
  """
167
- try:
168
- paths = self.raw_schema["paths"] # pylint: disable=unsubscriptable-object
169
- except KeyError as exc:
170
- # Missing `paths` is not recoverable
171
- raise InvalidSchema(SCHEMA_ERROR_MESSAGE) from exc
266
+ __tracebackhide__ = True
267
+ paths = self._get_paths()
268
+ if paths is None:
269
+ if version.parse(self.specification.version) >= version.parse("3.1"):
270
+ return
271
+ self._raise_invalid_schema(KeyError("paths"))
172
272
 
173
273
  context = HookContext()
174
- for path, methods in paths.items():
274
+ # Optimization: local variables are faster than attribute access
275
+ dispatch_hook = self.dispatch_hook
276
+ resolve_path_item = self._resolve_path_item
277
+ should_skip = self._should_skip
278
+ iter_parameters = self._iter_parameters
279
+ make_operation = self.make_operation
280
+ for path, path_item in paths.items():
175
281
  method = None
176
282
  try:
177
- full_path = self.get_full_path(path) # Should be available for later use
178
- if should_skip_endpoint(full_path, self.endpoint):
179
- continue
180
- self.dispatch_hook("before_process_path", context, path, methods)
181
- scope, raw_methods = self._resolve_methods(methods)
182
- common_parameters = self.resolver.resolve_all(methods.get("parameters", []), RECURSION_DEPTH_LIMIT - 5)
183
- for method, definition in raw_methods.items():
184
- try:
185
- # Setting a low recursion limit doesn't solve the problem with recursive references & inlining
186
- # too much but decreases the number of cases when Schemathesis stuck on this step.
187
- self.resolver.push_scope(scope)
188
- try:
189
- resolved_definition = self.resolver.resolve_all(definition, RECURSION_DEPTH_LIMIT - 5)
190
- finally:
191
- self.resolver.pop_scope()
192
- # Only method definitions are parsed
193
- if self._should_skip(method, resolved_definition):
283
+ dispatch_hook("before_process_path", context, path, path_item)
284
+ scope, path_item = resolve_path_item(path_item)
285
+ with in_scope(self.resolver, scope):
286
+ shared_parameters = path_item.get("parameters", [])
287
+ for method, entry in path_item.items():
288
+ if method not in HTTP_METHODS:
194
289
  continue
195
- parameters = self.collect_parameters(
196
- itertools.chain(resolved_definition.get("parameters", ()), common_parameters),
197
- resolved_definition,
198
- )
199
- # To prevent recursion errors we need to pass not resolved schema as well
200
- # It could be used for response validation
201
- raw_definition = OperationDefinition(
202
- raw_methods[method], resolved_definition, scope, parameters
203
- )
204
- yield Ok(self.make_operation(path, method, parameters, raw_definition))
205
- except SCHEMA_PARSING_ERRORS as exc:
206
- yield self._into_err(exc, path, method)
290
+ try:
291
+ if should_skip(path, method, entry):
292
+ continue
293
+ parameters = iter_parameters(entry, shared_parameters)
294
+ operation = make_operation(
295
+ path,
296
+ method,
297
+ parameters,
298
+ entry,
299
+ scope,
300
+ )
301
+ yield Ok(operation)
302
+ except SCHEMA_PARSING_ERRORS as exc:
303
+ yield self._into_err(exc, path, method)
207
304
  except SCHEMA_PARSING_ERRORS as exc:
208
305
  yield self._into_err(exc, path, method)
209
306
 
210
- def _into_err(self, error: Exception, path: Optional[str], method: Optional[str]) -> Err[InvalidSchema]:
307
+ def _into_err(self, error: Exception, path: str | None, method: str | None) -> Err[InvalidSchema]:
308
+ __tracebackhide__ = True
211
309
  try:
212
- full_path = self.get_full_path(path) if isinstance(path, str) else None
213
- raise InvalidSchema(SCHEMA_ERROR_MESSAGE, path=path, method=method, full_path=full_path) from error
310
+ self._raise_invalid_schema(error, path, method)
214
311
  except InvalidSchema as exc:
215
312
  return Err(exc)
216
313
 
217
- def collect_parameters(
218
- self, parameters: Iterable[Dict[str, Any]], definition: Dict[str, Any]
219
- ) -> List[OpenAPIParameter]:
220
- """Collect Open API parameters.
221
-
222
- They should be used uniformly during the generation step; therefore, we need to convert them into
223
- a spec-independent list of parameters.
224
- """
314
+ def _raise_invalid_schema(
315
+ self,
316
+ error: Exception,
317
+ path: str | None = None,
318
+ method: str | None = None,
319
+ ) -> NoReturn:
320
+ __tracebackhide__ = True
321
+ if isinstance(error, InfiniteRecursiveReference):
322
+ raise InvalidSchema(str(error), path=path, method=method) from None
323
+ if isinstance(error, RefResolutionError):
324
+ raise InvalidSchema.from_reference_resolution_error(error, path=path, method=method) from None
325
+ try:
326
+ self.validate()
327
+ except jsonschema.ValidationError as exc:
328
+ raise InvalidSchema.from_jsonschema_error(
329
+ exc,
330
+ path=path,
331
+ method=method,
332
+ config=self.config.output,
333
+ location=SchemaLocation.maybe_from_error_path(list(exc.absolute_path), self.specification.version),
334
+ ) from None
335
+ raise InvalidSchema(SCHEMA_ERROR_SUGGESTION, path=path, method=method) from error
336
+
337
+ def validate(self) -> None:
338
+ with suppress(TypeError):
339
+ self._validate()
340
+
341
+ def _validate(self) -> None:
225
342
  raise NotImplementedError
226
343
 
227
- def _resolve_methods(self, methods: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
228
- # We need to know a proper scope in what methods are.
229
- # It will allow us to provide a proper reference resolving in `response_schema_conformance` and avoid
230
- # recursion errors
344
+ def _iter_parameters(
345
+ self, definition: dict[str, Any], shared_parameters: Sequence[dict[str, Any]]
346
+ ) -> list[OperationParameter]:
347
+ return list(
348
+ self.adapter.iter_parameters(
349
+ definition, shared_parameters, self.default_media_types, self.resolver, self.adapter
350
+ )
351
+ )
352
+
353
+ def _parse_responses(self, definition: dict[str, Any], scope: str) -> OpenApiResponses:
354
+ responses = definition.get("responses", {})
355
+ return OpenApiResponses.from_definition(
356
+ definition=responses, resolver=self.resolver, scope=scope, adapter=self.adapter
357
+ )
358
+
359
+ def _parse_security(self, definition: dict[str, Any]) -> OpenApiSecurityParameters:
360
+ return OpenApiSecurityParameters.from_definition(
361
+ schema=self.raw_schema,
362
+ operation=definition,
363
+ resolver=self.resolver,
364
+ adapter=self.adapter,
365
+ )
366
+
367
+ def _resolve_path_item(self, methods: dict[str, Any]) -> tuple[str, dict[str, Any]]:
368
+ # The path item could be behind a reference
369
+ # In this case, we need to resolve it to get the proper scope for reference inside the item.
370
+ # It is mostly for validating responses.
231
371
  if "$ref" in methods:
232
- return deepcopy(self.resolver.resolve(methods["$ref"]))
233
- return self.resolver.resolution_scope, deepcopy(methods)
372
+ return self.resolver.resolve(methods["$ref"])
373
+ return self.resolver.resolution_scope, methods
234
374
 
235
375
  def make_operation(
236
376
  self,
237
377
  path: str,
238
378
  method: str,
239
- parameters: List[OpenAPIParameter],
240
- raw_definition: OperationDefinition,
379
+ parameters: list[OperationParameter],
380
+ definition: dict[str, Any],
381
+ scope: str,
241
382
  ) -> APIOperation:
242
- """Create JSON schemas for the query, body, etc from Swagger parameters definitions."""
383
+ __tracebackhide__ = True
243
384
  base_url = self.get_base_url()
244
- operation: APIOperation[OpenAPIParameter, Case] = APIOperation(
385
+ responses = self._parse_responses(definition, scope)
386
+ security = self._parse_security(definition)
387
+ operation: APIOperation[OperationParameter, ResponsesContainer, OpenApiSecurityParameters] = APIOperation(
245
388
  path=path,
246
389
  method=method,
247
- definition=raw_definition,
390
+ definition=OperationDefinition(definition),
248
391
  base_url=base_url,
249
392
  app=self.app,
250
393
  schema=self,
394
+ responses=responses,
395
+ security=security,
396
+ path_parameters=OpenApiParameterSet(ParameterLocation.PATH),
397
+ query=OpenApiParameterSet(ParameterLocation.QUERY),
398
+ headers=OpenApiParameterSet(ParameterLocation.HEADER),
399
+ cookies=OpenApiParameterSet(ParameterLocation.COOKIE),
251
400
  )
252
401
  for parameter in parameters:
253
402
  operation.add_parameter(parameter)
254
- self.security.process_definitions(self.raw_schema, operation, self.resolver)
403
+ # Inject unconstrained path parameters if any is missing
404
+ missing_parameter_names = get_template_fields(operation.path) - {
405
+ parameter.name for parameter in operation.path_parameters
406
+ }
407
+ for name in missing_parameter_names:
408
+ operation.add_parameter(
409
+ self.adapter.build_path_parameter({"name": name, INJECTED_PATH_PARAMETER_KEY: True})
410
+ )
411
+ config = self.config.generation_for(operation=operation)
412
+ if config.with_security_parameters:
413
+ for param in operation.security.iter_parameters():
414
+ param_name = param.get("name")
415
+ param_location = param.get("in")
416
+ if (
417
+ param_name is not None
418
+ and param_location is not None
419
+ and operation.get_parameter(name=param_name, location=param_location) is not None
420
+ ):
421
+ continue
422
+ operation.add_parameter(
423
+ OpenApiParameter.from_definition(definition=param, name_to_uri={}, adapter=self.adapter)
424
+ )
255
425
  self.dispatch_hook("before_init_operation", HookContext(operation=operation), operation)
256
426
  return operation
257
427
 
258
428
  @property
259
- def resolver(self) -> InliningResolver:
429
+ def resolver(self) -> ReferenceResolver:
260
430
  if not hasattr(self, "_resolver"):
261
- # pylint: disable=attribute-defined-outside-init
262
- self._resolver = InliningResolver(self.location or "", self.raw_schema)
431
+ self._resolver = ReferenceResolver(self.location or "", self.raw_schema)
263
432
  return self._resolver
264
433
 
265
- def get_content_types(self, operation: APIOperation, response: GenericResponse) -> List[str]:
434
+ def get_content_types(self, operation: APIOperation, response: Response) -> list[str]:
266
435
  """Content types available for this API operation."""
267
436
  raise NotImplementedError
268
437
 
269
- def get_strategies_from_examples(self, operation: APIOperation) -> List[SearchStrategy[Case]]:
438
+ def get_strategies_from_examples(self, operation: APIOperation, **kwargs: Any) -> list[SearchStrategy[Case]]:
270
439
  """Get examples from the API operation."""
271
440
  raise NotImplementedError
272
441
 
273
- def get_response_schema(self, definition: Dict[str, Any], scope: str) -> Tuple[List[str], Optional[Dict[str, Any]]]:
274
- """Extract response schema from `responses`."""
275
- raise NotImplementedError
276
-
277
- def get_operation_by_id(self, operation_id: str) -> APIOperation:
278
- """Get an `APIOperation` instance by its `operationId`."""
279
- if not hasattr(self, "_operations_by_id"):
280
- self._operations_by_id = dict(self._group_operations_by_id())
281
- return self._operations_by_id[operation_id]
282
-
283
- def _group_operations_by_id(self) -> Generator[Tuple[str, APIOperation], None, None]:
284
- for path, methods in self.raw_schema["paths"].items():
285
- scope, raw_methods = self._resolve_methods(methods)
286
- common_parameters = self.resolver.resolve_all(methods.get("parameters", []), RECURSION_DEPTH_LIMIT - 5)
287
- for method, definition in methods.items():
288
- if method not in HTTP_METHODS or "operationId" not in definition:
442
+ def find_operation_by_id(self, operation_id: str) -> APIOperation:
443
+ """Find an `APIOperation` instance by its `operationId`."""
444
+ resolve = self.resolver.resolve
445
+ default_scope = self.resolver.resolution_scope
446
+ paths = self._get_paths()
447
+ if paths is None:
448
+ self._on_missing_operation(operation_id, None, [])
449
+ assert paths is not None
450
+ for path, path_item in paths.items():
451
+ # If the path is behind a reference we have to keep the scope
452
+ # The scope is used to resolve nested components later on
453
+ if "$ref" in path_item:
454
+ scope, path_item = resolve(path_item["$ref"])
455
+ else:
456
+ scope = default_scope
457
+ for method, operation in path_item.items():
458
+ if method not in HTTP_METHODS:
289
459
  continue
290
- self.resolver.push_scope(scope)
291
- try:
292
- resolved_definition = self.resolver.resolve_all(definition, RECURSION_DEPTH_LIMIT - 5)
293
- finally:
294
- self.resolver.pop_scope()
295
- parameters = self.collect_parameters(
296
- itertools.chain(resolved_definition.get("parameters", ()), common_parameters), resolved_definition
297
- )
298
- raw_definition = OperationDefinition(raw_methods[method], resolved_definition, scope, parameters)
299
- yield resolved_definition["operationId"], self.make_operation(path, method, parameters, raw_definition)
460
+ if "operationId" in operation and operation["operationId"] == operation_id:
461
+ parameters = self._iter_parameters(operation, path_item.get("parameters", []))
462
+ return self.make_operation(path, method, parameters, operation, scope)
463
+ self._on_missing_operation(operation_id, None, [])
300
464
 
301
- def get_operation_by_reference(self, reference: str) -> APIOperation:
302
- """Get local or external `APIOperation` instance by reference.
465
+ def find_operation_by_reference(self, reference: str) -> APIOperation:
466
+ """Find local or external `APIOperation` instance by reference.
303
467
 
304
468
  Reference example: #/paths/~1users~1{user_id}/patch
305
469
  """
306
- scope, data = self.resolver.resolve(reference)
470
+ scope, operation = self.resolver.resolve(reference)
307
471
  path, method = scope.rsplit("/", maxsplit=2)[-2:]
308
472
  path = path.replace("~1", "/").replace("~0", "~")
309
- resolved_definition = self.resolver.resolve_all(data)
310
473
  parent_ref, _ = reference.rsplit("/", maxsplit=1)
311
- _, methods = self.resolver.resolve(parent_ref)
312
- common_parameters = self.resolver.resolve_all(methods.get("parameters", []), RECURSION_DEPTH_LIMIT - 5)
313
- parameters = self.collect_parameters(
314
- itertools.chain(resolved_definition.get("parameters", ()), common_parameters), resolved_definition
315
- )
316
- raw_definition = OperationDefinition(data, resolved_definition, scope, parameters)
317
- return self.make_operation(path, method, parameters, raw_definition)
474
+ _, path_item = self.resolver.resolve(parent_ref)
475
+ with in_scope(self.resolver, scope):
476
+ parameters = self._iter_parameters(operation, path_item.get("parameters", []))
477
+ return self.make_operation(path, method, parameters, operation, scope)
478
+
479
+ def find_operation_by_path(self, method: str, path: str) -> APIOperation | None:
480
+ """Find an `APIOperation` by matching an actual request path.
481
+
482
+ Matches path templates with parameters, e.g., /users/42 matches /users/{user_id}.
483
+ Returns None if no operation matches.
484
+ """
485
+ from werkzeug.exceptions import MethodNotAllowed, NotFound
486
+
487
+ from schemathesis.specs.openapi.stateful.inference import OperationById
488
+
489
+ # Match path and method using werkzeug router
490
+ try:
491
+ operation_ref, _ = self.analysis.inferencer._adapter.match(path, method=method.upper())
492
+ except (NotFound, MethodNotAllowed):
493
+ return None
494
+
495
+ if isinstance(operation_ref, OperationById):
496
+ return self.find_operation_by_id(operation_ref.value)
497
+ return self.find_operation_by_reference(operation_ref.value)
318
498
 
319
499
  def get_case_strategy(
320
500
  self,
321
501
  operation: APIOperation,
322
- hooks: Optional[HookDispatcher] = None,
323
- data_generation_method: DataGenerationMethod = DataGenerationMethod.default(),
502
+ hooks: HookDispatcher | None = None,
503
+ auth_storage: AuthStorage | None = None,
504
+ generation_mode: GenerationMode = GenerationMode.POSITIVE,
505
+ **kwargs: Any,
324
506
  ) -> SearchStrategy:
325
- return get_case_strategy(operation=operation, hooks=hooks, data_generation_method=data_generation_method)
326
-
327
- def get_parameter_serializer(self, operation: APIOperation, location: str) -> Optional[Callable]:
328
- definitions = [item for item in operation.definition.resolved.get("parameters", []) if item["in"] == location]
329
- security_parameters = self.security.get_security_definitions_as_parameters(
330
- self.raw_schema, operation, self.resolver, location
507
+ return openapi_cases(
508
+ operation=operation,
509
+ hooks=hooks,
510
+ auth_storage=auth_storage,
511
+ generation_mode=generation_mode,
512
+ **kwargs,
331
513
  )
332
- security_parameters = [item for item in security_parameters if item["in"] == location]
333
- if security_parameters:
334
- definitions.extend(security_parameters)
514
+
515
+ def get_parameter_serializer(self, operation: APIOperation, location: str) -> Callable | None:
516
+ definitions = [item.definition for item in operation.iter_parameters() if item.location == location]
517
+ config = self.config.generation_for(operation=operation)
518
+ if config.with_security_parameters:
519
+ security_parameters = [param for param in operation.security.iter_parameters() if param["in"] == location]
520
+ if security_parameters:
521
+ definitions.extend(security_parameters)
335
522
  if definitions:
336
523
  return self._get_parameter_serializer(definitions)
337
524
  return None
338
525
 
339
- def _get_parameter_serializer(self, definitions: List[Dict[str, Any]]) -> Optional[Callable]:
526
+ def _get_parameter_serializer(self, definitions: list[dict[str, Any]]) -> Callable | None:
340
527
  raise NotImplementedError
341
528
 
342
- def _get_response_definitions(self, operation: APIOperation, response: GenericResponse) -> Optional[Dict[str, Any]]:
343
- try:
344
- responses = operation.definition.resolved["responses"]
345
- except KeyError as exc:
346
- # Possible to get if `validate_schema=False` is passed during schema creation
347
- raise InvalidSchema("Schema parsing failed. Please check your schema.") from exc
348
- status_code = str(response.status_code)
349
- if status_code in responses:
350
- return responses[status_code]
351
- if "default" in responses:
352
- return responses["default"]
353
- return None
529
+ def as_state_machine(self) -> type[APIStateMachine]:
530
+ # Apply dependency inference if configured and not already done
531
+ if self.analysis.should_inject_links():
532
+ self.analysis.inject_links()
533
+ return create_state_machine(self)
534
+
535
+ def get_tags(self, operation: APIOperation) -> list[str] | None:
536
+ return operation.definition.raw.get("tags")
354
537
 
355
- def get_headers(self, operation: APIOperation, response: GenericResponse) -> Optional[Dict[str, Dict[str, Any]]]:
356
- definitions = self._get_response_definitions(operation, response)
357
- if not definitions:
538
+ def validate_response(
539
+ self,
540
+ operation: APIOperation,
541
+ response: Response,
542
+ *,
543
+ case: Case | None = None,
544
+ ) -> bool | None:
545
+ __tracebackhide__ = True
546
+ definition = operation.responses.find_by_status_code(response.status_code)
547
+ if definition is None or definition.schema is None:
548
+ # No definition for the given HTTP response, or missing "schema" in the matching definition
358
549
  return None
359
- return definitions.get("headers")
360
550
 
361
- def as_state_machine(self) -> Type[APIStateMachine]:
362
- return create_state_machine(self)
551
+ failures: list[Failure] = []
363
552
 
364
- def add_link(
365
- self,
366
- source: APIOperation,
367
- target: Union[str, APIOperation],
368
- status_code: Union[str, int],
369
- parameters: Optional[Dict[str, str]] = None,
370
- request_body: Any = None,
371
- ) -> None:
372
- """Add a new Open API link to the schema definition.
373
-
374
- :param APIOperation source: This operation is the source of data
375
- :param target: This operation will receive the data from this link.
376
- Can be an ``APIOperation`` instance or a reference like this - ``#/paths/~1users~1{userId}/get``
377
- :param str status_code: The link is triggered when the source API operation responds with this status code.
378
- :param parameters: A dictionary that describes how parameters should be extracted from the matched response.
379
- The key represents the parameter name in the target API operation, and the value is a runtime
380
- expression string.
381
- :param request_body: A literal value or runtime expression to use as a request body when
382
- calling the target operation.
383
-
384
- .. code-block:: python
385
-
386
- schema = schemathesis.from_uri("http://0.0.0.0/schema.yaml")
387
-
388
- schema.add_link(
389
- source=schema["/users/"]["POST"],
390
- target=schema["/users/{userId}"]["GET"],
391
- status_code="201",
392
- parameters={"userId": "$response.body#/id"},
393
- )
394
- """
395
- if parameters is None and request_body is None:
396
- raise ValueError("You need to provide `parameters` or `request_body`.")
397
- if hasattr(self, "_operations"):
398
- delattr(self, "_operations")
399
- for operation, methods in self.raw_schema["paths"].items():
400
- if operation == source.path:
401
- # Methods should be completely resolved now, otherwise they might miss a resolving scope when
402
- # they will be fully resolved later
403
- methods = self.resolver.resolve_all(methods)
404
- found = False
405
- for method, definition in methods.items():
406
- if method.upper() == source.method.upper():
407
- found = True
408
- links.add_link(
409
- definition["responses"], self.links_field, parameters, request_body, status_code, target
410
- )
411
- # If methods are behind a reference, then on the next resolving they will miss the new link
412
- # Therefore we need to modify it this way
413
- self.raw_schema["paths"][operation][method] = definition
414
- # The reference should be removed completely, otherwise new keys in this dictionary will be ignored
415
- # due to the `$ref` keyword behavior
416
- self.raw_schema["paths"][operation].pop("$ref", None)
417
- if found:
418
- return
419
- name = f"{source.method.upper()} {source.path}"
420
- # Use a name without basePath, as the user doesn't use it.
421
- # E.g. `source=schema["/users/"]["POST"]` without a prefix
422
- message = f"No such API operation: `{name}`."
423
- possibilities = [
424
- f"{op.ok().method.upper()} {op.ok().path}" for op in self.get_all_operations() if isinstance(op, Ok)
425
- ]
426
- matches = get_close_matches(name, possibilities)
427
- if matches:
428
- message += f" Did you mean `{matches[0]}`?"
429
- message += " Check if the requested API operation passes the filters in the schema."
430
- raise ValueError(message)
431
-
432
- def get_links(self, operation: APIOperation) -> Dict[str, Dict[str, Any]]:
433
- result: Dict[str, Dict[str, Any]] = defaultdict(dict)
434
- for status_code, link in links.get_all_links(operation):
435
- result[status_code][link.name] = link
436
- return result
437
-
438
- def validate_response(self, operation: APIOperation, response: GenericResponse) -> None:
439
- responses = {str(key): value for key, value in operation.definition.raw.get("responses", {}).items()}
440
- status_code = str(response.status_code)
441
- if status_code in responses:
442
- definition = responses[status_code]
443
- elif "default" in responses:
444
- definition = responses["default"]
553
+ content_types = response.headers.get("content-type")
554
+ if content_types is None:
555
+ all_media_types = self.get_content_types(operation, response)
556
+ formatted_content_types = [f"\n- `{content_type}`" for content_type in all_media_types]
557
+ message = f"The following media types are documented in the schema:{''.join(formatted_content_types)}"
558
+ failures.append(MissingContentType(operation=operation.label, message=message, media_types=all_media_types))
559
+ # Default content type
560
+ content_type = "application/json"
445
561
  else:
446
- # No response defined for the received response status code
447
- return
448
- scopes, schema = self.get_response_schema(definition, operation.definition.scope)
449
- if not schema:
450
- # No schema to check against
451
- return
452
- content_type = response.headers.get("Content-Type")
453
- if content_type is None:
454
- media_types = self.get_content_types(operation, response)
455
- formatted_media_types = "\n ".join(media_types)
456
- raise get_missing_content_type_error()(
457
- "The response is missing the `Content-Type` header. The schema defines the following media types:\n\n"
458
- f" {formatted_media_types}",
459
- context=failures.MissingContentType(media_types),
460
- )
461
- if not is_json_media_type(content_type):
462
- return
562
+ content_type = content_types[0]
563
+
564
+ context = deserialization.DeserializationContext(operation=operation, case=case)
565
+
463
566
  try:
464
- if isinstance(response, requests.Response):
465
- data = response.json()
466
- else:
467
- data = response.json
567
+ data = deserialization.deserialize_response(response, content_type, context=context)
468
568
  except JSONDecodeError as exc:
469
- exc_class = get_response_parsing_error(exc)
470
- payload = get_response_payload(response)
471
- raise exc_class(
472
- f"The received response is not valid JSON:\n\n {payload}\n\nException: \n\n {exc}",
473
- context=failures.JSONDecodeErrorContext(
474
- validation_message=exc.msg, document=exc.doc, position=exc.pos, lineno=exc.lineno, colno=exc.colno
475
- ),
569
+ failures.append(MalformedJson.from_exception(operation=operation.label, exc=exc))
570
+ _maybe_raise_one_or_more(failures)
571
+ return None
572
+ except NotImplementedError:
573
+ # No deserializer available for this media type - skip validation
574
+ # This is expected for many media types (images, binary formats, etc.)
575
+ return None
576
+ except Exception as exc:
577
+ failures.append(
578
+ Failure(
579
+ operation=operation.label,
580
+ title="Content deserialization error",
581
+ message=f"Failed to deserialize response content:\n\n {exc}",
582
+ )
583
+ )
584
+ _maybe_raise_one_or_more(failures)
585
+ return None
586
+
587
+ try:
588
+ definition.validator.validate(data)
589
+ except jsonschema.SchemaError as exc:
590
+ raise InvalidSchema.from_jsonschema_error(
591
+ exc,
592
+ path=operation.path,
593
+ method=operation.method,
594
+ config=self.config.output,
595
+ location=SchemaLocation.response_schema(self.specification.version),
476
596
  ) from exc
477
- resolver = ConvertingResolver(
478
- self.location or "", self.raw_schema, nullable_name=self.nullable_name, is_response_schema=True
479
- )
480
- with in_scopes(resolver, scopes):
481
- try:
482
- jsonschema.validate(data, schema, cls=jsonschema.Draft4Validator, resolver=resolver)
483
- except jsonschema.ValidationError as exc:
484
- exc_class = get_schema_validation_error(exc)
485
- raise exc_class(
486
- f"The received response does not conform to the defined schema!\n\nDetails: \n\n{exc}",
487
- context=failures.ValidationErrorContext(
488
- validation_message=exc.message,
489
- schema_path=list(exc.absolute_schema_path),
490
- schema=exc.schema,
491
- instance_path=list(exc.absolute_path),
492
- instance=exc.instance,
493
- ),
494
- ) from exc
597
+ except jsonschema.ValidationError as exc:
598
+ failures.append(
599
+ JsonSchemaError.from_exception(
600
+ operation=operation.label,
601
+ exc=exc,
602
+ config=operation.schema.config.output,
603
+ )
604
+ )
605
+ _maybe_raise_one_or_more(failures)
495
606
  return None # explicitly return None for mypy
496
607
 
497
- @property
498
- def rewritten_components(self) -> Dict[str, Any]:
499
- if not hasattr(self, "_rewritten_components"):
500
-
501
- def callback(_schema: Dict[str, Any], nullable_name: str) -> Dict[str, Any]:
502
- _schema = to_json_schema(_schema, nullable_name=nullable_name, copy=False)
503
- return self._rewrite_references(_schema, self.resolver)
504
-
505
- # pylint: disable=attribute-defined-outside-init
506
- # Different spec versions allow different keywords to store possible reference targets
507
- components: Dict[str, Any] = {}
508
- for path in self.component_locations:
509
- schema = self.raw_schema
510
- target = components
511
- for chunk in path:
512
- if chunk in schema:
513
- schema = schema[chunk]
514
- target = target.setdefault(chunk, {})
515
- else:
516
- break
517
- else:
518
- target.update(traverse_schema(deepcopy(schema), callback, self.nullable_name))
519
- self._rewritten_components = components
520
- return self._rewritten_components
521
-
522
- def prepare_schema(self, schema: Any) -> Any:
523
- """Inline Open API definitions.
524
608
 
525
- Inlining components helps `hypothesis-jsonschema` generate data that involves non-resolved references.
526
- """
527
- schema = deepcopy(schema)
528
- schema = traverse_schema(schema, self._rewrite_references, self.resolver)
529
- schema.update(self.rewritten_components)
530
- # If there are any cached references - add them to the resulting schema.
531
- # Note that not all of them might be used for data generation, but at this point it is the simplest way to go
532
- if self._inline_reference_cache:
533
- schema[INLINED_REFERENCES_KEY] = self._inline_reference_cache
534
- return schema
535
-
536
- def _rewrite_references(self, schema: Dict[str, Any], resolver: InliningResolver) -> Dict[str, Any]:
537
- """Rewrite references present in the schema.
538
-
539
- The idea is to resolve references, cache the result and replace these references with new ones
540
- that point to a local path which is populated from this cache later on.
541
- """
542
- reference = schema.get("$ref")
543
- # If `$ref` is not a property name and should be processed
544
- if reference is not None and isinstance(reference, str) and not reference.startswith("#/"):
545
- key = _make_reference_key(resolver._scopes_stack, reference)
546
- with self._inline_reference_cache_lock:
547
- if key not in self._inline_reference_cache:
548
- with resolver.resolving(reference) as resolved:
549
- # Resolved object also may have references
550
- self._inline_reference_cache[key] = traverse_schema(
551
- resolved, lambda s: self._rewrite_references(s, resolver)
552
- )
553
- # Rewrite the reference with the new location
554
- schema["$ref"] = f"#/{INLINED_REFERENCES_KEY}/{key}"
555
- return schema
556
-
557
-
558
- def _make_reference_key(scopes: List[str], reference: str) -> str:
559
- """A name under which the resolved reference data will be stored."""
560
- # Using a hexdigest is the simplest way to associate practically unique keys with each reference
561
- digest = sha1()
562
- for scope in scopes:
563
- digest.update(scope.encode("utf-8"))
564
- # Separator to avoid collissions like this: ["a"], "bc" vs. ["ab"], "c". Otherwise, the resulting digest
565
- # will be the same for both cases
566
- digest.update(b"#")
567
- digest.update(reference.encode("utf-8"))
568
- return digest.hexdigest()
569
-
570
-
571
- INLINED_REFERENCES_KEY = "x-inlined"
609
+ def _maybe_raise_one_or_more(failures: list[Failure]) -> None:
610
+ if not failures:
611
+ return
612
+ if len(failures) == 1:
613
+ raise failures[0] from None
614
+ raise FailureGroup(failures) from None
572
615
 
573
616
 
574
617
  @contextmanager
@@ -580,185 +623,154 @@ def in_scope(resolver: jsonschema.RefResolver, scope: str) -> Generator[None, No
580
623
  resolver.pop_scope()
581
624
 
582
625
 
583
- @contextmanager
584
- def in_scopes(resolver: jsonschema.RefResolver, scopes: List[str]) -> Generator[None, None, None]:
585
- """Push all available scopes into the resolver.
626
+ @dataclass
627
+ class MethodMap(Mapping):
628
+ """Container for accessing API operations.
586
629
 
587
- There could be an additional scope change during a schema resolving in `get_response_schema`, so in total there
588
- could be a stack of two scopes maximum. This context manager handles both cases (1 or 2 scope changes) in the same
589
- way.
630
+ Provides a more specific error message if API operation is not found.
590
631
  """
591
- with ExitStack() as stack:
592
- for scope in scopes:
593
- stack.enter_context(in_scope(resolver, scope))
594
- yield
595
632
 
633
+ _parent: APIOperationMap
634
+ # Reference resolution scope
635
+ _scope: str
636
+ # Methods are stored for this path
637
+ _path: str
638
+ # Storage for definitions
639
+ _path_item: CaseInsensitiveDict
640
+
641
+ __slots__ = ("_parent", "_scope", "_path", "_path_item")
642
+
643
+ def __len__(self) -> int:
644
+ return len(self._path_item)
645
+
646
+ def __iter__(self) -> Iterator[str]:
647
+ return iter(self._path_item)
648
+
649
+ def _init_operation(self, method: str) -> APIOperation:
650
+ method = method.lower()
651
+ operation = self._path_item[method]
652
+ schema = cast(BaseOpenAPISchema, self._parent._schema)
653
+ path = self._path
654
+ scope = self._scope
655
+ with in_scope(schema.resolver, scope):
656
+ try:
657
+ parameters = schema._iter_parameters(operation, self._path_item.get("parameters", []))
658
+ except SCHEMA_PARSING_ERRORS as exc:
659
+ schema._raise_invalid_schema(exc, path, method)
660
+ return schema.make_operation(path, method, parameters, operation, scope)
596
661
 
597
- OPENAPI_20_DEFAULT_BODY_MEDIA_TYPE = "application/json"
598
- OPENAPI_20_DEFAULT_FORM_MEDIA_TYPE = "multipart/form-data"
599
- C = TypeVar("C", bound=Case)
662
+ def __getitem__(self, item: str) -> APIOperation:
663
+ try:
664
+ return self._init_operation(item)
665
+ except LookupError as exc:
666
+ available_methods = ", ".join(key.upper() for key in self if key in HTTP_METHODS)
667
+ message = f"Method `{item.upper()}` not found."
668
+ if available_methods:
669
+ message += f" Available methods: {available_methods}"
670
+ raise LookupError(message) from exc
600
671
 
601
672
 
602
673
  class SwaggerV20(BaseOpenAPISchema):
603
- nullable_name = "x-nullable"
604
- example_field = "x-example"
605
- examples_field = "x-examples"
606
- security = SwaggerSecurityProcessor()
607
- component_locations: ClassVar[Tuple[Tuple[str, ...], ...]] = (("definitions",),)
608
- links_field = "x-links"
674
+ def __post_init__(self) -> None:
675
+ self.adapter = adapter.v2
676
+ super().__post_init__()
609
677
 
610
678
  @property
611
- def spec_version(self) -> str:
612
- return self.raw_schema["swagger"]
679
+ def specification(self) -> Specification:
680
+ version = self.raw_schema.get("swagger", "2.0")
681
+ return Specification.openapi(version=version)
613
682
 
614
- @property
615
- def verbose_name(self) -> str:
616
- return f"Swagger {self.spec_version}"
683
+ @cached_property
684
+ def default_media_types(self) -> list[str]:
685
+ return self.raw_schema.get("consumes", [])
686
+
687
+ def _validate(self) -> None:
688
+ SWAGGER_20_VALIDATOR.validate(self.raw_schema)
617
689
 
618
690
  def _get_base_path(self) -> str:
619
691
  return self.raw_schema.get("basePath", "/")
620
692
 
621
- def collect_parameters(
622
- self, parameters: Iterable[Dict[str, Any]], definition: Dict[str, Any]
623
- ) -> List[OpenAPIParameter]:
624
- # The main difference with Open API 3.0 is that it has `body` and `form` parameters that we need to handle
625
- # differently.
626
- collected: List[OpenAPIParameter] = []
627
- # NOTE. The Open API 2.0 spec doesn't strictly imply having media types in the "consumes" keyword.
628
- # It is not enforced by the meta schema and has no "MUST" verb in the spec text.
629
- # Also, not every API has operations with payload (they might have only GET operations without payloads).
630
- # For these reasons, it might be (and often is) absent, and we need to provide the proper media type in case
631
- # we have operations with a payload.
632
- media_types = self._get_consumes_for_operation(definition)
633
- # For `in=body` parameters, we imply `application/json` as the default media type because it is the most common.
634
- body_media_types = media_types or (OPENAPI_20_DEFAULT_BODY_MEDIA_TYPE,)
635
- # If an API operation has parameters with `in=formData`, Schemathesis should know how to serialize it.
636
- # We can't be 100% sure what media type is expected by the server and chose `multipart/form-data` as
637
- # the default because it is broader since it allows us to upload files.
638
- form_data_media_types = media_types or (OPENAPI_20_DEFAULT_FORM_MEDIA_TYPE,)
639
-
640
- form_parameters = []
641
- for parameter in parameters:
642
- if parameter["in"] == "formData":
643
- # We need to gather form parameters first before creating a composite parameter for them
644
- form_parameters.append(parameter)
645
- elif parameter["in"] == "body":
646
- for media_type in body_media_types:
647
- collected.append(OpenAPI20Body(definition=parameter, media_type=media_type))
648
- else:
649
- collected.append(OpenAPI20Parameter(definition=parameter))
650
-
651
- if form_parameters:
652
- for media_type in form_data_media_types:
653
- collected.append(
654
- # Individual `formData` parameters are joined into a single "composite" one.
655
- OpenAPI20CompositeBody.from_parameters(*form_parameters, media_type=media_type)
656
- )
657
- return collected
658
-
659
- def get_strategies_from_examples(self, operation: APIOperation) -> List[SearchStrategy[Case]]:
693
+ def get_strategies_from_examples(self, operation: APIOperation, **kwargs: Any) -> list[SearchStrategy[Case]]:
660
694
  """Get examples from the API operation."""
661
- return get_strategies_from_examples(operation, self.examples_field)
662
-
663
- def get_response_schema(self, definition: Dict[str, Any], scope: str) -> Tuple[List[str], Optional[Dict[str, Any]]]:
664
- scopes, definition = self.resolver.resolve_in_scope(deepcopy(definition), scope)
665
- schema = definition.get("schema")
666
- if not schema:
667
- return scopes, None
668
- # Extra conversion to JSON Schema is needed here if there was one $ref in the input
669
- # because it is not converted
670
- return scopes, to_json_schema_recursive(schema, self.nullable_name, is_response_schema=True)
671
-
672
- def get_content_types(self, operation: APIOperation, response: GenericResponse) -> List[str]:
695
+ return get_strategies_from_examples(operation, **kwargs)
696
+
697
+ def get_content_types(self, operation: APIOperation, response: Response) -> list[str]:
673
698
  produces = operation.definition.raw.get("produces", None)
674
699
  if produces:
675
700
  return produces
676
701
  return self.raw_schema.get("produces", [])
677
702
 
678
- def _get_parameter_serializer(self, definitions: List[Dict[str, Any]]) -> Optional[Callable]:
703
+ def _get_parameter_serializer(self, definitions: list[dict[str, Any]]) -> Callable | None:
679
704
  return serialization.serialize_swagger2_parameters(definitions)
680
705
 
681
706
  def prepare_multipart(
682
- self, form_data: FormData, operation: APIOperation
683
- ) -> Tuple[Optional[List], Optional[Dict[str, Any]]]:
684
- """Prepare form data for sending with `requests`.
685
-
686
- :param form_data: Raw generated data as a dictionary.
687
- :param operation: The tested API operation for which the data was generated.
688
- :return: `files` and `data` values for `requests.request`.
689
- """
707
+ self, form_data: dict[str, Any], operation: APIOperation
708
+ ) -> tuple[list | None, dict[str, Any] | None]:
690
709
  files, data = [], {}
691
710
  # If there is no content types specified for the request or "application/x-www-form-urlencoded" is specified
692
711
  # explicitly, then use it., but if "multipart/form-data" is specified, then use it
693
712
  content_types = self.get_request_payload_content_types(operation)
694
713
  is_multipart = "multipart/form-data" in content_types
695
714
 
696
- def add_file(file_value: Any) -> None:
697
- if isinstance(file_value, list):
698
- for item in file_value:
715
+ known_fields: dict[str, dict] = {}
716
+
717
+ for parameter in operation.body:
718
+ if COMBINED_FORM_DATA_MARKER in parameter.definition:
719
+ known_fields.update(parameter.definition["schema"].get("properties", {}))
720
+
721
+ def add_file(name: str, value: Any) -> None:
722
+ if isinstance(value, list):
723
+ for item in value:
699
724
  files.append((name, (None, item)))
700
725
  else:
701
- files.append((name, file_value))
702
-
703
- for parameter in operation.definition.parameters:
704
- if isinstance(parameter, OpenAPI20CompositeBody):
705
- for form_parameter in parameter.definition:
706
- name = form_parameter.name
707
- # It might be not in `form_data`, if the parameter is optional
708
- if name in form_data:
709
- value = form_data[name]
710
- if form_parameter.definition.get("type") == "file" or is_multipart:
711
- add_file(value)
712
- else:
713
- data[name] = value
726
+ files.append((name, value))
727
+
728
+ for name, value in form_data.items():
729
+ param_def = known_fields.get(name)
730
+ if param_def:
731
+ if param_def.get("type") == "file" or is_multipart:
732
+ add_file(name, value)
733
+ else:
734
+ data[name] = value
735
+ else:
736
+ # Unknown field — treat it as a file (safe default under multipart/form-data)
737
+ add_file(name, value)
714
738
  # `None` is the default value for `files` and `data` arguments in `requests.request`
715
739
  return files or None, data or None
716
740
 
717
- def get_request_payload_content_types(self, operation: APIOperation) -> List[str]:
718
- return self._get_consumes_for_operation(operation.definition.resolved)
741
+ def get_request_payload_content_types(self, operation: APIOperation) -> list[str]:
742
+ return self._get_consumes_for_operation(operation.definition.raw)
719
743
 
720
744
  def make_case(
721
745
  self,
722
746
  *,
723
- case_cls: Type[C],
724
747
  operation: APIOperation,
725
- path_parameters: Optional[PathParameters] = None,
726
- headers: Optional[Headers] = None,
727
- cookies: Optional[Cookies] = None,
728
- query: Optional[Query] = None,
729
- body: Union[Body, NotSet] = NOT_SET,
730
- media_type: Optional[str] = None,
731
- ) -> C:
748
+ method: str | None = None,
749
+ path: str | None = None,
750
+ path_parameters: dict[str, Any] | None = None,
751
+ headers: dict[str, Any] | CaseInsensitiveDict | None = None,
752
+ cookies: dict[str, Any] | None = None,
753
+ query: dict[str, Any] | None = None,
754
+ body: list | dict[str, Any] | str | int | float | bool | bytes | NotSet = NOT_SET,
755
+ media_type: str | None = None,
756
+ meta: CaseMetadata | None = None,
757
+ ) -> Case:
732
758
  if body is not NOT_SET and media_type is None:
733
- # If the user wants to send payload, then there should be a media type, otherwise the payload is ignored
734
- media_types = operation.get_request_payload_content_types()
735
- if len(media_types) == 1:
736
- # The only available option
737
- media_type = media_types[0]
738
- else:
739
- media_types_repr = ", ".join(media_types)
740
- raise UsageError(
741
- "Can not detect appropriate media type. "
742
- "You can either specify one of the defined media types "
743
- f"or pass any other media type available for serialization. Defined media types: {media_types_repr}"
744
- )
745
- return case_cls(
759
+ media_type = operation._get_default_media_type()
760
+ return Case(
746
761
  operation=operation,
747
- path_parameters=path_parameters,
748
- headers=CaseInsensitiveDict(headers) if headers is not None else headers,
749
- cookies=cookies,
750
- query=query,
762
+ method=method or operation.method.upper(),
763
+ path=path or operation.path,
764
+ path_parameters=path_parameters or {},
765
+ headers=CaseInsensitiveDict() if headers is None else CaseInsensitiveDict(headers),
766
+ cookies=cookies or {},
767
+ query=query or {},
751
768
  body=body,
752
769
  media_type=media_type,
770
+ meta=meta,
753
771
  )
754
772
 
755
- def _get_consumes_for_operation(self, definition: Dict[str, Any]) -> List[str]:
756
- """Get the `consumes` value for the given API operation.
757
-
758
- :param definition: Raw API operation definition.
759
- :return: A list of media-types for this operation.
760
- :rtype: List[str]
761
- """
773
+ def _get_consumes_for_operation(self, definition: dict[str, Any]) -> list[str]:
762
774
  global_consumes = self.raw_schema.get("consumes", [])
763
775
  consumes = definition.get("consumes", [])
764
776
  if not consumes:
@@ -766,21 +778,29 @@ class SwaggerV20(BaseOpenAPISchema):
766
778
  return consumes
767
779
 
768
780
 
769
- class OpenApi30(SwaggerV20): # pylint: disable=too-many-ancestors
770
- nullable_name = "nullable"
771
- example_field = "example"
772
- examples_field = "examples"
773
- security = OpenAPISecurityProcessor()
774
- component_locations = (("components", "schemas"),)
775
- links_field = "links"
781
+ class OpenApi30(SwaggerV20):
782
+ def __post_init__(self) -> None:
783
+ if self.specification.version.startswith("3.1"):
784
+ self.adapter = adapter.v3_1
785
+ else:
786
+ self.adapter = adapter.v3_0
787
+ BaseOpenAPISchema.__post_init__(self)
776
788
 
777
789
  @property
778
- def spec_version(self) -> str:
779
- return self.raw_schema["openapi"]
790
+ def specification(self) -> Specification:
791
+ version = self.raw_schema["openapi"]
792
+ return Specification.openapi(version=version)
780
793
 
781
- @property
782
- def verbose_name(self) -> str:
783
- return f"Open API {self.spec_version}"
794
+ @cached_property
795
+ def default_media_types(self) -> list[str]:
796
+ return []
797
+
798
+ def _validate(self) -> None:
799
+ if self.specification.version.startswith("3.1"):
800
+ # Currently we treat Open API 3.1 as 3.0 in some regard
801
+ OPENAPI_31_VALIDATOR.validate(self.raw_schema)
802
+ else:
803
+ OPENAPI_30_VALIDATOR.validate(self.raw_schema)
784
804
 
785
805
  def _get_base_path(self) -> str:
786
806
  servers = self.raw_schema.get("servers", [])
@@ -791,68 +811,46 @@ class OpenApi30(SwaggerV20): # pylint: disable=too-many-ancestors
791
811
  return urlsplit(url).path
792
812
  return "/"
793
813
 
794
- def collect_parameters(
795
- self, parameters: Iterable[Dict[str, Any]], definition: Dict[str, Any]
796
- ) -> List[OpenAPIParameter]:
797
- # Open API 3.0 has the `requestBody` keyword, which may contain multiple different payload variants.
798
- collected: List[OpenAPIParameter] = [OpenAPI30Parameter(definition=parameter) for parameter in parameters]
799
- if "requestBody" in definition:
800
- required = definition["requestBody"].get("required", False)
801
- description = definition["requestBody"].get("description")
802
- for media_type, content in definition["requestBody"]["content"].items():
803
- collected.append(
804
- OpenAPI30Body(content, description=description, media_type=media_type, required=required)
805
- )
806
- return collected
807
-
808
- def get_response_schema(self, definition: Dict[str, Any], scope: str) -> Tuple[List[str], Optional[Dict[str, Any]]]:
809
- scopes, definition = self.resolver.resolve_in_scope(deepcopy(definition), scope)
810
- options = iter(definition.get("content", {}).values())
811
- option = next(options, None)
812
- # "schema" is an optional key in the `MediaType` object
813
- if option and "schema" in option:
814
- # Extra conversion to JSON Schema is needed here if there was one $ref in the input
815
- # because it is not converted
816
- return scopes, to_json_schema_recursive(option["schema"], self.nullable_name, is_response_schema=True)
817
- return scopes, None
818
-
819
- def get_strategies_from_examples(self, operation: APIOperation) -> List[SearchStrategy[Case]]:
814
+ def get_strategies_from_examples(self, operation: APIOperation, **kwargs: Any) -> list[SearchStrategy[Case]]:
820
815
  """Get examples from the API operation."""
821
- return get_strategies_from_examples(operation, self.examples_field)
816
+ return get_strategies_from_examples(operation, **kwargs)
822
817
 
823
- def get_content_types(self, operation: APIOperation, response: GenericResponse) -> List[str]:
824
- definitions = self._get_response_definitions(operation, response)
825
- if not definitions:
818
+ def get_content_types(self, operation: APIOperation, response: Response) -> list[str]:
819
+ definition = operation.responses.find_by_status_code(response.status_code)
820
+ if definition is None:
826
821
  return []
827
- return list(definitions.get("content", {}).keys())
822
+ return list(definition.definition.get("content", {}).keys())
828
823
 
829
- def _get_parameter_serializer(self, definitions: List[Dict[str, Any]]) -> Optional[Callable]:
824
+ def _get_parameter_serializer(self, definitions: list[dict[str, Any]]) -> Callable | None:
830
825
  return serialization.serialize_openapi3_parameters(definitions)
831
826
 
832
- def get_request_payload_content_types(self, operation: APIOperation) -> List[str]:
833
- return list(operation.definition.resolved["requestBody"]["content"].keys())
827
+ def get_request_payload_content_types(self, operation: APIOperation) -> list[str]:
828
+ return [body.media_type for body in operation.body]
834
829
 
835
830
  def prepare_multipart(
836
- self, form_data: FormData, operation: APIOperation
837
- ) -> Tuple[Optional[List], Optional[Dict[str, Any]]]:
838
- """Prepare form data for sending with `requests`.
839
-
840
- :param form_data: Raw generated data as a dictionary.
841
- :param operation: The tested API operation for which the data was generated.
842
- :return: `files` and `data` values for `requests.request`.
843
- """
831
+ self, form_data: dict[str, Any], operation: APIOperation
832
+ ) -> tuple[list | None, dict[str, Any] | None]:
844
833
  files = []
845
- content = operation.definition.resolved["requestBody"]["content"]
846
834
  # Open API 3.0 requires media types to be present. We can get here only if the schema defines
847
- # the "multipart/form-data" media type
848
- schema = content["multipart/form-data"]["schema"]
849
- for name, property_schema in schema.get("properties", {}).items():
850
- if name in form_data:
851
- if isinstance(form_data[name], list):
852
- files.extend([(name, item) for item in form_data[name]])
835
+ # the "multipart/form-data" media type, or any other more general media type that matches it (like `*/*`)
836
+ schema = {}
837
+ for body in operation.body:
838
+ main, sub = media_types.parse(body.media_type)
839
+ if main in ("*", "multipart") and sub in ("*", "form-data", "mixed"):
840
+ schema = body.definition.get("schema")
841
+ break
842
+ for name, value in form_data.items():
843
+ property_schema = (schema or {}).get("properties", {}).get(name)
844
+ if property_schema:
845
+ if isinstance(value, list):
846
+ files.extend([(name, item) for item in value])
853
847
  elif property_schema.get("format") in ("binary", "base64"):
854
- files.append((name, form_data[name]))
848
+ files.append((name, value))
855
849
  else:
856
- files.append((name, (None, form_data[name])))
850
+ files.append((name, (None, value)))
851
+ elif isinstance(value, list):
852
+ files.extend([(name, item) for item in value])
853
+ else:
854
+ files.append((name, (None, value)))
857
855
  # `None` is the default value for `files` and `data` arguments in `requests.request`
858
856
  return files or None, None