schemathesis 3.15.4__py3-none-any.whl → 4.4.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. schemathesis/__init__.py +53 -25
  2. schemathesis/auths.py +507 -0
  3. schemathesis/checks.py +190 -25
  4. schemathesis/cli/__init__.py +27 -1219
  5. schemathesis/cli/__main__.py +4 -0
  6. schemathesis/cli/commands/__init__.py +133 -0
  7. schemathesis/cli/commands/data.py +10 -0
  8. schemathesis/cli/commands/run/__init__.py +602 -0
  9. schemathesis/cli/commands/run/context.py +228 -0
  10. schemathesis/cli/commands/run/events.py +60 -0
  11. schemathesis/cli/commands/run/executor.py +157 -0
  12. schemathesis/cli/commands/run/filters.py +53 -0
  13. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  14. schemathesis/cli/commands/run/handlers/base.py +45 -0
  15. schemathesis/cli/commands/run/handlers/cassettes.py +464 -0
  16. schemathesis/cli/commands/run/handlers/junitxml.py +60 -0
  17. schemathesis/cli/commands/run/handlers/output.py +1750 -0
  18. schemathesis/cli/commands/run/loaders.py +118 -0
  19. schemathesis/cli/commands/run/validation.py +256 -0
  20. schemathesis/cli/constants.py +5 -0
  21. schemathesis/cli/core.py +19 -0
  22. schemathesis/cli/ext/fs.py +16 -0
  23. schemathesis/cli/ext/groups.py +203 -0
  24. schemathesis/cli/ext/options.py +81 -0
  25. schemathesis/config/__init__.py +202 -0
  26. schemathesis/config/_auth.py +51 -0
  27. schemathesis/config/_checks.py +268 -0
  28. schemathesis/config/_diff_base.py +101 -0
  29. schemathesis/config/_env.py +21 -0
  30. schemathesis/config/_error.py +163 -0
  31. schemathesis/config/_generation.py +157 -0
  32. schemathesis/config/_health_check.py +24 -0
  33. schemathesis/config/_operations.py +335 -0
  34. schemathesis/config/_output.py +171 -0
  35. schemathesis/config/_parameters.py +19 -0
  36. schemathesis/config/_phases.py +253 -0
  37. schemathesis/config/_projects.py +543 -0
  38. schemathesis/config/_rate_limit.py +17 -0
  39. schemathesis/config/_report.py +120 -0
  40. schemathesis/config/_validator.py +9 -0
  41. schemathesis/config/_warnings.py +89 -0
  42. schemathesis/config/schema.json +975 -0
  43. schemathesis/core/__init__.py +72 -0
  44. schemathesis/core/adapter.py +34 -0
  45. schemathesis/core/compat.py +32 -0
  46. schemathesis/core/control.py +2 -0
  47. schemathesis/core/curl.py +100 -0
  48. schemathesis/core/deserialization.py +210 -0
  49. schemathesis/core/errors.py +588 -0
  50. schemathesis/core/failures.py +316 -0
  51. schemathesis/core/fs.py +19 -0
  52. schemathesis/core/hooks.py +20 -0
  53. schemathesis/core/jsonschema/__init__.py +13 -0
  54. schemathesis/core/jsonschema/bundler.py +183 -0
  55. schemathesis/core/jsonschema/keywords.py +40 -0
  56. schemathesis/core/jsonschema/references.py +222 -0
  57. schemathesis/core/jsonschema/types.py +41 -0
  58. schemathesis/core/lazy_import.py +15 -0
  59. schemathesis/core/loaders.py +107 -0
  60. schemathesis/core/marks.py +66 -0
  61. schemathesis/core/media_types.py +79 -0
  62. schemathesis/core/output/__init__.py +46 -0
  63. schemathesis/core/output/sanitization.py +54 -0
  64. schemathesis/core/parameters.py +45 -0
  65. schemathesis/core/rate_limit.py +60 -0
  66. schemathesis/core/registries.py +34 -0
  67. schemathesis/core/result.py +27 -0
  68. schemathesis/core/schema_analysis.py +17 -0
  69. schemathesis/core/shell.py +203 -0
  70. schemathesis/core/transforms.py +144 -0
  71. schemathesis/core/transport.py +223 -0
  72. schemathesis/core/validation.py +73 -0
  73. schemathesis/core/version.py +7 -0
  74. schemathesis/engine/__init__.py +28 -0
  75. schemathesis/engine/context.py +152 -0
  76. schemathesis/engine/control.py +44 -0
  77. schemathesis/engine/core.py +201 -0
  78. schemathesis/engine/errors.py +446 -0
  79. schemathesis/engine/events.py +284 -0
  80. schemathesis/engine/observations.py +42 -0
  81. schemathesis/engine/phases/__init__.py +108 -0
  82. schemathesis/engine/phases/analysis.py +28 -0
  83. schemathesis/engine/phases/probes.py +172 -0
  84. schemathesis/engine/phases/stateful/__init__.py +68 -0
  85. schemathesis/engine/phases/stateful/_executor.py +364 -0
  86. schemathesis/engine/phases/stateful/context.py +85 -0
  87. schemathesis/engine/phases/unit/__init__.py +220 -0
  88. schemathesis/engine/phases/unit/_executor.py +459 -0
  89. schemathesis/engine/phases/unit/_pool.py +82 -0
  90. schemathesis/engine/recorder.py +254 -0
  91. schemathesis/errors.py +47 -0
  92. schemathesis/filters.py +395 -0
  93. schemathesis/generation/__init__.py +25 -0
  94. schemathesis/generation/case.py +478 -0
  95. schemathesis/generation/coverage.py +1528 -0
  96. schemathesis/generation/hypothesis/__init__.py +121 -0
  97. schemathesis/generation/hypothesis/builder.py +992 -0
  98. schemathesis/generation/hypothesis/examples.py +56 -0
  99. schemathesis/generation/hypothesis/given.py +66 -0
  100. schemathesis/generation/hypothesis/reporting.py +285 -0
  101. schemathesis/generation/meta.py +227 -0
  102. schemathesis/generation/metrics.py +93 -0
  103. schemathesis/generation/modes.py +20 -0
  104. schemathesis/generation/overrides.py +127 -0
  105. schemathesis/generation/stateful/__init__.py +37 -0
  106. schemathesis/generation/stateful/state_machine.py +294 -0
  107. schemathesis/graphql/__init__.py +15 -0
  108. schemathesis/graphql/checks.py +109 -0
  109. schemathesis/graphql/loaders.py +285 -0
  110. schemathesis/hooks.py +270 -91
  111. schemathesis/openapi/__init__.py +13 -0
  112. schemathesis/openapi/checks.py +467 -0
  113. schemathesis/openapi/generation/__init__.py +0 -0
  114. schemathesis/openapi/generation/filters.py +72 -0
  115. schemathesis/openapi/loaders.py +315 -0
  116. schemathesis/pytest/__init__.py +5 -0
  117. schemathesis/pytest/control_flow.py +7 -0
  118. schemathesis/pytest/lazy.py +341 -0
  119. schemathesis/pytest/loaders.py +36 -0
  120. schemathesis/pytest/plugin.py +357 -0
  121. schemathesis/python/__init__.py +0 -0
  122. schemathesis/python/asgi.py +12 -0
  123. schemathesis/python/wsgi.py +12 -0
  124. schemathesis/schemas.py +682 -257
  125. schemathesis/specs/graphql/__init__.py +0 -1
  126. schemathesis/specs/graphql/nodes.py +26 -2
  127. schemathesis/specs/graphql/scalars.py +77 -12
  128. schemathesis/specs/graphql/schemas.py +367 -148
  129. schemathesis/specs/graphql/validation.py +33 -0
  130. schemathesis/specs/openapi/__init__.py +9 -1
  131. schemathesis/specs/openapi/_hypothesis.py +555 -318
  132. schemathesis/specs/openapi/adapter/__init__.py +10 -0
  133. schemathesis/specs/openapi/adapter/parameters.py +729 -0
  134. schemathesis/specs/openapi/adapter/protocol.py +59 -0
  135. schemathesis/specs/openapi/adapter/references.py +19 -0
  136. schemathesis/specs/openapi/adapter/responses.py +368 -0
  137. schemathesis/specs/openapi/adapter/security.py +144 -0
  138. schemathesis/specs/openapi/adapter/v2.py +30 -0
  139. schemathesis/specs/openapi/adapter/v3_0.py +30 -0
  140. schemathesis/specs/openapi/adapter/v3_1.py +30 -0
  141. schemathesis/specs/openapi/analysis.py +96 -0
  142. schemathesis/specs/openapi/checks.py +748 -82
  143. schemathesis/specs/openapi/converter.py +176 -37
  144. schemathesis/specs/openapi/definitions.py +599 -4
  145. schemathesis/specs/openapi/examples.py +581 -165
  146. schemathesis/specs/openapi/expressions/__init__.py +52 -5
  147. schemathesis/specs/openapi/expressions/extractors.py +25 -0
  148. schemathesis/specs/openapi/expressions/lexer.py +34 -31
  149. schemathesis/specs/openapi/expressions/nodes.py +97 -46
  150. schemathesis/specs/openapi/expressions/parser.py +35 -13
  151. schemathesis/specs/openapi/formats.py +122 -0
  152. schemathesis/specs/openapi/media_types.py +75 -0
  153. schemathesis/specs/openapi/negative/__init__.py +93 -73
  154. schemathesis/specs/openapi/negative/mutations.py +294 -103
  155. schemathesis/specs/openapi/negative/utils.py +0 -9
  156. schemathesis/specs/openapi/patterns.py +458 -0
  157. schemathesis/specs/openapi/references.py +60 -81
  158. schemathesis/specs/openapi/schemas.py +647 -666
  159. schemathesis/specs/openapi/serialization.py +53 -30
  160. schemathesis/specs/openapi/stateful/__init__.py +403 -68
  161. schemathesis/specs/openapi/stateful/control.py +87 -0
  162. schemathesis/specs/openapi/stateful/dependencies/__init__.py +232 -0
  163. schemathesis/specs/openapi/stateful/dependencies/inputs.py +428 -0
  164. schemathesis/specs/openapi/stateful/dependencies/models.py +341 -0
  165. schemathesis/specs/openapi/stateful/dependencies/naming.py +491 -0
  166. schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
  167. schemathesis/specs/openapi/stateful/dependencies/resources.py +339 -0
  168. schemathesis/specs/openapi/stateful/dependencies/schemas.py +447 -0
  169. schemathesis/specs/openapi/stateful/inference.py +254 -0
  170. schemathesis/specs/openapi/stateful/links.py +219 -78
  171. schemathesis/specs/openapi/types/__init__.py +3 -0
  172. schemathesis/specs/openapi/types/common.py +23 -0
  173. schemathesis/specs/openapi/types/v2.py +129 -0
  174. schemathesis/specs/openapi/types/v3.py +134 -0
  175. schemathesis/specs/openapi/utils.py +7 -6
  176. schemathesis/specs/openapi/warnings.py +75 -0
  177. schemathesis/transport/__init__.py +224 -0
  178. schemathesis/transport/asgi.py +26 -0
  179. schemathesis/transport/prepare.py +126 -0
  180. schemathesis/transport/requests.py +278 -0
  181. schemathesis/transport/serialization.py +329 -0
  182. schemathesis/transport/wsgi.py +175 -0
  183. schemathesis-4.4.2.dist-info/METADATA +213 -0
  184. schemathesis-4.4.2.dist-info/RECORD +192 -0
  185. {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info}/WHEEL +1 -1
  186. schemathesis-4.4.2.dist-info/entry_points.txt +6 -0
  187. {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info/licenses}/LICENSE +1 -1
  188. schemathesis/_compat.py +0 -57
  189. schemathesis/_hypothesis.py +0 -123
  190. schemathesis/auth.py +0 -214
  191. schemathesis/cli/callbacks.py +0 -240
  192. schemathesis/cli/cassettes.py +0 -351
  193. schemathesis/cli/context.py +0 -38
  194. schemathesis/cli/debug.py +0 -21
  195. schemathesis/cli/handlers.py +0 -11
  196. schemathesis/cli/junitxml.py +0 -41
  197. schemathesis/cli/options.py +0 -70
  198. schemathesis/cli/output/__init__.py +0 -1
  199. schemathesis/cli/output/default.py +0 -521
  200. schemathesis/cli/output/short.py +0 -40
  201. schemathesis/constants.py +0 -88
  202. schemathesis/exceptions.py +0 -257
  203. schemathesis/extra/_aiohttp.py +0 -27
  204. schemathesis/extra/_flask.py +0 -10
  205. schemathesis/extra/_server.py +0 -16
  206. schemathesis/extra/pytest_plugin.py +0 -251
  207. schemathesis/failures.py +0 -145
  208. schemathesis/fixups/__init__.py +0 -29
  209. schemathesis/fixups/fast_api.py +0 -30
  210. schemathesis/graphql.py +0 -5
  211. schemathesis/internal.py +0 -6
  212. schemathesis/lazy.py +0 -301
  213. schemathesis/models.py +0 -1113
  214. schemathesis/parameters.py +0 -91
  215. schemathesis/runner/__init__.py +0 -470
  216. schemathesis/runner/events.py +0 -242
  217. schemathesis/runner/impl/__init__.py +0 -3
  218. schemathesis/runner/impl/core.py +0 -791
  219. schemathesis/runner/impl/solo.py +0 -85
  220. schemathesis/runner/impl/threadpool.py +0 -367
  221. schemathesis/runner/serialization.py +0 -206
  222. schemathesis/serializers.py +0 -253
  223. schemathesis/service/__init__.py +0 -18
  224. schemathesis/service/auth.py +0 -10
  225. schemathesis/service/client.py +0 -62
  226. schemathesis/service/constants.py +0 -25
  227. schemathesis/service/events.py +0 -39
  228. schemathesis/service/handler.py +0 -46
  229. schemathesis/service/hosts.py +0 -74
  230. schemathesis/service/metadata.py +0 -42
  231. schemathesis/service/models.py +0 -21
  232. schemathesis/service/serialization.py +0 -184
  233. schemathesis/service/worker.py +0 -39
  234. schemathesis/specs/graphql/loaders.py +0 -215
  235. schemathesis/specs/openapi/constants.py +0 -7
  236. schemathesis/specs/openapi/expressions/context.py +0 -12
  237. schemathesis/specs/openapi/expressions/pointers.py +0 -29
  238. schemathesis/specs/openapi/filters.py +0 -44
  239. schemathesis/specs/openapi/links.py +0 -303
  240. schemathesis/specs/openapi/loaders.py +0 -453
  241. schemathesis/specs/openapi/parameters.py +0 -430
  242. schemathesis/specs/openapi/security.py +0 -129
  243. schemathesis/specs/openapi/validation.py +0 -24
  244. schemathesis/stateful.py +0 -358
  245. schemathesis/targets.py +0 -32
  246. schemathesis/types.py +0 -38
  247. schemathesis/utils.py +0 -475
  248. schemathesis-3.15.4.dist-info/METADATA +0 -202
  249. schemathesis-3.15.4.dist-info/RECORD +0 -99
  250. schemathesis-3.15.4.dist-info/entry_points.txt +0 -7
  251. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
