schemathesis 3.39.7__py3-none-any.whl → 4.0.0a2__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 (229) hide show
  1. schemathesis/__init__.py +27 -65
  2. schemathesis/auths.py +26 -68
  3. schemathesis/checks.py +130 -60
  4. schemathesis/cli/__init__.py +5 -2105
  5. schemathesis/cli/commands/__init__.py +37 -0
  6. schemathesis/cli/commands/run/__init__.py +662 -0
  7. schemathesis/cli/commands/run/checks.py +80 -0
  8. schemathesis/cli/commands/run/context.py +117 -0
  9. schemathesis/cli/commands/run/events.py +30 -0
  10. schemathesis/cli/commands/run/executor.py +141 -0
  11. schemathesis/cli/commands/run/filters.py +202 -0
  12. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  13. schemathesis/cli/commands/run/handlers/base.py +18 -0
  14. schemathesis/cli/{cassettes.py → commands/run/handlers/cassettes.py} +178 -247
  15. schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
  16. schemathesis/cli/commands/run/handlers/output.py +1368 -0
  17. schemathesis/cli/commands/run/hypothesis.py +105 -0
  18. schemathesis/cli/commands/run/loaders.py +129 -0
  19. schemathesis/cli/{callbacks.py → commands/run/validation.py} +59 -175
  20. schemathesis/cli/constants.py +5 -58
  21. schemathesis/cli/core.py +17 -0
  22. schemathesis/cli/ext/fs.py +14 -0
  23. schemathesis/cli/ext/groups.py +55 -0
  24. schemathesis/cli/{options.py → ext/options.py} +37 -16
  25. schemathesis/cli/hooks.py +36 -0
  26. schemathesis/contrib/__init__.py +1 -3
  27. schemathesis/contrib/openapi/__init__.py +1 -3
  28. schemathesis/contrib/openapi/fill_missing_examples.py +3 -7
  29. schemathesis/core/__init__.py +58 -0
  30. schemathesis/core/compat.py +25 -0
  31. schemathesis/core/control.py +2 -0
  32. schemathesis/core/curl.py +58 -0
  33. schemathesis/core/deserialization.py +65 -0
  34. schemathesis/core/errors.py +370 -0
  35. schemathesis/core/failures.py +315 -0
  36. schemathesis/core/fs.py +19 -0
  37. schemathesis/core/loaders.py +104 -0
  38. schemathesis/core/marks.py +66 -0
  39. schemathesis/{transports/content_types.py → core/media_types.py} +14 -12
  40. schemathesis/{internal/output.py → core/output/__init__.py} +1 -0
  41. schemathesis/core/output/sanitization.py +197 -0
  42. schemathesis/{throttling.py → core/rate_limit.py} +16 -17
  43. schemathesis/core/registries.py +31 -0
  44. schemathesis/core/transforms.py +113 -0
  45. schemathesis/core/transport.py +108 -0
  46. schemathesis/core/validation.py +38 -0
  47. schemathesis/core/version.py +7 -0
  48. schemathesis/engine/__init__.py +30 -0
  49. schemathesis/engine/config.py +59 -0
  50. schemathesis/engine/context.py +119 -0
  51. schemathesis/engine/control.py +36 -0
  52. schemathesis/engine/core.py +157 -0
  53. schemathesis/engine/errors.py +394 -0
  54. schemathesis/engine/events.py +243 -0
  55. schemathesis/engine/phases/__init__.py +66 -0
  56. schemathesis/{runner → engine/phases}/probes.py +49 -68
  57. schemathesis/engine/phases/stateful/__init__.py +66 -0
  58. schemathesis/engine/phases/stateful/_executor.py +301 -0
  59. schemathesis/engine/phases/stateful/context.py +85 -0
  60. schemathesis/engine/phases/unit/__init__.py +175 -0
  61. schemathesis/engine/phases/unit/_executor.py +322 -0
  62. schemathesis/engine/phases/unit/_pool.py +74 -0
  63. schemathesis/engine/recorder.py +246 -0
  64. schemathesis/errors.py +31 -0
  65. schemathesis/experimental/__init__.py +9 -40
  66. schemathesis/filters.py +7 -95
  67. schemathesis/generation/__init__.py +3 -3
  68. schemathesis/generation/case.py +190 -0
  69. schemathesis/generation/coverage.py +22 -22
  70. schemathesis/{_patches.py → generation/hypothesis/__init__.py} +15 -6
  71. schemathesis/generation/hypothesis/builder.py +585 -0
  72. schemathesis/generation/{_hypothesis.py → hypothesis/examples.py} +2 -11
  73. schemathesis/generation/hypothesis/given.py +66 -0
  74. schemathesis/generation/hypothesis/reporting.py +14 -0
  75. schemathesis/generation/hypothesis/strategies.py +16 -0
  76. schemathesis/generation/meta.py +115 -0
  77. schemathesis/generation/modes.py +28 -0
  78. schemathesis/generation/overrides.py +96 -0
  79. schemathesis/generation/stateful/__init__.py +20 -0
  80. schemathesis/{stateful → generation/stateful}/state_machine.py +84 -109
  81. schemathesis/generation/targets.py +69 -0
  82. schemathesis/graphql/__init__.py +15 -0
  83. schemathesis/graphql/checks.py +109 -0
  84. schemathesis/graphql/loaders.py +131 -0
  85. schemathesis/hooks.py +17 -62
  86. schemathesis/openapi/__init__.py +13 -0
  87. schemathesis/openapi/checks.py +387 -0
  88. schemathesis/openapi/generation/__init__.py +0 -0
  89. schemathesis/openapi/generation/filters.py +63 -0
  90. schemathesis/openapi/loaders.py +178 -0
  91. schemathesis/pytest/__init__.py +5 -0
  92. schemathesis/pytest/control_flow.py +7 -0
  93. schemathesis/pytest/lazy.py +273 -0
  94. schemathesis/pytest/loaders.py +12 -0
  95. schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +94 -107
  96. schemathesis/python/__init__.py +0 -0
  97. schemathesis/python/asgi.py +12 -0
  98. schemathesis/python/wsgi.py +12 -0
  99. schemathesis/schemas.py +456 -228
  100. schemathesis/specs/graphql/__init__.py +0 -1
  101. schemathesis/specs/graphql/_cache.py +1 -2
  102. schemathesis/specs/graphql/scalars.py +5 -3
  103. schemathesis/specs/graphql/schemas.py +122 -123
  104. schemathesis/specs/graphql/validation.py +11 -17
  105. schemathesis/specs/openapi/__init__.py +6 -1
  106. schemathesis/specs/openapi/_cache.py +1 -2
  107. schemathesis/specs/openapi/_hypothesis.py +97 -134
  108. schemathesis/specs/openapi/checks.py +238 -219
  109. schemathesis/specs/openapi/converter.py +4 -4
  110. schemathesis/specs/openapi/definitions.py +1 -1
  111. schemathesis/specs/openapi/examples.py +22 -20
  112. schemathesis/specs/openapi/expressions/__init__.py +11 -15
  113. schemathesis/specs/openapi/expressions/extractors.py +1 -4
  114. schemathesis/specs/openapi/expressions/nodes.py +33 -32
  115. schemathesis/specs/openapi/formats.py +3 -2
  116. schemathesis/specs/openapi/links.py +123 -299
  117. schemathesis/specs/openapi/media_types.py +10 -12
  118. schemathesis/specs/openapi/negative/__init__.py +2 -1
  119. schemathesis/specs/openapi/negative/mutations.py +3 -2
  120. schemathesis/specs/openapi/parameters.py +8 -6
  121. schemathesis/specs/openapi/patterns.py +1 -1
  122. schemathesis/specs/openapi/references.py +11 -51
  123. schemathesis/specs/openapi/schemas.py +177 -191
  124. schemathesis/specs/openapi/security.py +1 -1
  125. schemathesis/specs/openapi/serialization.py +10 -6
  126. schemathesis/specs/openapi/stateful/__init__.py +97 -91
  127. schemathesis/transport/__init__.py +104 -0
  128. schemathesis/transport/asgi.py +26 -0
  129. schemathesis/transport/prepare.py +99 -0
  130. schemathesis/transport/requests.py +221 -0
  131. schemathesis/{_xml.py → transport/serialization.py} +69 -7
  132. schemathesis/transport/wsgi.py +165 -0
  133. {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/METADATA +18 -14
  134. schemathesis-4.0.0a2.dist-info/RECORD +151 -0
  135. {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/entry_points.txt +1 -1
  136. schemathesis/_compat.py +0 -74
  137. schemathesis/_dependency_versions.py +0 -19
  138. schemathesis/_hypothesis.py +0 -559
  139. schemathesis/_override.py +0 -50
  140. schemathesis/_rate_limiter.py +0 -7
  141. schemathesis/cli/context.py +0 -75
  142. schemathesis/cli/debug.py +0 -27
  143. schemathesis/cli/handlers.py +0 -19
  144. schemathesis/cli/junitxml.py +0 -124
  145. schemathesis/cli/output/__init__.py +0 -1
  146. schemathesis/cli/output/default.py +0 -936
  147. schemathesis/cli/output/short.py +0 -59
  148. schemathesis/cli/reporting.py +0 -79
  149. schemathesis/cli/sanitization.py +0 -26
  150. schemathesis/code_samples.py +0 -151
  151. schemathesis/constants.py +0 -56
  152. schemathesis/contrib/openapi/formats/__init__.py +0 -9
  153. schemathesis/contrib/openapi/formats/uuid.py +0 -16
  154. schemathesis/contrib/unique_data.py +0 -41
  155. schemathesis/exceptions.py +0 -571
  156. schemathesis/extra/_aiohttp.py +0 -28
  157. schemathesis/extra/_flask.py +0 -13
  158. schemathesis/extra/_server.py +0 -18
  159. schemathesis/failures.py +0 -277
  160. schemathesis/fixups/__init__.py +0 -37
  161. schemathesis/fixups/fast_api.py +0 -41
  162. schemathesis/fixups/utf8_bom.py +0 -28
  163. schemathesis/generation/_methods.py +0 -44
  164. schemathesis/graphql.py +0 -3
  165. schemathesis/internal/__init__.py +0 -7
  166. schemathesis/internal/checks.py +0 -84
  167. schemathesis/internal/copy.py +0 -32
  168. schemathesis/internal/datetime.py +0 -5
  169. schemathesis/internal/deprecation.py +0 -38
  170. schemathesis/internal/diff.py +0 -15
  171. schemathesis/internal/extensions.py +0 -27
  172. schemathesis/internal/jsonschema.py +0 -36
  173. schemathesis/internal/transformation.py +0 -26
  174. schemathesis/internal/validation.py +0 -34
  175. schemathesis/lazy.py +0 -474
  176. schemathesis/loaders.py +0 -122
  177. schemathesis/models.py +0 -1341
  178. schemathesis/parameters.py +0 -90
  179. schemathesis/runner/__init__.py +0 -605
  180. schemathesis/runner/events.py +0 -389
  181. schemathesis/runner/impl/__init__.py +0 -3
  182. schemathesis/runner/impl/context.py +0 -104
  183. schemathesis/runner/impl/core.py +0 -1246
  184. schemathesis/runner/impl/solo.py +0 -80
  185. schemathesis/runner/impl/threadpool.py +0 -391
  186. schemathesis/runner/serialization.py +0 -544
  187. schemathesis/sanitization.py +0 -252
  188. schemathesis/serializers.py +0 -328
  189. schemathesis/service/__init__.py +0 -18
  190. schemathesis/service/auth.py +0 -11
  191. schemathesis/service/ci.py +0 -202
  192. schemathesis/service/client.py +0 -133
  193. schemathesis/service/constants.py +0 -38
  194. schemathesis/service/events.py +0 -61
  195. schemathesis/service/extensions.py +0 -224
  196. schemathesis/service/hosts.py +0 -111
  197. schemathesis/service/metadata.py +0 -71
  198. schemathesis/service/models.py +0 -258
  199. schemathesis/service/report.py +0 -255
  200. schemathesis/service/serialization.py +0 -173
  201. schemathesis/service/usage.py +0 -66
  202. schemathesis/specs/graphql/loaders.py +0 -364
  203. schemathesis/specs/openapi/expressions/context.py +0 -16
  204. schemathesis/specs/openapi/loaders.py +0 -708
  205. schemathesis/specs/openapi/stateful/statistic.py +0 -198
  206. schemathesis/specs/openapi/stateful/types.py +0 -14
  207. schemathesis/specs/openapi/validation.py +0 -26
  208. schemathesis/stateful/__init__.py +0 -147
  209. schemathesis/stateful/config.py +0 -97
  210. schemathesis/stateful/context.py +0 -135
  211. schemathesis/stateful/events.py +0 -274
  212. schemathesis/stateful/runner.py +0 -309
  213. schemathesis/stateful/sink.py +0 -68
  214. schemathesis/stateful/statistic.py +0 -22
  215. schemathesis/stateful/validation.py +0 -100
  216. schemathesis/targets.py +0 -77
  217. schemathesis/transports/__init__.py +0 -359
  218. schemathesis/transports/asgi.py +0 -7
  219. schemathesis/transports/auth.py +0 -38
  220. schemathesis/transports/headers.py +0 -36
  221. schemathesis/transports/responses.py +0 -57
  222. schemathesis/types.py +0 -44
  223. schemathesis/utils.py +0 -164
  224. schemathesis-3.39.7.dist-info/RECORD +0 -160
  225. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
  226. /schemathesis/{_lazy_import.py → core/lazy_import.py} +0 -0
  227. /schemathesis/{internal → core}/result.py +0 -0
  228. {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/WHEEL +0 -0
  229. {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/licenses/LICENSE +0 -0
@@ -1,334 +1,158 @@
1
- """Open API links support.
2
-
3
- Based on https://swagger.io/docs/specification/links/
4
- """
5
-
6
1
  from __future__ import annotations
7
2
 
8
- from dataclasses import dataclass, field
9
- from difflib import get_close_matches
10
- from types import SimpleNamespace
11
- from typing import TYPE_CHECKING, Any, Generator, Literal, NoReturn, Sequence, TypedDict, Union, cast
3
+ from dataclasses import dataclass
4
+ from functools import lru_cache
5
+ from typing import TYPE_CHECKING, Any, Generator, Literal, Union, cast
6
+
7
+ from schemathesis.core import NOT_SET, NotSet
8
+ from schemathesis.core.result import Err, Ok, Result
9
+ from schemathesis.generation.stateful.state_machine import ExtractedParam, StepOutput, Transition
10
+ from schemathesis.schemas import APIOperation
12
11
 
13
- from ...constants import NOT_SET
14
- from ...internal.copy import fast_deepcopy
15
- from ...models import APIOperation, Case, TransitionId
16
- from ...stateful import ParsedData, StatefulTest, UnresolvableLink
17
- from ...stateful.state_machine import Direction
18
12
  from . import expressions
19
13
  from .constants import LOCATION_TO_CONTAINER
20
- from .parameters import OpenAPI20Body, OpenAPI30Body, OpenAPIParameter
21
- from .references import RECURSION_DEPTH_LIMIT, Unresolvable
14
+ from .references import RECURSION_DEPTH_LIMIT
22
15
 
23
16
  if TYPE_CHECKING:
24
- from hypothesis.vendor.pretty import RepresentationPrinter
25
17
  from jsonschema import RefResolver
26
18
 
27
- from ...parameters import ParameterSet
28
- from ...transports.responses import GenericResponse
29
- from ...types import NotSet
30
19
 
20
+ SCHEMATHESIS_LINK_EXTENSION = "x-schemathesis"
21
+ ParameterLocation = Literal["path", "query", "header", "cookie", "body"]
31
22
 
32
- @dataclass(repr=False)
33
- class Link(StatefulTest):
34
- operation: APIOperation
35
- parameters: dict[str, Any]
36
- request_body: Any = NOT_SET
37
- merge_body: bool = True
38
-
39
- def __post_init__(self) -> None:
40
- if self.request_body is not NOT_SET and not self.operation.body:
41
- # Link defines `requestBody` for a parameter that does not accept one
42
- raise ValueError(
43
- f"Request body is not defined in API operation {self.operation.method.upper()} {self.operation.path}"
44
- )
45
-
46
- @classmethod
47
- def from_definition(cls, name: str, definition: dict[str, dict[str, Any]], source_operation: APIOperation) -> Link:
48
- # Links can be behind a reference
49
- _, definition = source_operation.schema.resolver.resolve_in_scope( # type: ignore
50
- definition, source_operation.definition.scope
51
- )
52
- if "operationId" in definition:
53
- # source_operation.schema is `BaseOpenAPISchema` and has this method
54
- operation = source_operation.schema.get_operation_by_id(definition["operationId"]) # type: ignore
55
- else:
56
- operation = source_operation.schema.get_operation_by_reference(definition["operationRef"]) # type: ignore
57
- extension = definition.get(SCHEMATHESIS_LINK_EXTENSION)
58
- return cls(
59
- # Pylint can't detect that the API operation is always defined at this point
60
- # E.g. if there is no matching operation or no operations at all, then a ValueError will be risen
61
- name=name,
62
- operation=operation,
63
- parameters=definition.get("parameters", {}),
64
- request_body=definition.get("requestBody", NOT_SET), # `None` might be a valid value - `null`
65
- merge_body=extension.get("merge_body", True) if extension is not None else True,
66
- )
67
23
 
68
- def parse(self, case: Case, response: GenericResponse) -> ParsedData:
69
- """Parse data into a structure expected by links definition."""
70
- context = expressions.ExpressionContext(case=case, response=response)
71
- parameters = {}
72
- for parameter, expression in self.parameters.items():
73
- evaluated = expressions.evaluate(expression, context)
74
- if isinstance(evaluated, Unresolvable):
75
- raise UnresolvableLink(f"Unresolvable reference in the link: {expression}")
76
- parameters[parameter] = evaluated
77
- body = expressions.evaluate(self.request_body, context, evaluate_nested=True)
78
- if self.merge_body:
79
- body = merge_body(case.body, body)
80
- return ParsedData(parameters=parameters, body=body)
81
-
82
- def is_match(self) -> bool:
83
- return self.operation.schema.filter_set.match(SimpleNamespace(operation=self.operation))
84
-
85
- def make_operation(self, collected: list[ParsedData]) -> APIOperation:
86
- """Create a modified version of the original API operation with additional data merged in."""
87
- # We split the gathered data among all locations & store the original parameter
88
- containers = {
89
- location: {
90
- parameter.name: {"options": [], "parameter": parameter}
91
- for parameter in getattr(self.operation, container_name)
92
- }
93
- for location, container_name in LOCATION_TO_CONTAINER.items()
94
- }
95
- # There might be duplicates in the data
96
- for item in set(collected):
97
- for name, value in item.parameters.items():
98
- container = self._get_container_by_parameter_name(name, containers)
99
- container.append(value)
100
- if "body" in containers["body"] and item.body is not NOT_SET:
101
- containers["body"]["body"]["options"].append(item.body)
102
- # These are the final `path_parameters`, `query`, and other API operation components
103
- components: dict[str, ParameterSet] = {
104
- container_name: getattr(self.operation, container_name).__class__()
105
- for location, container_name in LOCATION_TO_CONTAINER.items()
106
- }
107
- # Here are all components that are filled with parameters
108
- for location, parameters in containers.items():
109
- for parameter_data in parameters.values():
110
- parameter = parameter_data["parameter"]
111
- if parameter_data["options"]:
112
- definition = fast_deepcopy(parameter.definition)
113
- if "schema" in definition:
114
- # The actual schema doesn't matter since we have a list of allowed values
115
- definition["schema"] = {"enum": parameter_data["options"]}
116
- else:
117
- # Other schema-related keywords will be ignored later, during the canonicalisation step
118
- # inside `hypothesis-jsonschema`
119
- definition["enum"] = parameter_data["options"]
120
- new_parameter: OpenAPIParameter
121
- if isinstance(parameter, OpenAPI30Body):
122
- new_parameter = parameter.__class__(
123
- definition, media_type=parameter.media_type, required=parameter.required
124
- )
125
- elif isinstance(parameter, OpenAPI20Body):
126
- new_parameter = parameter.__class__(definition, media_type=parameter.media_type)
127
- else:
128
- new_parameter = parameter.__class__(definition)
129
- components[LOCATION_TO_CONTAINER[location]].add(new_parameter)
130
- else:
131
- # No options were gathered for this parameter - use the original one
132
- components[LOCATION_TO_CONTAINER[location]].add(parameter)
133
- return self.operation.clone(**components)
134
-
135
- def _get_container_by_parameter_name(self, full_name: str, templates: dict[str, dict[str, dict[str, Any]]]) -> list:
136
- """Detect in what request part the parameters is defined."""
137
- location: str | None
138
- try:
139
- # The parameter name is prefixed with its location. Example: `path.id`
140
- location, name = full_name.split(".")
141
- except ValueError:
142
- location, name = None, full_name
143
- if location:
144
- try:
145
- parameters = templates[location]
146
- except KeyError:
147
- self._unknown_parameter(full_name)
148
- else:
149
- for parameters in templates.values():
150
- if name in parameters:
151
- break
152
- else:
153
- self._unknown_parameter(full_name)
154
- if not parameters:
155
- self._unknown_parameter(full_name)
156
- return parameters[name]["options"]
157
-
158
- def _unknown_parameter(self, name: str) -> NoReturn:
159
- raise ValueError(
160
- f"Parameter `{name}` is not defined in API operation {self.operation.method.upper()} {self.operation.path}"
161
- )
24
+ @dataclass
25
+ class NormalizedParameter:
26
+ """Processed link parameter with resolved container information."""
162
27
 
28
+ location: ParameterLocation | None
29
+ name: str
30
+ expression: str
31
+ container_name: str
163
32
 
164
- def get_links(response: GenericResponse, operation: APIOperation, field: str) -> Sequence[Link]:
165
- """Get `x-links` / `links` definitions from the schema."""
166
- responses = operation.definition.raw["responses"]
167
- if str(response.status_code) in responses:
168
- definition = responses[str(response.status_code)]
169
- elif response.status_code in responses:
170
- definition = responses[response.status_code]
171
- else:
172
- definition = responses.get("default", {})
173
- if not definition:
174
- return []
175
- _, definition = operation.schema.resolver.resolve_in_scope(definition, operation.definition.scope) # type: ignore[attr-defined]
176
- links = definition.get(field, {})
177
- return [Link.from_definition(name, definition, operation) for name, definition in links.items()]
178
-
33
+ __slots__ = ("location", "name", "expression", "container_name")
179
34
 
180
- SCHEMATHESIS_LINK_EXTENSION = "x-schemathesis"
181
35
 
36
+ @dataclass(repr=False)
37
+ class OpenApiLink:
38
+ """Represents an OpenAPI link between operations."""
182
39
 
183
- class SchemathesisLink(TypedDict):
40
+ name: str
41
+ status_code: str
42
+ source: APIOperation
43
+ target: APIOperation
44
+ parameters: list[NormalizedParameter]
45
+ body: dict[str, Any] | NotSet
184
46
  merge_body: bool
185
47
 
48
+ __slots__ = ("name", "status_code", "source", "target", "parameters", "body", "merge_body", "_cached_extract")
186
49
 
187
- @dataclass(repr=False)
188
- class OpenAPILink(Direction):
189
- """Alternative approach to link processing.
50
+ def __init__(self, name: str, status_code: str, definition: dict[str, Any], source: APIOperation):
51
+ self.name = name
52
+ self.status_code = status_code
53
+ self.source = source
190
54
 
191
- NOTE. This class will replace `Link` in the future.
192
- """
55
+ if "operationId" in definition:
56
+ self.target = source.schema.get_operation_by_id(definition["operationId"]) # type: ignore
57
+ else:
58
+ self.target = source.schema.get_operation_by_reference(definition["operationRef"]) # type: ignore
193
59
 
194
- name: str
195
- status_code: str
196
- definition: dict[str, Any]
197
- operation: APIOperation
198
- parameters: list[tuple[Literal["path", "query", "header", "cookie", "body"] | None, str, str]] = field(init=False)
199
- body: dict[str, Any] | NotSet = field(init=False)
200
- merge_body: bool = True
201
-
202
- def __repr__(self) -> str:
203
- path = self.operation.path
204
- method = self.operation.method
205
- return f"state.schema['{path}']['{method}'].links['{self.status_code}']['{self.name}']"
206
-
207
- def _repr_pretty_(self, printer: RepresentationPrinter, cycle: bool) -> None:
208
- return printer.text(repr(self))
209
-
210
- def __post_init__(self) -> None:
211
- extension = self.definition.get(SCHEMATHESIS_LINK_EXTENSION)
212
- self.parameters = [
213
- normalize_parameter(parameter, expression)
214
- for parameter, expression in self.definition.get("parameters", {}).items()
215
- ]
216
- self.body = self.definition.get("requestBody", NOT_SET)
217
- if extension is not None:
218
- self.merge_body = extension.get("merge_body", True)
219
-
220
- def set_data(self, case: Case, elapsed: float, **kwargs: Any) -> None:
221
- """Assign all linked definitions to the new case instance."""
222
- context = kwargs["context"]
223
- overrides = self.set_parameters(case, context)
224
- self.set_body(case, context, overrides)
225
- overrides_all_parameters = True
226
- if case.operation.body and "body" not in overrides.get("body", []):
227
- overrides_all_parameters = False
228
- if overrides_all_parameters:
229
- for parameter in case.operation.iter_parameters():
230
- if parameter.name not in overrides.get(parameter.location, []):
231
- overrides_all_parameters = False
232
- break
233
- case.set_source(
234
- context.response,
235
- context.case,
236
- elapsed,
237
- overrides_all_parameters,
238
- transition_id=TransitionId(
239
- name=self.name,
240
- status_code=self.status_code,
241
- ),
242
- )
60
+ extension = definition.get(SCHEMATHESIS_LINK_EXTENSION)
61
+ self.parameters = self._normalize_parameters(definition.get("parameters", {}))
62
+ self.body = definition.get("requestBody", NOT_SET)
63
+ self.merge_body = extension.get("merge_body", True) if extension else True
243
64
 
244
- def set_parameters(
245
- self, case: Case, context: expressions.ExpressionContext
246
- ) -> dict[Literal["path", "query", "header", "cookie", "body"], list[str]]:
247
- overrides: dict[Literal["path", "query", "header", "cookie", "body"], list[str]] = {}
248
- for location, name, expression in self.parameters:
249
- location, container = get_container(case, location, name)
250
- # Might happen if there is directly specified container,
251
- # but the schema has no parameters of such type at all.
252
- # Therefore the container is empty, otherwise it will be at least an empty object
253
- if container is None:
254
- message = f"No such parameter in `{case.operation.method.upper()} {case.operation.path}`: `{name}`."
255
- possibilities = [param.name for param in case.operation.iter_parameters()]
256
- matches = get_close_matches(name, possibilities)
257
- if matches:
258
- message += f" Did you mean `{matches[0]}`?"
259
- raise ValueError(message)
260
- value = expressions.evaluate(expression, context)
261
- if value is not None:
262
- container[name] = value
263
- overrides.setdefault(location, []).append(name)
264
- return overrides
265
-
266
- def set_body(
267
- self,
268
- case: Case,
269
- context: expressions.ExpressionContext,
270
- overrides: dict[Literal["path", "query", "header", "cookie", "body"], list[str]],
271
- ) -> None:
272
- if self.body is not NOT_SET:
273
- evaluated = expressions.evaluate(self.body, context, evaluate_nested=True)
274
- overrides["body"] = ["body"]
275
- if self.merge_body:
276
- case.body = merge_body(case.body, evaluated)
277
- else:
278
- case.body = evaluated
279
-
280
- def get_target_operation(self) -> APIOperation:
281
- if "operationId" in self.definition:
282
- return self.operation.schema.get_operation_by_id(self.definition["operationId"]) # type: ignore
283
- return self.operation.schema.get_operation_by_reference(self.definition["operationRef"]) # type: ignore
284
-
285
-
286
- def merge_body(old: Any, new: Any) -> Any:
287
- if isinstance(old, dict) and isinstance(new, dict):
288
- return {**old, **new}
289
- return new
290
-
291
-
292
- def get_container(
293
- case: Case, location: Literal["path", "query", "header", "cookie", "body"] | None, name: str
294
- ) -> tuple[Literal["path", "query", "header", "cookie", "body"], dict[str, Any] | None]:
295
- """Get a container that suppose to store the given parameter."""
296
- if location:
297
- container_name = LOCATION_TO_CONTAINER[location]
298
- else:
299
- for param in case.operation.iter_parameters():
65
+ self._cached_extract = lru_cache(8)(self._extract_impl)
66
+
67
+ def _normalize_parameters(self, parameters: dict[str, str]) -> list[NormalizedParameter]:
68
+ """Process link parameters and resolve their container locations.
69
+
70
+ Handles both explicit locations (e.g., "path.id") and implicit ones resolved from target operation.
71
+ """
72
+ result = []
73
+ for parameter, expression in parameters.items():
74
+ location: ParameterLocation | None
75
+ try:
76
+ # The parameter name is prefixed with its location. Example: `path.id`
77
+ _location, name = tuple(parameter.split("."))
78
+ location = cast(ParameterLocation, _location)
79
+ except ValueError:
80
+ location = None
81
+ name = parameter
82
+
83
+ container_name = self._get_parameter_container(location, name)
84
+ result.append(NormalizedParameter(location, name, expression, container_name))
85
+ return result
86
+
87
+ def _get_parameter_container(self, location: ParameterLocation | None, name: str) -> str:
88
+ """Resolve parameter container either from explicit location or by looking up in target operation."""
89
+ if location:
90
+ return LOCATION_TO_CONTAINER[location]
91
+
92
+ for param in self.target.iter_parameters():
300
93
  if param.name == name:
301
- location = param.location
302
- container_name = LOCATION_TO_CONTAINER[param.location]
303
- break
304
- else:
305
- raise ValueError(f"Parameter `{name}` is not defined in API operation `{case.operation.verbose_name}`")
306
- return location, getattr(case, container_name)
94
+ return LOCATION_TO_CONTAINER[param.location]
95
+ raise ValueError(f"Parameter `{name}` is not defined in API operation `{self.target.label}`")
96
+
97
+ def extract(self, output: StepOutput) -> Transition:
98
+ return self._cached_extract(StepOutputWrapper(output))
99
+
100
+ def _extract_impl(self, wrapper: StepOutputWrapper) -> Transition:
101
+ output = wrapper.output
102
+ return Transition(
103
+ id=f"{self.source.label} - {self.status_code} - {self.name}",
104
+ parent_id=output.case.id,
105
+ parameters=self.extract_parameters(output),
106
+ request_body=self.extract_body(output),
107
+ )
108
+
109
+ def extract_parameters(self, output: StepOutput) -> dict[str, dict[str, ExtractedParam]]:
110
+ """Extract parameters using runtime expressions.
111
+
112
+ Returns a two-level dictionary: container -> parameter name -> extracted value
113
+ """
114
+ extracted: dict[str, dict[str, ExtractedParam]] = {}
115
+ for parameter in self.parameters:
116
+ container = extracted.setdefault(parameter.container_name, {})
117
+ value: Result[Any, Exception]
118
+ try:
119
+ value = Ok(expressions.evaluate(parameter.expression, output))
120
+ except Exception as exc:
121
+ value = Err(exc)
122
+ container[parameter.name] = ExtractedParam(definition=parameter.expression, value=value)
123
+ return extracted
124
+
125
+ def extract_body(self, output: StepOutput) -> ExtractedParam | None:
126
+ if not isinstance(self.body, NotSet):
127
+ value: Result[Any, Exception]
128
+ try:
129
+ value = Ok(expressions.evaluate(self.body, output, evaluate_nested=True))
130
+ except Exception as exc:
131
+ value = Err(exc)
132
+ return ExtractedParam(definition=self.body, value=value)
133
+ return None
134
+
135
+
136
+ @dataclass
137
+ class StepOutputWrapper:
138
+ """Wrapper for StepOutput that uses only case_id for hash-based caching."""
307
139
 
140
+ output: StepOutput
141
+ __slots__ = ("output",)
308
142
 
309
- def normalize_parameter(
310
- parameter: str, expression: str
311
- ) -> tuple[Literal["path", "query", "header", "cookie", "body"] | None, str, str]:
312
- """Normalize runtime expressions.
143
+ def __hash__(self) -> int:
144
+ return hash(self.output.case.id)
313
145
 
314
- Runtime expressions may have parameter names prefixed with their location - `path.id`.
315
- At the same time, parameters could be defined without a prefix - `id`.
316
- We need to normalize all parameters to the same form to simplify working with them.
317
- """
318
- try:
319
- # The parameter name is prefixed with its location. Example: `path.id`
320
- location, name = tuple(parameter.split("."))
321
- _location = cast(Literal["path", "query", "header", "cookie", "body"], location)
322
- return _location, name, expression
323
- except ValueError:
324
- return None, parameter, expression
146
+ def __eq__(self, other: object) -> bool:
147
+ assert isinstance(other, StepOutputWrapper)
148
+ return self.output.case.id == other.output.case.id
325
149
 
326
150
 
327
- def get_all_links(operation: APIOperation) -> Generator[tuple[str, OpenAPILink], None, None]:
151
+ def get_all_links(operation: APIOperation) -> Generator[tuple[str, OpenApiLink], None, None]:
328
152
  for status_code, definition in operation.definition.raw["responses"].items():
329
153
  definition = operation.schema.resolver.resolve_all(definition, RECURSION_DEPTH_LIMIT - 8) # type: ignore[attr-defined]
330
154
  for name, link_definition in definition.get(operation.schema.links_field, {}).items(): # type: ignore
331
- yield status_code, OpenAPILink(name, status_code, link_definition, operation)
155
+ yield status_code, OpenApiLink(name, status_code, link_definition, operation)
332
156
 
333
157
 
334
158
  StatusCode = Union[str, int]
@@ -2,6 +2,11 @@ from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING, Any, Collection
4
4
 
5
+ from schemathesis.transport import SerializationContext
6
+ from schemathesis.transport.asgi import ASGI_TRANSPORT
7
+ from schemathesis.transport.requests import REQUESTS_TRANSPORT
8
+ from schemathesis.transport.wsgi import WSGI_TRANSPORT
9
+
5
10
  if TYPE_CHECKING:
6
11
  from hypothesis import strategies as st
7
12
 
@@ -11,15 +16,12 @@ MEDIA_TYPES: dict[str, st.SearchStrategy[bytes]] = {}
11
16
 
12
17
  def register_media_type(name: str, strategy: st.SearchStrategy[bytes], *, aliases: Collection[str] = ()) -> None:
13
18
  """Register a strategy for the given media type."""
14
- from ...serializers import SerializerContext, register
15
-
16
- @register(name, aliases=aliases)
17
- class MediaTypeSerializer:
18
- def as_requests(self, context: SerializerContext, value: Any) -> dict[str, Any]:
19
- return {"data": value}
20
19
 
21
- def as_werkzeug(self, context: SerializerContext, value: Any) -> dict[str, Any]:
22
- return {"data": value}
20
+ @REQUESTS_TRANSPORT.serializer(name, *aliases)
21
+ @ASGI_TRANSPORT.serializer(name, *aliases)
22
+ @WSGI_TRANSPORT.serializer(name, *aliases)
23
+ def serialize(ctx: SerializationContext, value: Any) -> dict[str, Any]:
24
+ return {"data": value}
23
25
 
24
26
  MEDIA_TYPES[name] = strategy
25
27
  for alias in aliases:
@@ -27,8 +29,4 @@ def register_media_type(name: str, strategy: st.SearchStrategy[bytes], *, aliase
27
29
 
28
30
 
29
31
  def unregister_all() -> None:
30
- from ...serializers import unregister
31
-
32
- for media_type in MEDIA_TYPES:
33
- unregister(media_type)
34
32
  MEDIA_TYPES.clear()
@@ -13,7 +13,8 @@ from ..constants import ALL_KEYWORDS
13
13
  from .mutations import MutationContext
14
14
 
15
15
  if TYPE_CHECKING:
16
- from ....generation import GenerationConfig
16
+ from schemathesis.generation import GenerationConfig
17
+
17
18
  from .types import Draw, Schema
18
19
 
19
20
 
@@ -11,7 +11,8 @@ from hypothesis import reject
11
11
  from hypothesis import strategies as st
12
12
  from hypothesis.strategies._internal.featureflags import FeatureStrategy
13
13
 
14
- from ....internal.copy import fast_deepcopy
14
+ from schemathesis.core.transforms import deepclone
15
+
15
16
  from ..utils import get_type, is_header_location
16
17
  from .types import Draw, Schema
17
18
  from .utils import can_negate
@@ -111,7 +112,7 @@ class MutationContext:
111
112
  # Body can be of any type and does not have any specific type semantic.
112
113
  mutations = draw(ordered(get_mutations(draw, self.keywords)))
113
114
  # Deep copy all keywords to avoid modifying the original schema
114
- new_schema = fast_deepcopy(self.keywords)
115
+ new_schema = deepclone(self.keywords)
115
116
  enabled_mutations = draw(st.shared(FeatureStrategy(), key="mutations")) # type: ignore
116
117
  # Always apply at least one mutation, otherwise everything is rejected, and we'd like to avoid it
117
118
  # for performance reasons
@@ -4,12 +4,13 @@ import json
4
4
  from dataclasses import dataclass
5
5
  from typing import TYPE_CHECKING, Any, ClassVar, Iterable
6
6
 
7
- from ...exceptions import OperationSchemaError
8
- from ...parameters import Parameter
7
+ from schemathesis.core.errors import InvalidSchema
8
+ from schemathesis.schemas import Parameter
9
+
9
10
  from .converter import to_json_schema_recursive
10
11
 
11
12
  if TYPE_CHECKING:
12
- from ...models import APIOperation
13
+ from ...schemas import APIOperation
13
14
 
14
15
 
15
16
  @dataclass(eq=False)
@@ -22,6 +23,7 @@ class OpenAPIParameter(Parameter):
22
23
  supported_jsonschema_keywords: ClassVar[tuple[str, ...]]
23
24
 
24
25
  def _repr_pretty_(self, *args: Any, **kwargs: Any) -> None: ...
26
+
25
27
  @property
26
28
  def description(self) -> str | None:
27
29
  """A brief parameter description."""
@@ -359,7 +361,7 @@ MISSING_SCHEMA_OR_CONTENT_MESSAGE = (
359
361
  )
360
362
 
361
363
  INVALID_SCHEMA_MESSAGE = (
362
- 'Can not generate data for {location} parameter "{name}"! ' "Its schema should be an object, got {schema}"
364
+ 'Can not generate data for {location} parameter "{name}"! Its schema should be an object, got {schema}'
363
365
  )
364
366
 
365
367
 
@@ -368,7 +370,7 @@ def get_parameter_schema(operation: APIOperation, data: dict[str, Any]) -> dict[
368
370
  # In Open API 3.0, there could be "schema" or "content" field. They are mutually exclusive.
369
371
  if "schema" in data:
370
372
  if not isinstance(data["schema"], dict):
371
- raise OperationSchemaError(
373
+ raise InvalidSchema(
372
374
  INVALID_SCHEMA_MESSAGE.format(
373
375
  location=data.get("in", ""), name=data.get("name", "<UNKNOWN>"), schema=data["schema"]
374
376
  ),
@@ -382,7 +384,7 @@ def get_parameter_schema(operation: APIOperation, data: dict[str, Any]) -> dict[
382
384
  try:
383
385
  content = data["content"]
384
386
  except KeyError as exc:
385
- raise OperationSchemaError(
387
+ raise InvalidSchema(
386
388
  MISSING_SCHEMA_OR_CONTENT_MESSAGE.format(location=data.get("in", ""), name=data.get("name", "<UNKNOWN>")),
387
389
  path=operation.path,
388
390
  method=operation.method,
@@ -21,7 +21,7 @@ IN = sre.IN
21
21
  MAXREPEAT = sre_parse.MAXREPEAT
22
22
 
23
23
 
24
- @lru_cache()
24
+ @lru_cache
25
25
  def update_quantifier(pattern: str, min_length: int | None, max_length: int | None) -> str:
26
26
  """Update the quantifier of a regular expression based on given min and max lengths."""
27
27
  if not pattern or (min_length in (None, 0) and max_length is None):