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
@@ -0,0 +1,232 @@
1
+ """Dependency detection between API operations for stateful testing.
2
+
3
+ Infers which operations must run before others by tracking resource creation and consumption across API operations.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from schemathesis.core import NOT_SET
11
+ from schemathesis.core.compat import RefResolutionError
12
+ from schemathesis.core.errors import InvalidSchema
13
+ from schemathesis.core.result import Ok
14
+ from schemathesis.specs.openapi.stateful.dependencies.inputs import (
15
+ extract_inputs,
16
+ merge_related_resources,
17
+ update_input_field_bindings,
18
+ )
19
+ from schemathesis.specs.openapi.stateful.dependencies.models import (
20
+ CanonicalizationCache,
21
+ Cardinality,
22
+ DefinitionSource,
23
+ DependencyGraph,
24
+ InputSlot,
25
+ NormalizedLink,
26
+ OperationMap,
27
+ OperationNode,
28
+ OutputSlot,
29
+ ResourceDefinition,
30
+ ResourceMap,
31
+ )
32
+ from schemathesis.specs.openapi.stateful.dependencies.outputs import extract_outputs
33
+ from schemathesis.specs.openapi.stateful.dependencies.resources import remove_unused_resources
34
+
35
+ if TYPE_CHECKING:
36
+ from schemathesis.schemas import APIOperation
37
+ from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
38
+
39
+ __all__ = [
40
+ "analyze",
41
+ "inject_links",
42
+ "DependencyGraph",
43
+ "InputSlot",
44
+ "OutputSlot",
45
+ "Cardinality",
46
+ "ResourceDefinition",
47
+ "DefinitionSource",
48
+ ]
49
+
50
+
51
+ def analyze(schema: BaseOpenAPISchema) -> DependencyGraph:
52
+ """Build a dependency graph by inferring resource producers and consumers from API operations."""
53
+ operations: OperationMap = {}
54
+ resources: ResourceMap = {}
55
+ # Track resources that got upgraded (e.g., from parameter inference to schema definition)
56
+ # to propagate better field information to existing input slots
57
+ updated_resources: set[str] = set()
58
+ # Cache for expensive canonicalize() calls - same schemas are often processed multiple times
59
+ canonicalization_cache: CanonicalizationCache = {}
60
+
61
+ for result in schema.get_all_operations():
62
+ if isinstance(result, Ok):
63
+ operation = result.ok()
64
+ try:
65
+ inputs = extract_inputs(
66
+ operation=operation,
67
+ resources=resources,
68
+ updated_resources=updated_resources,
69
+ resolver=schema.resolver,
70
+ canonicalization_cache=canonicalization_cache,
71
+ )
72
+ outputs = extract_outputs(
73
+ operation=operation,
74
+ resources=resources,
75
+ updated_resources=updated_resources,
76
+ resolver=schema.resolver,
77
+ canonicalization_cache=canonicalization_cache,
78
+ )
79
+ operations[operation.label] = OperationNode(
80
+ method=operation.method,
81
+ path=operation.path,
82
+ inputs=list(inputs),
83
+ outputs=list(outputs),
84
+ )
85
+ except RefResolutionError:
86
+ # Skip operations with unresolvable $refs (e.g., unavailable external references or references with typos)
87
+ # These won't participate in dependency detection
88
+ continue
89
+
90
+ # Update input slots with improved resource definitions discovered during extraction
91
+ #
92
+ # Example:
93
+ # - `DELETE /users/{userId}` initially inferred `User.fields=["userId"]`
94
+ # - then `POST /users` response revealed `User.fields=["id", "email"]`
95
+ for resource in updated_resources:
96
+ update_input_field_bindings(resource, operations)
97
+
98
+ # Merge parameter-inferred resources with schema-defined ones
99
+ merge_related_resources(operations, resources)
100
+
101
+ # Clean up orphaned resources
102
+ remove_unused_resources(operations, resources)
103
+
104
+ return DependencyGraph(operations=operations, resources=resources)
105
+
106
+
107
+ def inject_links(schema: BaseOpenAPISchema) -> int:
108
+ graph = analyze(schema)
109
+ return _inject_links(schema, graph)
110
+
111
+
112
+ def _inject_links(schema: BaseOpenAPISchema, graph: DependencyGraph) -> int:
113
+ injected = 0
114
+ for response_links in graph.iter_links():
115
+ operation = schema.find_operation_by_reference(response_links.producer_operation_ref)
116
+ response = operation.responses.get(response_links.status_code)
117
+ links = response.definition.setdefault(schema.adapter.links_keyword, {})
118
+
119
+ # Normalize existing links once
120
+ if links:
121
+ normalized_existing = [_normalize_link(link, schema) for link in links.values()]
122
+ else:
123
+ normalized_existing = []
124
+
125
+ for link_name, definition in response_links.links.items():
126
+ inferred_link = definition.to_openapi()
127
+
128
+ # Check if duplicate / subsets exists
129
+ if normalized_existing:
130
+ normalized = _normalize_link(inferred_link, schema)
131
+ if any(_is_subset_link(normalized, existing) for existing in normalized_existing):
132
+ continue
133
+
134
+ # Find unique name if collision exists
135
+ final_name = _resolve_link_name_collision(link_name, links)
136
+ links[final_name] = inferred_link
137
+ injected += 1
138
+ return injected
139
+
140
+
141
+ def _normalize_link(link: dict[str, Any], schema: BaseOpenAPISchema) -> NormalizedLink:
142
+ """Normalize a link definition for comparison."""
143
+ operation = _resolve_link_operation(link, schema)
144
+
145
+ normalized_params = _normalize_parameter_keys(link.get("parameters", {}), operation)
146
+
147
+ return NormalizedLink(
148
+ path=operation.path,
149
+ method=operation.method,
150
+ parameters=normalized_params,
151
+ request_body=link.get("requestBody", {}),
152
+ )
153
+
154
+
155
+ def _normalize_parameter_keys(parameters: dict, operation: APIOperation) -> set[str]:
156
+ """Normalize parameter keys to location.name format."""
157
+ normalized = set()
158
+
159
+ for parameter_name in parameters.keys():
160
+ # If already has location prefix, use as-is
161
+ if "." in parameter_name:
162
+ normalized.add(parameter_name)
163
+ continue
164
+
165
+ # Find the parameter and prepend location
166
+ for parameter in operation.iter_parameters():
167
+ if parameter.name == parameter_name:
168
+ normalized.add(f"{parameter.location.value}.{parameter_name}")
169
+ break
170
+
171
+ return normalized
172
+
173
+
174
+ def _resolve_link_operation(link: dict, schema: BaseOpenAPISchema) -> APIOperation:
175
+ """Resolve link to operation."""
176
+ if "operationRef" in link:
177
+ return schema.find_operation_by_reference(link["operationRef"])
178
+ if "operationId" in link:
179
+ return schema.find_operation_by_id(link["operationId"])
180
+ raise InvalidSchema(
181
+ "Link definition is missing both 'operationRef' and 'operationId'. "
182
+ "At least one of these fields must be present to identify the target operation."
183
+ )
184
+
185
+
186
+ def _resolve_link_name_collision(proposed_name: str, existing_links: dict[str, Any]) -> str:
187
+ """Find unique link name if collision exists."""
188
+ if proposed_name not in existing_links:
189
+ return proposed_name
190
+
191
+ suffix = 0
192
+ while True:
193
+ candidate = f"{proposed_name}_{suffix}"
194
+ if candidate not in existing_links:
195
+ return candidate
196
+ suffix += 1
197
+
198
+
199
+ def _is_subset_link(inferred: NormalizedLink, existing: NormalizedLink) -> bool:
200
+ """Check if inferred link is a subset of existing link."""
201
+ # Must target the same operation
202
+ if inferred.path != existing.path or inferred.method != existing.method:
203
+ return False
204
+
205
+ # Inferred parameters must be subset of existing parameters
206
+ if not inferred.parameters.issubset(existing.parameters):
207
+ return False
208
+
209
+ # Inferred request body must be subset of existing body
210
+ return _is_request_body_subset(inferred.request_body, existing.request_body)
211
+
212
+
213
+ def _is_request_body_subset(inferred_body: Any, existing_body: Any) -> bool:
214
+ """Check if inferred body is a subset of existing body."""
215
+ # Empty inferred body is always a subset
216
+ if not inferred_body:
217
+ return True
218
+
219
+ # If existing is empty but inferred isn't, not a subset
220
+ if not existing_body:
221
+ return False
222
+
223
+ # Both must be dicts for subset comparison, otherwise check for equality
224
+ if not isinstance(inferred_body, dict) or not isinstance(existing_body, dict):
225
+ return inferred_body == existing_body
226
+
227
+ # Check if all inferred fields exist in existing with same values
228
+ for key, value in inferred_body.items():
229
+ if existing_body.get(key, NOT_SET) != value:
230
+ return False
231
+
232
+ return True
@@ -0,0 +1,428 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Iterator
4
+
5
+ from schemathesis.core import media_types
6
+ from schemathesis.core.errors import MalformedMediaType
7
+ from schemathesis.core.jsonschema.bundler import BUNDLE_STORAGE_KEY
8
+ from schemathesis.core.jsonschema.types import get_type
9
+ from schemathesis.core.parameters import ParameterLocation
10
+ from schemathesis.specs.openapi.adapter.parameters import resource_name_from_ref
11
+ from schemathesis.specs.openapi.stateful.dependencies import naming
12
+ from schemathesis.specs.openapi.stateful.dependencies.models import (
13
+ CanonicalizationCache,
14
+ DefinitionSource,
15
+ InputSlot,
16
+ OperationMap,
17
+ OutputSlot,
18
+ ResourceDefinition,
19
+ ResourceMap,
20
+ )
21
+ from schemathesis.specs.openapi.stateful.dependencies.resources import extract_resources_from_responses
22
+
23
+ if TYPE_CHECKING:
24
+ from schemathesis.core.compat import RefResolver
25
+ from schemathesis.specs.openapi.adapter.parameters import OpenApiBody
26
+ from schemathesis.specs.openapi.schemas import APIOperation
27
+
28
+
29
+ def extract_inputs(
30
+ *,
31
+ operation: APIOperation,
32
+ resources: ResourceMap,
33
+ updated_resources: set[str],
34
+ resolver: RefResolver,
35
+ canonicalization_cache: CanonicalizationCache,
36
+ ) -> Iterator[InputSlot]:
37
+ """Extract resource dependencies for an API operation from its input parameters.
38
+
39
+ Connects each parameter (e.g., `userId`) to its resource definition (`User`),
40
+ creating placeholder resources if not yet discovered from their schemas.
41
+ """
42
+ known_dependencies = set()
43
+ for param in operation.iter_parameters():
44
+ input_slot = _resolve_parameter_dependency(
45
+ parameter_name=param.name,
46
+ parameter_location=param.location,
47
+ operation=operation,
48
+ resources=resources,
49
+ updated_resources=updated_resources,
50
+ resolver=resolver,
51
+ canonicalization_cache=canonicalization_cache,
52
+ )
53
+ if input_slot is not None:
54
+ if input_slot.resource.source >= DefinitionSource.SCHEMA_WITH_PROPERTIES:
55
+ known_dependencies.add(input_slot.resource.name)
56
+ yield input_slot
57
+
58
+ for body in operation.body:
59
+ try:
60
+ if media_types.is_json(body.media_type):
61
+ yield from _resolve_body_dependencies(
62
+ body=body, operation=operation, resources=resources, known_dependencies=known_dependencies
63
+ )
64
+ except MalformedMediaType:
65
+ continue
66
+
67
+
68
+ def _resolve_parameter_dependency(
69
+ *,
70
+ parameter_name: str,
71
+ parameter_location: ParameterLocation,
72
+ operation: APIOperation,
73
+ resources: ResourceMap,
74
+ updated_resources: set[str],
75
+ resolver: RefResolver,
76
+ canonicalization_cache: CanonicalizationCache,
77
+ ) -> InputSlot | None:
78
+ """Connect a parameter to its resource definition, creating placeholder if needed.
79
+
80
+ Strategy:
81
+ 1. Infer resource name from parameter (`userId` -> `User`)
82
+ 2. Use existing resource if high-quality definition exists
83
+ 3. Try discovering from operation's response schemas
84
+ 4. Fall back to creating placeholder with a single field
85
+ """
86
+ resource_name = naming.from_parameter(parameter=parameter_name, path=operation.path)
87
+
88
+ if resource_name is None:
89
+ return None
90
+
91
+ resource = resources.get(resource_name)
92
+
93
+ # Upgrade low-quality resource definitions (e.g., from parameter inference)
94
+ # by searching this operation's responses for actual schema
95
+ if resource is None or resource.source < DefinitionSource.SCHEMA_WITH_PROPERTIES:
96
+ resource = _find_resource_in_responses(
97
+ operation=operation,
98
+ resource_name=resource_name,
99
+ resources=resources,
100
+ updated_resources=updated_resources,
101
+ resolver=resolver,
102
+ canonicalization_cache=canonicalization_cache,
103
+ )
104
+ if resource is not None:
105
+ resources[resource_name] = resource
106
+
107
+ # Determine resource and its field
108
+ if resource is None:
109
+ # No schema found - create placeholder resource with inferred field
110
+ #
111
+ # Example: `DELETE /users/{userId}` with no response body -> `User` resource with "userId" field
112
+ #
113
+ # Later operations with schemas will upgrade this placeholder
114
+ if resource_name in resources:
115
+ # Resource exists but was empty - update with parameter field
116
+ resources[resource_name].fields = [parameter_name]
117
+ resources[resource_name].source = DefinitionSource.PARAMETER_INFERENCE
118
+ updated_resources.add(resource_name)
119
+ resource = resources[resource_name]
120
+ else:
121
+ resource = ResourceDefinition.inferred_from_parameter(
122
+ name=resource_name,
123
+ parameter_name=parameter_name,
124
+ )
125
+ resources[resource_name] = resource
126
+ field = parameter_name
127
+ else:
128
+ # Match parameter to resource field (`userId` → `id`, `Id` → `ChannelId`, etc.)
129
+ field = (
130
+ naming.find_matching_field(
131
+ parameter=parameter_name,
132
+ resource=resource_name,
133
+ fields=resource.fields,
134
+ )
135
+ or "id"
136
+ )
137
+
138
+ return InputSlot(
139
+ resource=resource,
140
+ resource_field=field,
141
+ parameter_name=parameter_name,
142
+ parameter_location=parameter_location,
143
+ )
144
+
145
+
146
+ def _find_resource_in_responses(
147
+ *,
148
+ operation: APIOperation,
149
+ resource_name: str,
150
+ resources: ResourceMap,
151
+ updated_resources: set[str],
152
+ resolver: RefResolver,
153
+ canonicalization_cache: CanonicalizationCache,
154
+ ) -> ResourceDefinition | None:
155
+ """Search operation's successful responses for a specific resource definition.
156
+
157
+ Used when a parameter references a resource not yet discovered. Scans this
158
+ operation's response schemas hoping to find the resource definition.
159
+ """
160
+ for _, extracted in extract_resources_from_responses(
161
+ operation=operation,
162
+ resources=resources,
163
+ updated_resources=updated_resources,
164
+ resolver=resolver,
165
+ canonicalization_cache=canonicalization_cache,
166
+ ):
167
+ if extracted.resource.name == resource_name:
168
+ return extracted.resource
169
+
170
+ return None
171
+
172
+
173
+ GENERIC_FIELD_NAMES = frozenset(
174
+ {
175
+ "body",
176
+ "text",
177
+ "content",
178
+ "message",
179
+ "description",
180
+ }
181
+ )
182
+
183
+
184
+ def _maybe_resolve_bundled(root: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]:
185
+ # Right now, the body schema comes bundled to dependency analysis
186
+ if BUNDLE_STORAGE_KEY in root and "$ref" in schema:
187
+ key = schema["$ref"].split("/")[-1]
188
+ return root[BUNDLE_STORAGE_KEY][key]
189
+ return schema
190
+
191
+
192
+ def _resolve_body_dependencies(
193
+ *,
194
+ body: OpenApiBody,
195
+ operation: APIOperation,
196
+ resources: ResourceMap,
197
+ known_dependencies: set[str],
198
+ ) -> Iterator[InputSlot]:
199
+ schema = body.raw_schema
200
+ if not isinstance(schema, dict):
201
+ return
202
+
203
+ resolved = _maybe_resolve_bundled(schema, schema)
204
+
205
+ # For `items`, we'll inject an array with extracted resource
206
+ items = resolved.get("items", {})
207
+ if items is not None:
208
+ resource_name = naming.from_path(operation.path)
209
+
210
+ if "$ref" in items:
211
+ schema_key = items["$ref"].split("/")[-1]
212
+ original_ref = body.name_to_uri[schema_key]
213
+ resource_name = resource_name_from_ref(original_ref)
214
+ resource = resources.get(resource_name)
215
+ if resource is None:
216
+ resource = ResourceDefinition.inferred_from_parameter(name=resource_name, parameter_name=None)
217
+ resources[resource_name] = resource
218
+ field = None
219
+ else:
220
+ field = None
221
+ yield InputSlot(
222
+ resource=resource,
223
+ resource_field=field,
224
+ parameter_name=0,
225
+ parameter_location=ParameterLocation.BODY,
226
+ )
227
+
228
+ # Inspect each property that could be a part of some other resource
229
+ properties = resolved.get("properties", {})
230
+ required = resolved.get("required", [])
231
+ path = operation.path
232
+ for property_name, subschema in properties.items():
233
+ resource_name = naming.from_parameter(property_name, path)
234
+ if resource_name is not None:
235
+ resource = resources.get(resource_name)
236
+ if resource is None:
237
+ resource = ResourceDefinition.inferred_from_parameter(
238
+ name=resource_name,
239
+ parameter_name=property_name,
240
+ )
241
+ resources[resource_name] = resource
242
+ field = property_name
243
+ else:
244
+ field = (
245
+ naming.find_matching_field(
246
+ parameter=property_name,
247
+ resource=resource_name,
248
+ fields=resource.fields,
249
+ )
250
+ or "id"
251
+ )
252
+ yield InputSlot(
253
+ resource=resource,
254
+ resource_field=field,
255
+ parameter_name=property_name,
256
+ parameter_location=ParameterLocation.BODY,
257
+ )
258
+ continue
259
+
260
+ # Skip generic property names & optional fields (at least for now)
261
+ if property_name in GENERIC_FIELD_NAMES or property_name not in required:
262
+ continue
263
+
264
+ # Find candidate resources among known dependencies that actually have this field
265
+ candidates = [
266
+ resources[dep] for dep in known_dependencies if dep in resources and property_name in resources[dep].fields
267
+ ]
268
+
269
+ # Skip ambiguous cases when multiple resources have same field name
270
+ if len(candidates) != 1:
271
+ continue
272
+
273
+ resource = candidates[0]
274
+ # Ensure the target field supports the same type
275
+ if not resource.types[property_name] & set(get_type(subschema)):
276
+ continue
277
+
278
+ yield InputSlot(
279
+ resource=resource,
280
+ resource_field=property_name,
281
+ parameter_name=property_name,
282
+ parameter_location=ParameterLocation.BODY,
283
+ )
284
+
285
+
286
+ def update_input_field_bindings(resource_name: str, operations: OperationMap) -> None:
287
+ """Update input slots field bindings after resource definition was upgraded.
288
+
289
+ When a resource's fields change (e.g., `User` upgraded from `["userId"]` to `["id", "email"]`),
290
+ existing input slots may reference stale field names. This re-evaluates field matching
291
+ for all operations using this resource.
292
+
293
+ Example:
294
+ `DELETE /users/{userId}` created `InputSlot(resource_field="userId")`
295
+ `POST /users` revealed actual fields `["id", "email"]`
296
+ This updates DELETE's `InputSlot` to use `resource_field="id"`
297
+
298
+ """
299
+ # Re-evaluate field matching for all operations referencing this resource
300
+ for operation in operations.values():
301
+ for input_slot in operation.inputs:
302
+ # Skip inputs not using this resource
303
+ if input_slot.resource.name != resource_name or isinstance(input_slot.parameter_name, int):
304
+ continue
305
+
306
+ # Re-match parameter to upgraded resource fields
307
+ new_field = naming.find_matching_field(
308
+ parameter=input_slot.parameter_name,
309
+ resource=resource_name,
310
+ fields=input_slot.resource.fields,
311
+ )
312
+ if new_field is not None:
313
+ input_slot.resource_field = new_field
314
+
315
+
316
+ def merge_related_resources(operations: OperationMap, resources: ResourceMap) -> None:
317
+ """Merge parameter-inferred resources with schema-defined resources from related operations."""
318
+ candidates = find_producer_consumer_candidates(operations)
319
+
320
+ for producer_name, consumer_name in candidates:
321
+ producer = operations[producer_name]
322
+ consumer = operations[consumer_name]
323
+
324
+ # Try to upgrade each input slot
325
+ for input_slot in consumer.inputs:
326
+ result = try_merge_input_resource(input_slot, producer.outputs, resources)
327
+
328
+ if result is not None:
329
+ new_resource_name, new_field_name = result
330
+ # Update input slot to use the better resource definition
331
+ input_slot.resource = resources[new_resource_name]
332
+ input_slot.resource_field = new_field_name
333
+
334
+
335
+ def try_merge_input_resource(
336
+ input_slot: InputSlot,
337
+ producer_outputs: list[OutputSlot],
338
+ resources: ResourceMap,
339
+ ) -> tuple[str, str] | None:
340
+ """Try to upgrade an input's resource to a producer's resource."""
341
+ consumer_resource = input_slot.resource
342
+
343
+ # Only upgrade parameter-inferred resources (low confidence)
344
+ if consumer_resource.source != DefinitionSource.PARAMETER_INFERENCE:
345
+ return None
346
+
347
+ # Try each producer output
348
+ for output in producer_outputs:
349
+ producer_resource = resources[output.resource.name]
350
+
351
+ # Only merge to schema-defined resources (high confidence)
352
+ if producer_resource.source != DefinitionSource.SCHEMA_WITH_PROPERTIES:
353
+ continue
354
+
355
+ # Try to match the input parameter to producer's fields
356
+ param_name = input_slot.parameter_name
357
+ if not isinstance(param_name, str):
358
+ continue
359
+
360
+ for resource_name in (input_slot.resource.name, producer_resource.name):
361
+ matched_field = naming.find_matching_field(
362
+ parameter=param_name,
363
+ resource=resource_name,
364
+ fields=producer_resource.fields,
365
+ )
366
+
367
+ if matched_field is not None:
368
+ return (producer_resource.name, matched_field)
369
+
370
+ return None
371
+
372
+
373
+ def find_producer_consumer_candidates(operations: OperationMap) -> list[tuple[str, str]]:
374
+ """Find operation pairs that might produce/consume the same resource via REST patterns."""
375
+ candidates = []
376
+
377
+ # Group by base path to reduce comparisons
378
+ paths: dict[str, list[str]] = {}
379
+ for name, node in operations.items():
380
+ base = _extract_base_path(node.path)
381
+ paths.setdefault(base, []).append(name)
382
+
383
+ # Within each path group, find POST/PUT → GET/DELETE/PATCH patterns
384
+ for names in paths.values():
385
+ for producer_name in names:
386
+ producer = operations[producer_name]
387
+ # Producer must create/update and return data
388
+ if producer.method not in ("post", "put") or not producer.outputs:
389
+ continue
390
+
391
+ for consumer_name in names:
392
+ consumer = operations[consumer_name]
393
+ # Consumer must have path parameters
394
+ if not consumer.inputs:
395
+ continue
396
+ # Paths must be related (collection + item pattern)
397
+ if _is_collection_item_pattern(producer.path, consumer.path):
398
+ candidates.append((producer_name, consumer_name))
399
+
400
+ return candidates
401
+
402
+
403
+ def _extract_base_path(path: str) -> str:
404
+ """Extract collection path: /blog/posts/{id} -> /blog/posts."""
405
+ parts = [p for p in path.split("/") if not p.startswith("{")]
406
+ return "/".join(parts).rstrip("/")
407
+
408
+
409
+ def _is_collection_item_pattern(collection_path: str, item_path: str) -> bool:
410
+ """Check if paths follow REST collection/item pattern."""
411
+ # /blog/posts + /blog/posts/{postId}
412
+ normalized_collection = collection_path.rstrip("/")
413
+ normalized_item = item_path.rstrip("/")
414
+
415
+ # Must start with collection path
416
+ if not normalized_item.startswith(normalized_collection + "/"):
417
+ return False
418
+
419
+ # Extract the segment after collection path
420
+ remainder = normalized_item[len(normalized_collection) + 1 :]
421
+
422
+ # Must be a single path parameter: {paramName} with no slashes
423
+ return (
424
+ remainder.startswith("{")
425
+ and remainder.endswith("}")
426
+ and len(remainder) > 2 # Not empty {}
427
+ and "/" not in remainder
428
+ )