@@ -0,0 +1,339 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, Any, Iterator, Mapping, cast
5
+
6
+ from schemathesis.core.errors import InfiniteRecursiveReference
7
+ from schemathesis.core.jsonschema.bundler import BundleError
8
+ from schemathesis.core.jsonschema.types import get_type
9
+ from schemathesis.specs.openapi.adapter.parameters import resource_name_from_ref
10
+ from schemathesis.specs.openapi.adapter.references import maybe_resolve
11
+ from schemathesis.specs.openapi.stateful.dependencies import naming
12
+ from schemathesis.specs.openapi.stateful.dependencies.models import (
13
+ CanonicalizationCache,
14
+ Cardinality,
15
+ DefinitionSource,
16
+ OperationMap,
17
+ ResourceDefinition,
18
+ ResourceMap,
19
+ extend_pointer,
20
+ )
21
+ from schemathesis.specs.openapi.stateful.dependencies.naming import from_path
22
+ from schemathesis.specs.openapi.stateful.dependencies.schemas import (
23
+ ROOT_POINTER,
24
+ canonicalize,
25
+ try_unwrap_composition,
26
+ unwrap_schema,
27
+ )
28
+
29
+ if TYPE_CHECKING:
30
+ from schemathesis.core.compat import RefResolver
31
+ from schemathesis.schemas import APIOperation
32
+ from schemathesis.specs.openapi.adapter.responses import OpenApiResponse
33
+
34
+
35
+ @dataclass
36
+ class ExtractedResource:
37
+ """How a resource was extracted from a response."""
38
+
39
+ resource: ResourceDefinition
40
+ # Where in response body (JSON pointer)
41
+ pointer: str
42
+ # Is this a single resource or an array?
43
+ cardinality: Cardinality
44
+
45
+ __slots__ = ("resource", "pointer", "cardinality")
46
+
47
+
48
+ def extract_resources_from_responses(
49
+ *,
50
+ operation: APIOperation,
51
+ resources: ResourceMap,
52
+ updated_resources: set[str],
53
+ resolver: RefResolver,
54
+ canonicalization_cache: CanonicalizationCache,
55
+ ) -> Iterator[tuple[OpenApiResponse, ExtractedResource]]:
56
+ """Extract resource definitions from operation's successful responses.
57
+
58
+ Processes each 2xx response, unwrapping pagination wrappers,
59
+ handling `allOf` / `oneOf` / `anyOf` composition, and determining cardinality.
60
+ Updates the global resource registry as resources are discovered.
61
+ """
62
+ for response in operation.responses.iter_successful_responses():
63
+ for extracted in iter_resources_from_response(
64
+ path=operation.path,
65
+ response=response,
66
+ resources=resources,
67
+ updated_resources=updated_resources,
68
+ resolver=resolver,
69
+ canonicalization_cache=canonicalization_cache,
70
+ ):
71
+ yield response, extracted
72
+
73
+
74
+ def iter_resources_from_response(
75
+ *,
76
+ path: str,
77
+ response: OpenApiResponse,
78
+ resources: ResourceMap,
79
+ updated_resources: set[str],
80
+ resolver: RefResolver,
81
+ canonicalization_cache: CanonicalizationCache,
82
+ ) -> Iterator[ExtractedResource]:
83
+ schema = response.get_raw_schema()
84
+
85
+ if isinstance(schema, bool):
86
+ boolean_resource = _resource_from_boolean_schema(path=path, resources=resources)
87
+ if boolean_resource is not None:
88
+ yield boolean_resource
89
+ return None
90
+ elif not isinstance(schema, dict):
91
+ # Ignore invalid schemas
92
+ return None
93
+
94
+ parent_ref = schema.get("$ref")
95
+ _, resolved = maybe_resolve(schema, resolver, "")
96
+
97
+ # Sometimes data is wrapped in a single wrapper field
98
+ # Common patterns: {data: {...}}, {result: {...}}, {response: {...}}
99
+ pointer = None
100
+ properties = resolved.get("properties", {})
101
+ if properties and len(properties) == 1:
102
+ wrapper_field = list(properties)[0]
103
+ # Check if it's a known wrapper field name
104
+ common_wrappers = {"data", "result", "response", "payload"}
105
+ if wrapper_field.lower() in common_wrappers:
106
+ pointer = f"/{wrapper_field}"
107
+ resolved = properties[wrapper_field]
108
+
109
+ resolved = try_unwrap_composition(resolved, resolver)
110
+
111
+ if "allOf" in resolved:
112
+ if parent_ref is not None and parent_ref in canonicalization_cache:
113
+ canonicalized = canonicalization_cache[parent_ref]
114
+ else:
115
+ try:
116
+ canonicalized = canonicalize(cast(dict, resolved), resolver)
117
+ except (InfiniteRecursiveReference, BundleError):
118
+ canonicalized = resolved
119
+ if parent_ref is not None:
120
+ canonicalization_cache[parent_ref] = canonicalized
121
+ else:
122
+ canonicalized = resolved
123
+
124
+ # Detect wrapper pattern and navigate to data
125
+ unwrapped = unwrap_schema(schema=canonicalized, path=path, parent_ref=parent_ref, resolver=resolver)
126
+
127
+ # Recover $ref lost during allOf canonicalization
128
+ recovered_ref = None
129
+ if unwrapped.pointer != ROOT_POINTER and "allOf" in resolved:
130
+ recovered_ref = _recover_ref_from_allof(
131
+ branches=resolved["allOf"],
132
+ pointer=unwrapped.pointer,
133
+ resolver=resolver,
134
+ )
135
+
136
+ # Extract resource and determine cardinality
137
+ result = _extract_resource_and_cardinality(
138
+ schema=unwrapped.schema,
139
+ path=path,
140
+ resources=resources,
141
+ updated_resources=updated_resources,
142
+ resolver=resolver,
143
+ parent_ref=recovered_ref or unwrapped.ref or parent_ref,
144
+ )
145
+
146
+ if result is not None:
147
+ resource, cardinality = result
148
+ if pointer:
149
+ if unwrapped.pointer != ROOT_POINTER:
150
+ pointer += unwrapped.pointer
151
+ else:
152
+ pointer = unwrapped.pointer
153
+ yield ExtractedResource(resource=resource, cardinality=cardinality, pointer=pointer)
154
+ # Look for sub-resources
155
+ properties = unwrapped.schema.get("properties")
156
+ if isinstance(properties, dict):
157
+ for field, subschema in properties.items():
158
+ if isinstance(subschema, dict):
159
+ reference = subschema.get("$ref")
160
+ if isinstance(reference, str):
161
+ result = _extract_resource_and_cardinality(
162
+ schema=subschema,
163
+ path=path,
164
+ resources=resources,
165
+ updated_resources=updated_resources,
166
+ resolver=resolver,
167
+ parent_ref=reference,
168
+ )
169
+ if result is not None:
170
+ subresource, cardinality = result
171
+ subresource_pointer = extend_pointer(pointer, field, cardinality=cardinality)
172
+ yield ExtractedResource(
173
+ resource=subresource, cardinality=cardinality, pointer=subresource_pointer
174
+ )
175
+
176
+
177
+ def _recover_ref_from_allof(*, branches: list[dict], pointer: str, resolver: RefResolver) -> str | None:
178
+ """Recover original $ref from allOf branches after canonicalization.
179
+
180
+ Canonicalization inlines all $refs, losing resource name information.
181
+ This searches original allOf branches to find which one defined the
182
+ property at the given pointer.
183
+ """
184
+ # Parse pointer segments (e.g., "/data" -> ["data"])
185
+ segments = [s for s in pointer.strip("/").split("/") if s]
186
+
187
+ # Search each branch for the property
188
+ for branch in branches:
189
+ _, resolved_branch = maybe_resolve(branch, resolver, "")
190
+ properties = resolved_branch.get("properties", {})
191
+
192
+ # Check if this branch defines the target property
193
+ if segments[-1] in properties:
194
+ # Navigate to property in original (unresolved) branch
195
+ original_properties = branch.get("properties", {})
196
+ if segments[-1] in original_properties:
197
+ prop_schema = original_properties[segments[-1]]
198
+ # Extract $ref from property or its items
199
+ return prop_schema.get("$ref") or prop_schema.get("items", {}).get("$ref")
200
+
201
+ return None
202
+
203
+
204
+ def _resource_from_boolean_schema(*, path: str, resources: ResourceMap) -> ExtractedResource | None:
205
+ name = from_path(path)
206
+ if name is None:
207
+ return None
208
+ resource = resources.get(name)
209
+ if resource is None:
210
+ resource = ResourceDefinition.without_properties(name)
211
+ resources[name] = resource
212
+ # Do not update existing resource as if it is inferred, it will have at least one field
213
+ return ExtractedResource(resource=resource, cardinality=Cardinality.ONE, pointer=ROOT_POINTER)
214
+
215
+
216
+ def _extract_resource_and_cardinality(
217
+ *,
218
+ schema: Mapping[str, Any],
219
+ path: str,
220
+ resources: ResourceMap,
221
+ updated_resources: set[str],
222
+ resolver: RefResolver,
223
+ parent_ref: str | None = None,
224
+ ) -> tuple[ResourceDefinition, Cardinality] | None:
225
+ """Extract resource from schema and determine cardinality."""
226
+ # Check if it's an array
227
+ if schema.get("type") == "array" or "items" in schema:
228
+ items = schema.get("items")
229
+ if not isinstance(items, dict):
230
+ return None
231
+
232
+ # Resolve items if it's a $ref
233
+ _, resolved_items = maybe_resolve(items, resolver, "")
234
+
235
+ # Extract resource from items
236
+ resource = _extract_resource_from_schema(
237
+ schema=resolved_items,
238
+ path=path,
239
+ resources=resources,
240
+ updated_resources=updated_resources,
241
+ resolver=resolver,
242
+ # Prefer items $ref for name
243
+ parent_ref=items.get("$ref") or parent_ref,
244
+ )
245
+
246
+ if resource is None:
247
+ return None
248
+
249
+ return resource, Cardinality.MANY
250
+
251
+ # Single object
252
+ resource = _extract_resource_from_schema(
253
+ schema=schema,
254
+ path=path,
255
+ resources=resources,
256
+ updated_resources=updated_resources,
257
+ resolver=resolver,
258
+ parent_ref=parent_ref,
259
+ )
260
+
261
+ if resource is None:
262
+ return None
263
+
264
+ return resource, Cardinality.ONE
265
+
266
+
267
+ def _extract_resource_from_schema(
268
+ *,
269
+ schema: Mapping[str, Any],
270
+ path: str,
271
+ resources: ResourceMap,
272
+ updated_resources: set[str],
273
+ resolver: RefResolver,
274
+ parent_ref: str | None = None,
275
+ ) -> ResourceDefinition | None:
276
+ """Extract resource definition from a schema."""
277
+ resource_name: str | None = None
278
+
279
+ ref = schema.get("$ref")
280
+ if ref is not None:
281
+ resource_name = resource_name_from_ref(ref)
282
+ elif parent_ref is not None:
283
+ resource_name = resource_name_from_ref(parent_ref)
284
+ else:
285
+ resource_name = naming.from_path(path)
286
+
287
+ if resource_name is None:
288
+ return None
289
+
290
+ resource = resources.get(resource_name)
291
+
292
+ if resource is None or resource.source < DefinitionSource.SCHEMA_WITH_PROPERTIES:
293
+ _, resolved = maybe_resolve(schema, resolver, "")
294
+
295
+ if "type" in resolved and resolved["type"] != "object" and "properties" not in resolved:
296
+ # Skip strings, etc
297
+ return None
298
+
299
+ properties = resolved.get("properties")
300
+ if properties:
301
+ fields = sorted(properties)
302
+ types = {}
303
+ for field, subschema in properties.items():
304
+ if isinstance(subschema, dict):
305
+ _, resolved_subschema = maybe_resolve(subschema, resolver, "")
306
+ else:
307
+ resolved_subschema = subschema
308
+ types[field] = set(get_type(cast(dict, resolved_subschema)))
309
+ source = DefinitionSource.SCHEMA_WITH_PROPERTIES
310
+ else:
311
+ fields = []
312
+ types = {}
313
+ source = DefinitionSource.SCHEMA_WITHOUT_PROPERTIES
314
+ if resource is not None:
315
+ if resource.source < source:
316
+ resource.source = source
317
+ resource.fields = fields
318
+ resource.types = types
319
+ updated_resources.add(resource_name)
320
+ else:
321
+ resource = ResourceDefinition(name=resource_name, fields=fields, types=types, source=source)
322
+ resources[resource_name] = resource
323
+
324
+ return resource
325
+
326
+
327
+ def remove_unused_resources(operations: OperationMap, resources: ResourceMap) -> None:
328
+ """Remove resources that aren't referenced by any operation."""
329
+ # Collect all resource names currently in use
330
+ used_resources = set()
331
+ for operation in operations.values():
332
+ for input_slot in operation.inputs:
333
+ used_resources.add(input_slot.resource.name)
334
+ for output_slot in operation.outputs:
335
+ used_resources.add(output_slot.resource.name)
336
+
337
+ unused = set(resources.keys()) - used_resources
338
+ for resource_name in unused:
339
+ del resources[resource_name]