schemathesis 4.2.2__py3-none-any.whl → 4.3.1__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 (28) hide show
  1. schemathesis/config/__init__.py +8 -1
  2. schemathesis/config/_phases.py +14 -3
  3. schemathesis/config/schema.json +2 -1
  4. schemathesis/core/jsonschema/bundler.py +3 -2
  5. schemathesis/core/transforms.py +14 -6
  6. schemathesis/engine/context.py +35 -2
  7. schemathesis/generation/hypothesis/__init__.py +3 -1
  8. schemathesis/generation/hypothesis/builder.py +10 -2
  9. schemathesis/openapi/checks.py +13 -1
  10. schemathesis/specs/openapi/adapter/parameters.py +3 -3
  11. schemathesis/specs/openapi/adapter/protocol.py +2 -0
  12. schemathesis/specs/openapi/adapter/responses.py +29 -7
  13. schemathesis/specs/openapi/adapter/v2.py +2 -0
  14. schemathesis/specs/openapi/adapter/v3_0.py +2 -0
  15. schemathesis/specs/openapi/adapter/v3_1.py +2 -0
  16. schemathesis/specs/openapi/stateful/dependencies/__init__.py +88 -0
  17. schemathesis/specs/openapi/stateful/dependencies/inputs.py +182 -0
  18. schemathesis/specs/openapi/stateful/dependencies/models.py +270 -0
  19. schemathesis/specs/openapi/stateful/dependencies/naming.py +345 -0
  20. schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
  21. schemathesis/specs/openapi/stateful/dependencies/resources.py +282 -0
  22. schemathesis/specs/openapi/stateful/dependencies/schemas.py +420 -0
  23. schemathesis/specs/openapi/stateful/inference.py +2 -1
  24. {schemathesis-4.2.2.dist-info → schemathesis-4.3.1.dist-info}/METADATA +1 -1
  25. {schemathesis-4.2.2.dist-info → schemathesis-4.3.1.dist-info}/RECORD +28 -21
  26. {schemathesis-4.2.2.dist-info → schemathesis-4.3.1.dist-info}/WHEEL +0 -0
  27. {schemathesis-4.2.2.dist-info → schemathesis-4.3.1.dist-info}/entry_points.txt +0 -0
  28. {schemathesis-4.2.2.dist-info → schemathesis-4.3.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,345 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def from_parameter(parameter: str, path: str) -> str | None:
5
+ # TODO: support other naming patterns
6
+ # Named like "userId" -> look for "User" resource
7
+ if parameter.endswith("Id"):
8
+ return to_pascal_case(parameter[:-2])
9
+ # Named like "user_id" -> look for "User" resource
10
+ elif parameter.endswith("_id"):
11
+ return to_pascal_case(parameter[:-3])
12
+ # Just "id" -> infer from path context
13
+ elif parameter == "id":
14
+ return from_path(path)
15
+ return None
16
+
17
+
18
+ def from_path(path: str) -> str | None:
19
+ segments = [s for s in path.split("/") if s and "{" not in s]
20
+
21
+ if not segments:
22
+ # API Root
23
+ return None
24
+
25
+ singular = to_singular(segments[-1])
26
+ return to_pascal_case(singular)
27
+
28
+
29
+ IRREGULAR_TO_PLURAL = {
30
+ "echo": "echoes",
31
+ "dingo": "dingoes",
32
+ "volcano": "volcanoes",
33
+ "tornado": "tornadoes",
34
+ "torpedo": "torpedoes",
35
+ "genus": "genera",
36
+ "viscus": "viscera",
37
+ "stigma": "stigmata",
38
+ "stoma": "stomata",
39
+ "dogma": "dogmata",
40
+ "lemma": "lemmata",
41
+ "anathema": "anathemata",
42
+ "ox": "oxen",
43
+ "axe": "axes",
44
+ "die": "dice",
45
+ "yes": "yeses",
46
+ "foot": "feet",
47
+ "eave": "eaves",
48
+ "goose": "geese",
49
+ "tooth": "teeth",
50
+ "quiz": "quizzes",
51
+ "human": "humans",
52
+ "proof": "proofs",
53
+ "carve": "carves",
54
+ "valve": "valves",
55
+ "looey": "looies",
56
+ "thief": "thieves",
57
+ "groove": "grooves",
58
+ "pickaxe": "pickaxes",
59
+ "passerby": "passersby",
60
+ "canvas": "canvases",
61
+ "use": "uses",
62
+ "case": "cases",
63
+ "vase": "vases",
64
+ "house": "houses",
65
+ "mouse": "mice",
66
+ "reuse": "reuses",
67
+ "abuse": "abuses",
68
+ "excuse": "excuses",
69
+ "cause": "causes",
70
+ "pause": "pauses",
71
+ "base": "bases",
72
+ "phase": "phases",
73
+ "rose": "roses",
74
+ "dose": "doses",
75
+ "nose": "noses",
76
+ "horse": "horses",
77
+ "course": "courses",
78
+ "purpose": "purposes",
79
+ "response": "responses",
80
+ "sense": "senses",
81
+ "tense": "tenses",
82
+ "expense": "expenses",
83
+ "license": "licenses",
84
+ "defense": "defenses",
85
+ }
86
+ IRREGULAR_TO_SINGULAR = {v: k for k, v in IRREGULAR_TO_PLURAL.items()}
87
+ UNCOUNTABLE = frozenset(
88
+ [
89
+ "adulthood",
90
+ "advice",
91
+ "agenda",
92
+ "aid",
93
+ "aircraft",
94
+ "alcohol",
95
+ "ammo",
96
+ "analytics",
97
+ "anime",
98
+ "athletics",
99
+ "audio",
100
+ "bison",
101
+ "blood",
102
+ "bream",
103
+ "buffalo",
104
+ "butter",
105
+ "carp",
106
+ "cash",
107
+ "chassis",
108
+ "chess",
109
+ "clothing",
110
+ "cod",
111
+ "commerce",
112
+ "cooperation",
113
+ "corps",
114
+ "debris",
115
+ "diabetes",
116
+ "digestion",
117
+ "elk",
118
+ "energy",
119
+ "equipment",
120
+ "excretion",
121
+ "expertise",
122
+ "firmware",
123
+ "flounder",
124
+ "fun",
125
+ "gallows",
126
+ "garbage",
127
+ "graffiti",
128
+ "hardware",
129
+ "headquarters",
130
+ "health",
131
+ "herpes",
132
+ "highjinks",
133
+ "homework",
134
+ "housework",
135
+ "information",
136
+ "jeans",
137
+ "justice",
138
+ "kudos",
139
+ "labour",
140
+ "literature",
141
+ "machinery",
142
+ "mackerel",
143
+ "mail",
144
+ "media",
145
+ "mews",
146
+ "moose",
147
+ "music",
148
+ "mud",
149
+ "manga",
150
+ "news",
151
+ "only",
152
+ "personnel",
153
+ "pike",
154
+ "plankton",
155
+ "pliers",
156
+ "police",
157
+ "pollution",
158
+ "premises",
159
+ "rain",
160
+ "research",
161
+ "rice",
162
+ "salmon",
163
+ "scissors",
164
+ "series",
165
+ "sewage",
166
+ "shambles",
167
+ "shrimp",
168
+ "software",
169
+ "staff",
170
+ "swine",
171
+ "tennis",
172
+ "traffic",
173
+ "transportation",
174
+ "trout",
175
+ "tuna",
176
+ "wealth",
177
+ "welfare",
178
+ "whiting",
179
+ "wildebeest",
180
+ "wildlife",
181
+ "you",
182
+ "sheep",
183
+ "deer",
184
+ "species",
185
+ "series",
186
+ "means",
187
+ ]
188
+ )
189
+
190
+
191
+ def to_singular(word: str) -> str:
192
+ if word in UNCOUNTABLE:
193
+ return word
194
+ known = IRREGULAR_TO_SINGULAR.get(word)
195
+ if known is not None:
196
+ return known
197
+ if word.endswith("ies") and len(word) > 3 and word[-4] not in "aeiou":
198
+ return word[:-3] + "y"
199
+ if word.endswith("sses"):
200
+ return word[:-2]
201
+ if word.endswith(("xes", "zes", "ches", "shes")):
202
+ return word[:-2]
203
+ # Handle "ses" ending: check if it was "se" + "s" or "s" + "es"
204
+ if word.endswith("ses") and len(word) > 3:
205
+ # "gases" has 's' at position -3, formed from "gas" + "es"
206
+ # "statuses" has 's' at position -3, formed from "status" + "es"
207
+ return word[:-2]
208
+ if word.endswith("s"):
209
+ return word[:-1]
210
+ return word
211
+
212
+
213
+ def to_plural(word: str) -> str:
214
+ if word in UNCOUNTABLE:
215
+ return word
216
+ known = IRREGULAR_TO_PLURAL.get(word)
217
+ if known is not None:
218
+ return known
219
+ # Only change y -> ies after consonants (party -> parties, not day -> days)
220
+ if word.endswith("y") and len(word) > 1 and word[-2] not in "aeiou":
221
+ return word[:-1] + "ies"
222
+ # class -> classes
223
+ if word.endswith("ss"):
224
+ return word + "es"
225
+ # words that normally take -es: box -> boxes
226
+ if word.endswith(("s", "x", "z", "ch", "sh")):
227
+ return word + "es"
228
+ # just add 's' (car -> cars)
229
+ return word + "s"
230
+
231
+
232
+ def to_pascal_case(text: str) -> str:
233
+ parts = text.replace("-", "_").split("_")
234
+ return "".join(word.capitalize() for word in parts if word)
235
+
236
+
237
+ def to_snake_case(text: str) -> str:
238
+ text = text.replace("-", "_")
239
+ # Insert underscores before uppercase letters
240
+ result = []
241
+ for i, char in enumerate(text):
242
+ # Add underscore before uppercase (except at start)
243
+ if i > 0 and char.isupper():
244
+ result.append("_")
245
+ result.append(char.lower())
246
+ return "".join(result)
247
+
248
+
249
+ def find_matching_field(*, parameter: str, resource: str, fields: list[str]) -> str | None:
250
+ """Find which resource field matches the parameter name."""
251
+ if not fields:
252
+ return None
253
+
254
+ # Exact match
255
+ if parameter in fields:
256
+ return parameter
257
+
258
+ # Normalize for fuzzy matching
259
+ parameter_normalized = _normalize_for_matching(parameter)
260
+ resource_normalized = _normalize_for_matching(resource)
261
+
262
+ # Normalized exact match
263
+ # `brandId` -> `Brand.BrandId`
264
+ for field in fields:
265
+ if _normalize_for_matching(field) == parameter_normalized:
266
+ return field
267
+
268
+ # Extract parameter components
269
+ parameter_prefix, param_suffix = _split_parameter_name(parameter)
270
+ parameter_prefix_normalized = _normalize_for_matching(parameter_prefix)
271
+
272
+ # Parameter has resource prefix, field might not
273
+ # Example: `channelId` - `Channel.id`
274
+ if parameter_prefix and parameter_prefix_normalized == resource_normalized:
275
+ suffix_normalized = _normalize_for_matching(param_suffix)
276
+
277
+ for field in fields:
278
+ field_normalized = _normalize_for_matching(field)
279
+ if field_normalized == suffix_normalized:
280
+ return field
281
+
282
+ # Parameter has no prefix, field might have resource prefix
283
+ # Example: `id` - `Channel.channelId`
284
+ if not parameter_prefix and param_suffix:
285
+ expected_field_normalized = resource_normalized + _normalize_for_matching(param_suffix)
286
+
287
+ for field in fields:
288
+ field_normalized = _normalize_for_matching(field)
289
+ if field_normalized == expected_field_normalized:
290
+ return field
291
+
292
+ return None
293
+
294
+
295
+ def _normalize_for_matching(text: str) -> str:
296
+ """Normalize text for case-insensitive, separator-insensitive matching.
297
+
298
+ Examples:
299
+ "channelId" -> "channelid"
300
+ "channel_id" -> "channelid"
301
+ "ChannelId" -> "channelid"
302
+ "Channel" -> "channel"
303
+
304
+ """
305
+ return text.lower().replace("_", "").replace("-", "")
306
+
307
+
308
+ def _split_parameter_name(param_name: str) -> tuple[str, str]:
309
+ """Split parameter into (prefix, suffix) components.
310
+
311
+ Examples:
312
+ "channelId" -> ("channel", "Id")
313
+ "userId" -> ("user", "Id")
314
+ "user_id" -> ("user", "_id")
315
+ "id" -> ("", "id")
316
+ "channel_id" -> ("channel", "_id")
317
+
318
+ """
319
+ if param_name.endswith("Id") and len(param_name) > 2:
320
+ return (param_name[:-2], "Id")
321
+
322
+ if param_name.endswith("_id") and len(param_name) > 3:
323
+ return (param_name[:-3], "_id")
324
+
325
+ return ("", param_name)
326
+
327
+
328
+ def strip_affixes(name: str, prefixes: list[str], suffixes: list[str]) -> str:
329
+ """Remove common prefixes and suffixes from a name (case-insensitive)."""
330
+ result = name.strip()
331
+ name_lower = result.lower()
332
+
333
+ # Remove one matching prefix
334
+ for prefix in prefixes:
335
+ if name_lower.startswith(prefix):
336
+ result = result[len(prefix) :]
337
+ break
338
+
339
+ # Remove one matching suffix
340
+ for suffix in suffixes:
341
+ if name_lower.endswith(suffix):
342
+ result = result[: -len(suffix)]
343
+ break
344
+
345
+ return result.strip()
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Iterator
4
+
5
+ from schemathesis.specs.openapi.stateful.dependencies.models import CanonicalizationCache, OutputSlot, ResourceMap
6
+ from schemathesis.specs.openapi.stateful.dependencies.resources import extract_resources_from_responses
7
+
8
+ if TYPE_CHECKING:
9
+ from schemathesis.core.compat import RefResolver
10
+ from schemathesis.specs.openapi.schemas import APIOperation
11
+
12
+
13
+ def extract_outputs(
14
+ *,
15
+ operation: APIOperation,
16
+ resources: ResourceMap,
17
+ updated_resources: set[str],
18
+ resolver: RefResolver,
19
+ canonicalization_cache: CanonicalizationCache,
20
+ ) -> Iterator[OutputSlot]:
21
+ """Extract resources from API operation's responses."""
22
+ for response, extracted in extract_resources_from_responses(
23
+ operation=operation,
24
+ resources=resources,
25
+ updated_resources=updated_resources,
26
+ resolver=resolver,
27
+ canonicalization_cache=canonicalization_cache,
28
+ ):
29
+ yield OutputSlot(
30
+ resource=extracted.resource,
31
+ pointer=extracted.pointer,
32
+ cardinality=extracted.cardinality,
33
+ status_code=response.status_code,
34
+ )
@@ -0,0 +1,282 @@
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.specs.openapi.adapter.parameters import resource_name_from_ref
9
+ from schemathesis.specs.openapi.adapter.references import maybe_resolve
10
+ from schemathesis.specs.openapi.stateful.dependencies import naming
11
+ from schemathesis.specs.openapi.stateful.dependencies.models import (
12
+ CanonicalizationCache,
13
+ Cardinality,
14
+ DefinitionSource,
15
+ ResourceDefinition,
16
+ ResourceMap,
17
+ )
18
+ from schemathesis.specs.openapi.stateful.dependencies.naming import from_path
19
+ from schemathesis.specs.openapi.stateful.dependencies.schemas import (
20
+ ROOT_POINTER,
21
+ canonicalize,
22
+ try_unwrap_composition,
23
+ unwrap_schema,
24
+ )
25
+
26
+ if TYPE_CHECKING:
27
+ from schemathesis.core.compat import RefResolver
28
+ from schemathesis.schemas import APIOperation
29
+ from schemathesis.specs.openapi.adapter.responses import OpenApiResponse
30
+
31
+
32
+ @dataclass
33
+ class ExtractedResource:
34
+ """How a resource was extracted from a response."""
35
+
36
+ resource: ResourceDefinition
37
+ # Where in response body (JSON pointer)
38
+ pointer: str
39
+ # Is this a single resource or an array?
40
+ cardinality: Cardinality
41
+
42
+ __slots__ = ("resource", "pointer", "cardinality")
43
+
44
+
45
+ def extract_resources_from_responses(
46
+ *,
47
+ operation: APIOperation,
48
+ resources: ResourceMap,
49
+ updated_resources: set[str],
50
+ resolver: RefResolver,
51
+ canonicalization_cache: CanonicalizationCache,
52
+ ) -> Iterator[tuple[OpenApiResponse, ExtractedResource]]:
53
+ """Extract resource definitions from operation's successful responses.
54
+
55
+ Processes each 2xx response, unwrapping pagination wrappers,
56
+ handling `allOf` / `oneOf` / `anyOf` composition, and determining cardinality.
57
+ Updates the global resource registry as resources are discovered.
58
+ """
59
+ for response in operation.responses.iter_successful_responses():
60
+ for extracted in iter_resources_from_response(
61
+ path=operation.path,
62
+ response=response,
63
+ resources=resources,
64
+ updated_resources=updated_resources,
65
+ resolver=resolver,
66
+ canonicalization_cache=canonicalization_cache,
67
+ ):
68
+ yield response, extracted
69
+
70
+
71
+ def iter_resources_from_response(
72
+ *,
73
+ path: str,
74
+ response: OpenApiResponse,
75
+ resources: ResourceMap,
76
+ updated_resources: set[str],
77
+ resolver: RefResolver,
78
+ canonicalization_cache: CanonicalizationCache,
79
+ ) -> Iterator[ExtractedResource]:
80
+ schema = response.get_raw_schema()
81
+
82
+ if isinstance(schema, bool):
83
+ boolean_resource = _resource_from_boolean_schema(path=path, resources=resources)
84
+ if boolean_resource is not None:
85
+ yield boolean_resource
86
+ return None
87
+ elif not isinstance(schema, dict):
88
+ # Ignore invalid schemas
89
+ return None
90
+
91
+ parent_ref = schema.get("$ref")
92
+ _, resolved = maybe_resolve(schema, resolver, "")
93
+
94
+ # Sometimes data is wrapped in `data` field
95
+ pointer = None
96
+ properties = resolved.get("properties", {})
97
+ if properties and list(properties) == ["data"]:
98
+ pointer = "/data"
99
+ resolved = properties["data"]
100
+
101
+ resolved = try_unwrap_composition(resolved, resolver)
102
+
103
+ if "allOf" in resolved:
104
+ if parent_ref is not None and parent_ref in canonicalization_cache:
105
+ canonicalized = canonicalization_cache[parent_ref]
106
+ else:
107
+ try:
108
+ canonicalized = canonicalize(cast(dict, resolved), resolver)
109
+ except (InfiniteRecursiveReference, BundleError):
110
+ canonicalized = resolved
111
+ if parent_ref is not None:
112
+ canonicalization_cache[parent_ref] = canonicalized
113
+ else:
114
+ canonicalized = resolved
115
+
116
+ # Detect wrapper pattern and navigate to data
117
+ unwrapped = unwrap_schema(schema=canonicalized, path=path, parent_ref=parent_ref, resolver=resolver)
118
+
119
+ # Recover $ref lost during allOf canonicalization
120
+ recovered_ref = None
121
+ if unwrapped.pointer != ROOT_POINTER and "allOf" in resolved:
122
+ recovered_ref = _recover_ref_from_allof(
123
+ branches=resolved["allOf"],
124
+ pointer=unwrapped.pointer,
125
+ resolver=resolver,
126
+ )
127
+
128
+ # Extract resource and determine cardinality
129
+ result = _extract_resource_and_cardinality(
130
+ schema=unwrapped.schema,
131
+ path=path,
132
+ resources=resources,
133
+ updated_resources=updated_resources,
134
+ resolver=resolver,
135
+ parent_ref=recovered_ref or unwrapped.ref or parent_ref,
136
+ )
137
+
138
+ if result is not None:
139
+ resource, cardinality = result
140
+ if pointer:
141
+ if unwrapped.pointer != ROOT_POINTER:
142
+ pointer += unwrapped.pointer
143
+ else:
144
+ pointer = unwrapped.pointer
145
+ yield ExtractedResource(resource=resource, cardinality=cardinality, pointer=pointer)
146
+
147
+
148
+ def _recover_ref_from_allof(*, branches: list[dict], pointer: str, resolver: RefResolver) -> str | None:
149
+ """Recover original $ref from allOf branches after canonicalization.
150
+
151
+ Canonicalization inlines all $refs, losing resource name information.
152
+ This searches original allOf branches to find which one defined the
153
+ property at the given pointer.
154
+ """
155
+ # Parse pointer segments (e.g., "/data" -> ["data"])
156
+ segments = [s for s in pointer.strip("/").split("/") if s]
157
+
158
+ # Search each branch for the property
159
+ for branch in branches:
160
+ _, resolved_branch = maybe_resolve(branch, resolver, "")
161
+ properties = resolved_branch.get("properties", {})
162
+
163
+ # Check if this branch defines the target property
164
+ if segments[-1] in properties:
165
+ # Navigate to property in original (unresolved) branch
166
+ original_properties = branch.get("properties", {})
167
+ if segments[-1] in original_properties:
168
+ prop_schema = original_properties[segments[-1]]
169
+ # Extract $ref from property or its items
170
+ return prop_schema.get("$ref") or prop_schema.get("items", {}).get("$ref")
171
+
172
+ return None
173
+
174
+
175
+ def _resource_from_boolean_schema(*, path: str, resources: ResourceMap) -> ExtractedResource | None:
176
+ name = from_path(path)
177
+ if name is None:
178
+ return None
179
+ resource = resources.get(name)
180
+ if resource is None:
181
+ resource = ResourceDefinition.without_properties(name)
182
+ resources[name] = resource
183
+ # Do not update existing resource as if it is inferred, it will have at least one field
184
+ return ExtractedResource(resource=resource, cardinality=Cardinality.ONE, pointer=ROOT_POINTER)
185
+
186
+
187
+ def _extract_resource_and_cardinality(
188
+ *,
189
+ schema: Mapping[str, Any],
190
+ path: str,
191
+ resources: ResourceMap,
192
+ updated_resources: set[str],
193
+ resolver: RefResolver,
194
+ parent_ref: str | None = None,
195
+ ) -> tuple[ResourceDefinition, Cardinality] | None:
196
+ """Extract resource from schema and determine cardinality."""
197
+ # Check if it's an array
198
+ if schema.get("type") == "array" or "items" in schema:
199
+ items = schema.get("items")
200
+ if not isinstance(items, dict):
201
+ return None
202
+
203
+ # Resolve items if it's a $ref
204
+ _, resolved_items = maybe_resolve(items, resolver, "")
205
+
206
+ # Extract resource from items
207
+ resource = _extract_resource_from_schema(
208
+ schema=resolved_items,
209
+ path=path,
210
+ resources=resources,
211
+ updated_resources=updated_resources,
212
+ resolver=resolver,
213
+ # Prefer items $ref for name
214
+ parent_ref=items.get("$ref") or parent_ref,
215
+ )
216
+
217
+ if resource is None:
218
+ return None
219
+
220
+ return resource, Cardinality.MANY
221
+
222
+ # Single object
223
+ resource = _extract_resource_from_schema(
224
+ schema=schema,
225
+ path=path,
226
+ resources=resources,
227
+ updated_resources=updated_resources,
228
+ resolver=resolver,
229
+ parent_ref=parent_ref,
230
+ )
231
+
232
+ if resource is None:
233
+ return None
234
+
235
+ return resource, Cardinality.ONE
236
+
237
+
238
+ def _extract_resource_from_schema(
239
+ *,
240
+ schema: Mapping[str, Any],
241
+ path: str,
242
+ resources: ResourceMap,
243
+ updated_resources: set[str],
244
+ resolver: RefResolver,
245
+ parent_ref: str | None = None,
246
+ ) -> ResourceDefinition | None:
247
+ """Extract resource definition from a schema."""
248
+ resource_name: str | None = None
249
+
250
+ ref = schema.get("$ref")
251
+ if ref is not None:
252
+ resource_name = resource_name_from_ref(ref)
253
+ elif parent_ref is not None:
254
+ resource_name = resource_name_from_ref(parent_ref)
255
+ else:
256
+ resource_name = naming.from_path(path)
257
+
258
+ if resource_name is None:
259
+ return None
260
+
261
+ resource = resources.get(resource_name)
262
+
263
+ if resource is None or resource.source < DefinitionSource.SCHEMA_WITH_PROPERTIES:
264
+ _, resolved = maybe_resolve(schema, resolver, "")
265
+
266
+ properties = resolved.get("properties")
267
+ if properties:
268
+ fields = list(properties)
269
+ source = DefinitionSource.SCHEMA_WITH_PROPERTIES
270
+ else:
271
+ fields = []
272
+ source = DefinitionSource.SCHEMA_WITHOUT_PROPERTIES
273
+ if resource is not None:
274
+ if resource.source < source:
275
+ resource.source = source
276
+ resource.fields = fields
277
+ updated_resources.add(resource_name)
278
+ else:
279
+ resource = ResourceDefinition(name=resource_name, fields=fields, source=source)
280
+ resources[resource_name] = resource
281
+
282
+ return resource