datamodel-code-generator 0.11.12__py3-none-any.whl → 0.45.0__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 (73) hide show
  1. datamodel_code_generator/__init__.py +654 -185
  2. datamodel_code_generator/__main__.py +872 -388
  3. datamodel_code_generator/arguments.py +798 -0
  4. datamodel_code_generator/cli_options.py +295 -0
  5. datamodel_code_generator/format.py +292 -54
  6. datamodel_code_generator/http.py +85 -10
  7. datamodel_code_generator/imports.py +152 -43
  8. datamodel_code_generator/model/__init__.py +138 -1
  9. datamodel_code_generator/model/base.py +531 -120
  10. datamodel_code_generator/model/dataclass.py +211 -0
  11. datamodel_code_generator/model/enum.py +133 -12
  12. datamodel_code_generator/model/imports.py +22 -0
  13. datamodel_code_generator/model/msgspec.py +462 -0
  14. datamodel_code_generator/model/pydantic/__init__.py +30 -25
  15. datamodel_code_generator/model/pydantic/base_model.py +304 -100
  16. datamodel_code_generator/model/pydantic/custom_root_type.py +11 -2
  17. datamodel_code_generator/model/pydantic/dataclass.py +15 -4
  18. datamodel_code_generator/model/pydantic/imports.py +40 -27
  19. datamodel_code_generator/model/pydantic/types.py +188 -96
  20. datamodel_code_generator/model/pydantic_v2/__init__.py +51 -0
  21. datamodel_code_generator/model/pydantic_v2/base_model.py +268 -0
  22. datamodel_code_generator/model/pydantic_v2/imports.py +15 -0
  23. datamodel_code_generator/model/pydantic_v2/root_model.py +35 -0
  24. datamodel_code_generator/model/pydantic_v2/types.py +143 -0
  25. datamodel_code_generator/model/scalar.py +124 -0
  26. datamodel_code_generator/model/template/Enum.jinja2 +15 -2
  27. datamodel_code_generator/model/template/ScalarTypeAliasAnnotation.jinja2 +6 -0
  28. datamodel_code_generator/model/template/ScalarTypeAliasType.jinja2 +6 -0
  29. datamodel_code_generator/model/template/ScalarTypeStatement.jinja2 +6 -0
  30. datamodel_code_generator/model/template/TypeAliasAnnotation.jinja2 +20 -0
  31. datamodel_code_generator/model/template/TypeAliasType.jinja2 +20 -0
  32. datamodel_code_generator/model/template/TypeStatement.jinja2 +20 -0
  33. datamodel_code_generator/model/template/TypedDict.jinja2 +5 -0
  34. datamodel_code_generator/model/template/TypedDictClass.jinja2 +25 -0
  35. datamodel_code_generator/model/template/TypedDictFunction.jinja2 +24 -0
  36. datamodel_code_generator/model/template/UnionTypeAliasAnnotation.jinja2 +10 -0
  37. datamodel_code_generator/model/template/UnionTypeAliasType.jinja2 +10 -0
  38. datamodel_code_generator/model/template/UnionTypeStatement.jinja2 +10 -0
  39. datamodel_code_generator/model/template/dataclass.jinja2 +50 -0
  40. datamodel_code_generator/model/template/msgspec.jinja2 +55 -0
  41. datamodel_code_generator/model/template/pydantic/BaseModel.jinja2 +17 -4
  42. datamodel_code_generator/model/template/pydantic/BaseModel_root.jinja2 +12 -4
  43. datamodel_code_generator/model/template/pydantic/Config.jinja2 +1 -1
  44. datamodel_code_generator/model/template/pydantic/dataclass.jinja2 +15 -2
  45. datamodel_code_generator/model/template/pydantic_v2/BaseModel.jinja2 +57 -0
  46. datamodel_code_generator/model/template/pydantic_v2/ConfigDict.jinja2 +5 -0
  47. datamodel_code_generator/model/template/pydantic_v2/RootModel.jinja2 +48 -0
  48. datamodel_code_generator/model/type_alias.py +70 -0
  49. datamodel_code_generator/model/typed_dict.py +161 -0
  50. datamodel_code_generator/model/types.py +106 -0
  51. datamodel_code_generator/model/union.py +105 -0
  52. datamodel_code_generator/parser/__init__.py +30 -12
  53. datamodel_code_generator/parser/_graph.py +67 -0
  54. datamodel_code_generator/parser/_scc.py +171 -0
  55. datamodel_code_generator/parser/base.py +2426 -380
  56. datamodel_code_generator/parser/graphql.py +652 -0
  57. datamodel_code_generator/parser/jsonschema.py +2518 -647
  58. datamodel_code_generator/parser/openapi.py +631 -222
  59. datamodel_code_generator/py.typed +0 -0
  60. datamodel_code_generator/pydantic_patch.py +28 -0
  61. datamodel_code_generator/reference.py +672 -290
  62. datamodel_code_generator/types.py +521 -145
  63. datamodel_code_generator/util.py +155 -0
  64. datamodel_code_generator/watch.py +65 -0
  65. datamodel_code_generator-0.45.0.dist-info/METADATA +301 -0
  66. datamodel_code_generator-0.45.0.dist-info/RECORD +69 -0
  67. {datamodel_code_generator-0.11.12.dist-info → datamodel_code_generator-0.45.0.dist-info}/WHEEL +1 -1
  68. datamodel_code_generator-0.45.0.dist-info/entry_points.txt +2 -0
  69. datamodel_code_generator/version.py +0 -1
  70. datamodel_code_generator-0.11.12.dist-info/METADATA +0 -440
  71. datamodel_code_generator-0.11.12.dist-info/RECORD +0 -31
  72. datamodel_code_generator-0.11.12.dist-info/entry_points.txt +0 -3
  73. {datamodel_code_generator-0.11.12.dist-info → datamodel_code_generator-0.45.0.dist-info/licenses}/LICENSE +0 -0
@@ -1,110 +1,218 @@
1
+ """Reference resolution and model tracking system.
2
+
3
+ Provides Reference for tracking model references across schemas, ModelResolver
4
+ for managing class names and field names, and FieldNameResolver for converting
5
+ schema field names to valid Python identifiers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
1
10
  import re
2
11
  from collections import defaultdict
3
12
  from contextlib import contextmanager
4
13
  from enum import Enum, auto
5
- from functools import lru_cache
14
+ from functools import cached_property, lru_cache
6
15
  from itertools import zip_longest
7
16
  from keyword import iskeyword
8
17
  from pathlib import Path, PurePath
18
+ from re import Pattern
9
19
  from typing import (
10
20
  TYPE_CHECKING,
11
21
  Any,
12
22
  Callable,
13
23
  ClassVar,
14
- DefaultDict,
15
- Dict,
16
- Generator,
17
- List,
18
- Mapping,
24
+ NamedTuple,
19
25
  Optional,
20
- Pattern,
21
- Sequence,
22
- Set,
23
- Tuple,
24
- Type,
26
+ Protocol,
25
27
  TypeVar,
26
- Union,
28
+ cast,
29
+ runtime_checkable,
27
30
  )
31
+ from urllib.parse import ParseResult, urlparse
28
32
 
29
- import inflect
30
- from pydantic import BaseModel, validator
33
+ import pydantic
34
+ from packaging import version
35
+ from pydantic import BaseModel, Field
36
+ from typing_extensions import TypeIs
31
37
 
32
- from datamodel_code_generator import cached_property
38
+ from datamodel_code_generator import Error
39
+ from datamodel_code_generator.util import PYDANTIC_V2, ConfigDict, camel_to_snake, model_validator
33
40
 
34
41
  if TYPE_CHECKING:
35
- from pydantic.typing import AbstractSetIntStr, DictStrAny, MappingIntStrAny
42
+ from collections.abc import Generator, Iterator, Mapping, Sequence
43
+ from collections.abc import Set as AbstractSet
44
+
45
+ import inflect
46
+ from pydantic.typing import DictStrAny
47
+
48
+ from datamodel_code_generator.model.base import DataModel
49
+ from datamodel_code_generator.types import DataType
50
+
51
+
52
+ def _is_data_type(value: object) -> TypeIs[DataType]:
53
+ """Check if value is a DataType instance."""
54
+ from datamodel_code_generator.types import DataType as DataType_ # noqa: PLC0415
55
+
56
+ return isinstance(value, DataType_)
57
+
58
+
59
+ def _is_data_model(value: object) -> TypeIs[DataModel]:
60
+ """Check if value is a DataModel instance."""
61
+ from datamodel_code_generator.model.base import DataModel as DataModel_ # noqa: PLC0415
62
+
63
+ return isinstance(value, DataModel_)
64
+
65
+
66
+ @runtime_checkable
67
+ class ReferenceChild(Protocol):
68
+ """Protocol for objects that can be stored in Reference.children.
69
+
70
+ This is a minimal protocol - actual usage checks isinstance for DataType
71
+ or DataModel to access specific methods like replace_reference or class_name.
72
+ Using a property makes the type covariant, allowing both DataModel (Reference)
73
+ and DataType (Reference | None) to satisfy this protocol.
74
+ """
75
+
76
+ @property
77
+ def reference(self) -> Reference | None:
78
+ """Return the reference associated with this object."""
79
+ ...
36
80
 
37
81
 
38
82
  class _BaseModel(BaseModel):
39
- _exclude_fields: ClassVar[Set[str]] = set()
40
- _pass_fields: ClassVar[Set[str]] = set()
83
+ """Base model with field exclusion and pass-through support."""
84
+
85
+ _exclude_fields: ClassVar[set[str]] = set()
86
+ _pass_fields: ClassVar[set[str]] = set()
87
+
88
+ if not TYPE_CHECKING:
89
+
90
+ def __init__(self, **values: Any) -> None:
91
+ super().__init__(**values)
92
+ for pass_field_name in self._pass_fields:
93
+ if pass_field_name in values:
94
+ setattr(self, pass_field_name, values[pass_field_name])
95
+
96
+ if not TYPE_CHECKING:
97
+ if PYDANTIC_V2:
98
+
99
+ def dict( # noqa: PLR0913
100
+ self,
101
+ *,
102
+ include: AbstractSet[int | str] | Mapping[int | str, Any] | None = None,
103
+ exclude: AbstractSet[int | str] | Mapping[int | str, Any] | None = None,
104
+ by_alias: bool = False,
105
+ exclude_unset: bool = False,
106
+ exclude_defaults: bool = False,
107
+ exclude_none: bool = False,
108
+ ) -> DictStrAny:
109
+ return self.model_dump(
110
+ include=include,
111
+ exclude=set(exclude or ()) | self._exclude_fields,
112
+ by_alias=by_alias,
113
+ exclude_unset=exclude_unset,
114
+ exclude_defaults=exclude_defaults,
115
+ exclude_none=exclude_none,
116
+ )
41
117
 
42
- def __init__(self, **values: Any) -> None: # type: ignore
43
- super().__init__(**values)
44
- for pass_field_name in self._pass_fields:
45
- if pass_field_name in values:
46
- setattr(self, pass_field_name, values[pass_field_name])
118
+ else:
47
119
 
48
- def dict(
49
- self,
50
- *,
51
- include: Union['AbstractSetIntStr', 'MappingIntStrAny'] = None,
52
- exclude: Union['AbstractSetIntStr', 'MappingIntStrAny'] = None,
53
- by_alias: bool = False,
54
- skip_defaults: bool = None,
55
- exclude_unset: bool = False,
56
- exclude_defaults: bool = False,
57
- exclude_none: bool = False,
58
- ) -> 'DictStrAny':
59
- return super().dict(
60
- include=include,
61
- exclude=set(exclude or ()) | self._exclude_fields,
62
- by_alias=by_alias,
63
- skip_defaults=skip_defaults,
64
- exclude_unset=exclude_unset,
65
- exclude_defaults=exclude_defaults,
66
- exclude_none=exclude_none,
67
- )
120
+ def dict( # noqa: PLR0913
121
+ self,
122
+ *,
123
+ include: AbstractSet[int | str] | Mapping[int | str, Any] | None = None,
124
+ exclude: AbstractSet[int | str] | Mapping[int | str, Any] | None = None,
125
+ by_alias: bool = False,
126
+ skip_defaults: bool | None = None,
127
+ exclude_unset: bool = False,
128
+ exclude_defaults: bool = False,
129
+ exclude_none: bool = False,
130
+ ) -> DictStrAny:
131
+ return super().dict(
132
+ include=include,
133
+ exclude=set(exclude or ()) | self._exclude_fields,
134
+ by_alias=by_alias,
135
+ skip_defaults=skip_defaults,
136
+ exclude_unset=exclude_unset,
137
+ exclude_defaults=exclude_defaults,
138
+ exclude_none=exclude_none,
139
+ )
68
140
 
69
141
 
70
142
  class Reference(_BaseModel):
143
+ """Represents a reference to a model in the schema.
144
+
145
+ Tracks path, name, and relationships between models for resolution.
146
+ """
147
+
71
148
  path: str
72
- original_name: str = ''
149
+ original_name: str = ""
73
150
  name: str
151
+ duplicate_name: Optional[str] = None # noqa: UP045
74
152
  loaded: bool = True
75
- source: Optional[Any] = None
76
- children: List[Any] = []
77
- _exclude_fields: ClassVar = {'children'}
153
+ source: Optional[ReferenceChild] = None # noqa: UP045
154
+ children: list[ReferenceChild] = Field(default_factory=list)
155
+ _exclude_fields: ClassVar[set[str]] = {"children"}
156
+
157
+ @model_validator(mode="before")
158
+ def validate_original_name(cls, values: Any) -> Any: # noqa: N805
159
+ """Assign name to original_name if original_name is empty."""
160
+ if not isinstance(values, dict): # pragma: no cover
161
+ return values
162
+ original_name = values.get("original_name")
163
+ if original_name:
164
+ return values
165
+
166
+ values["original_name"] = values.get("name", original_name)
167
+ return values
168
+
169
+ if PYDANTIC_V2:
170
+ # TODO[pydantic]: The following keys were removed: `copy_on_model_validation`.
171
+ # Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-config for more information.
172
+ model_config = ConfigDict( # pyright: ignore[reportAssignmentType]
173
+ arbitrary_types_allowed=True,
174
+ ignored_types=(cached_property,),
175
+ revalidate_instances="never",
176
+ )
177
+ else:
78
178
 
79
- @validator('original_name')
80
- def validate_original_name(cls, v: Any, values: Dict[str, Any]) -> str:
81
- """
82
- If original_name is empty then, `original_name` is assigned `name`
83
- """
84
- if v: # pragma: no cover
85
- return v
86
- return values.get('name', v) # pragma: no cover
179
+ class Config:
180
+ """Pydantic v1 configuration for Reference model."""
87
181
 
88
- class Config:
89
- arbitrary_types_allowed = True
90
- keep_untouched = (cached_property,)
182
+ arbitrary_types_allowed = True
183
+ keep_untouched = (cached_property,)
184
+ copy_on_model_validation = False if version.parse(pydantic.VERSION) < version.parse("1.9.2") else "none"
91
185
 
92
186
  @property
93
187
  def short_name(self) -> str:
94
- return self.name.rsplit('.', 1)[-1]
188
+ """Return the last component of the dotted name."""
189
+ return self.name.rsplit(".", 1)[-1]
190
+
191
+ def replace_children_references(self, new_reference: Reference) -> None:
192
+ """Replace all DataType children's reference with new_reference."""
193
+ for child in self.children[:]:
194
+ if _is_data_type(child):
195
+ child.replace_reference(new_reference)
196
+
197
+ def iter_data_model_children(self) -> Iterator[DataModel]:
198
+ """Yield all DataModel children."""
199
+ for child in self.children:
200
+ if _is_data_model(child):
201
+ yield child
95
202
 
96
203
 
97
- SINGULAR_NAME_SUFFIX: str = 'Item'
204
+ SINGULAR_NAME_SUFFIX: str = "Item"
98
205
 
99
- ID_PATTERN: Pattern[str] = re.compile(r'^#[^/].*')
206
+ ID_PATTERN: Pattern[str] = re.compile(r"^#[^/].*")
100
207
 
101
- T = TypeVar('T')
208
+ SPECIAL_PATH_MARKER: str = "#-datamodel-code-generator-#-"
209
+
210
+ T = TypeVar("T")
102
211
 
103
212
 
104
213
  @contextmanager
105
- def context_variable(
106
- setter: Callable[[T], None], current_value: T, new_value: T
107
- ) -> Generator[None, None, None]:
214
+ def context_variable(setter: Callable[[T], None], current_value: T, new_value: T) -> Generator[None, None, None]:
215
+ """Context manager that temporarily sets a value and restores it on exit."""
108
216
  previous_value: T = current_value
109
217
  setter(new_value)
110
218
  try:
@@ -113,96 +221,265 @@ def context_variable(
113
221
  setter(previous_value)
114
222
 
115
223
 
116
- _UNDER_SCORE_1: Pattern[str] = re.compile(r'(.)([A-Z][a-z]+)')
117
- _UNDER_SCORE_2: Pattern[str] = re.compile('([a-z0-9])([A-Z])')
118
-
119
-
120
- @lru_cache()
121
- def camel_to_snake(string: str) -> str:
122
- subbed = _UNDER_SCORE_1.sub(r'\1_\2', string)
123
- return _UNDER_SCORE_2.sub(r'\1_\2', subbed).lower()
124
-
125
-
126
224
  class FieldNameResolver:
127
- def __init__(
225
+ """Converts schema field names to valid Python identifiers."""
226
+
227
+ def __init__( # noqa: PLR0913, PLR0917
128
228
  self,
129
- aliases: Optional[Mapping[str, str]] = None,
130
- snake_case_field: bool = False,
131
- empty_field_name: Optional[str] = None,
132
- ):
229
+ aliases: Mapping[str, str] | None = None,
230
+ snake_case_field: bool = False, # noqa: FBT001, FBT002
231
+ empty_field_name: str | None = None,
232
+ original_delimiter: str | None = None,
233
+ special_field_name_prefix: str | None = None,
234
+ remove_special_field_name_prefix: bool = False, # noqa: FBT001, FBT002
235
+ capitalise_enum_members: bool = False, # noqa: FBT001, FBT002
236
+ no_alias: bool = False, # noqa: FBT001, FBT002
237
+ ) -> None:
238
+ """Initialize field name resolver with transformation options."""
133
239
  self.aliases: Mapping[str, str] = {} if aliases is None else {**aliases}
134
- self.empty_field_name: str = empty_field_name or '_'
240
+ self.empty_field_name: str = empty_field_name or "_"
135
241
  self.snake_case_field = snake_case_field
242
+ self.original_delimiter: str | None = original_delimiter
243
+ self.special_field_name_prefix: str | None = (
244
+ "field" if special_field_name_prefix is None else special_field_name_prefix
245
+ )
246
+ self.remove_special_field_name_prefix: bool = remove_special_field_name_prefix
247
+ self.capitalise_enum_members: bool = capitalise_enum_members
248
+ self.no_alias = no_alias
136
249
 
137
250
  @classmethod
138
- def _validate_field_name(cls, field_name: str) -> bool:
251
+ def _validate_field_name(cls, field_name: str) -> bool: # noqa: ARG003
252
+ """Check if a field name is valid. Subclasses may override."""
139
253
  return True
140
254
 
141
- def get_valid_name(
255
+ def get_valid_name( # noqa: PLR0912
142
256
  self,
143
257
  name: str,
144
- excludes: Optional[Set[str]] = None,
258
+ excludes: set[str] | None = None,
259
+ ignore_snake_case_field: bool = False, # noqa: FBT001, FBT002
260
+ upper_camel: bool = False, # noqa: FBT001, FBT002
145
261
  ) -> str:
262
+ """Convert a name to a valid Python identifier."""
146
263
  if not name:
147
264
  name = self.empty_field_name
148
- if name[0] == '#':
149
- name = name[1:]
150
- # TODO: when first character is a number
151
- name = re.sub(r'\W', '_', name)
265
+ if name[0] == "#":
266
+ name = name[1:] or self.empty_field_name
267
+
268
+ if self.snake_case_field and not ignore_snake_case_field and self.original_delimiter is not None:
269
+ name = snake_to_upper_camel(name, delimiter=self.original_delimiter)
270
+
271
+ name = re.sub(r"[¹²³⁴⁵⁶⁷⁸⁹]|\W", "_", name)
152
272
  if name[0].isnumeric():
153
- name = f'field_{name}'
154
- if self.snake_case_field:
273
+ name = f"{self.special_field_name_prefix}_{name}"
274
+
275
+ # We should avoid having a field begin with an underscore, as it
276
+ # causes pydantic to consider it as private
277
+ while name.startswith("_"):
278
+ if self.remove_special_field_name_prefix:
279
+ name = name[1:]
280
+ else:
281
+ name = f"{self.special_field_name_prefix}{name}"
282
+ break
283
+ if self.capitalise_enum_members or (self.snake_case_field and not ignore_snake_case_field):
155
284
  name = camel_to_snake(name)
156
285
  count = 1
157
286
  if iskeyword(name) or not self._validate_field_name(name):
158
- name += '_'
159
- new_name = name
160
- while not (
161
- new_name.isidentifier() or not self._validate_field_name(new_name)
162
- ) or (excludes and new_name in excludes):
163
- new_name = f'{name}_{count}'
287
+ name += "_"
288
+ if upper_camel:
289
+ new_name = snake_to_upper_camel(name)
290
+ elif self.capitalise_enum_members:
291
+ new_name = name.upper()
292
+ else:
293
+ new_name = name
294
+ while (
295
+ not new_name.isidentifier()
296
+ or iskeyword(new_name)
297
+ or (excludes and new_name in excludes)
298
+ or not self._validate_field_name(new_name)
299
+ ):
300
+ new_name = f"{name}{count}" if upper_camel else f"{name}_{count}"
164
301
  count += 1
165
302
  return new_name
166
303
 
167
304
  def get_valid_field_name_and_alias(
168
- self, field_name: str, excludes: Optional[Set[str]] = None
169
- ) -> Tuple[str, Optional[str]]:
305
+ self,
306
+ field_name: str,
307
+ excludes: set[str] | None = None,
308
+ path: list[str] | None = None,
309
+ class_name: str | None = None,
310
+ ) -> tuple[str, str | None]:
311
+ """Get valid field name and original alias if different.
312
+
313
+ Supports hierarchical alias resolution with the following priority:
314
+ 1. Scoped aliases (ClassName.field_name) - class-level specificity
315
+ 2. Flat aliases (field_name) - applies to all occurrences
316
+
317
+ Args:
318
+ field_name: The original field name from the schema.
319
+ excludes: Set of names to avoid when generating valid names.
320
+ path: Unused, kept for backward compatibility.
321
+ class_name: Optional class name for scoped alias resolution.
322
+ """
323
+ del path
324
+ if class_name:
325
+ scoped_key = f"{class_name}.{field_name}"
326
+ if scoped_key in self.aliases:
327
+ return self.aliases[scoped_key], field_name
328
+
170
329
  if field_name in self.aliases:
171
330
  return self.aliases[field_name], field_name
331
+
172
332
  valid_name = self.get_valid_name(field_name, excludes=excludes)
173
- return valid_name, None if field_name == valid_name else field_name
333
+ return (
334
+ valid_name,
335
+ None if self.no_alias or field_name == valid_name else field_name,
336
+ )
174
337
 
175
338
 
176
339
  class PydanticFieldNameResolver(FieldNameResolver):
340
+ """Field name resolver that avoids Pydantic reserved names."""
341
+
177
342
  @classmethod
178
343
  def _validate_field_name(cls, field_name: str) -> bool:
344
+ """Check field name doesn't conflict with BaseModel attributes."""
345
+ # TODO: Support Pydantic V2
179
346
  return not hasattr(BaseModel, field_name)
180
347
 
181
348
 
182
349
  class EnumFieldNameResolver(FieldNameResolver):
183
- pass
350
+ """Field name resolver for enum members with special handling for reserved names.
351
+
352
+ When using --use-subclass-enum, enums inherit from types like str or int.
353
+ Member names that conflict with methods of these types cause type checker errors.
354
+ This class detects and handles such conflicts by adding underscore suffixes.
355
+
356
+ The _BUILTIN_TYPE_ATTRIBUTES set is intentionally static (not using hasattr)
357
+ to avoid runtime Python version differences affecting code generation.
358
+ Based on Python 3.8-3.14 method names (union of all versions for safety).
359
+ Note: 'mro' is handled explicitly in get_valid_name for backward compatibility.
360
+ """
361
+
362
+ _BUILTIN_TYPE_ATTRIBUTES: ClassVar[frozenset[str]] = frozenset({
363
+ "as_integer_ratio",
364
+ "bit_count",
365
+ "bit_length",
366
+ "capitalize",
367
+ "casefold",
368
+ "center",
369
+ "conjugate",
370
+ "count",
371
+ "decode",
372
+ "denominator",
373
+ "encode",
374
+ "endswith",
375
+ "expandtabs",
376
+ "find",
377
+ "format",
378
+ "format_map",
379
+ "from_bytes",
380
+ "from_number",
381
+ "fromhex",
382
+ "hex",
383
+ "imag",
384
+ "index",
385
+ "isalnum",
386
+ "isalpha",
387
+ "isascii",
388
+ "isdecimal",
389
+ "isdigit",
390
+ "isidentifier",
391
+ "islower",
392
+ "isnumeric",
393
+ "isprintable",
394
+ "isspace",
395
+ "istitle",
396
+ "isupper",
397
+ "is_integer",
398
+ "join",
399
+ "ljust",
400
+ "lower",
401
+ "lstrip",
402
+ "maketrans",
403
+ "numerator",
404
+ "partition",
405
+ "real",
406
+ "removeprefix",
407
+ "removesuffix",
408
+ "replace",
409
+ "rfind",
410
+ "rindex",
411
+ "rjust",
412
+ "rpartition",
413
+ "rsplit",
414
+ "rstrip",
415
+ "split",
416
+ "splitlines",
417
+ "startswith",
418
+ "strip",
419
+ "swapcase",
420
+ "title",
421
+ "to_bytes",
422
+ "translate",
423
+ "upper",
424
+ "zfill",
425
+ })
426
+
427
+ @classmethod
428
+ def _validate_field_name(cls, field_name: str) -> bool:
429
+ """Check field name doesn't conflict with subclass enum base type attributes.
430
+
431
+ When using --use-subclass-enum, enums inherit from types like str or int.
432
+ Member names that conflict with methods of these types (e.g., 'count' for str)
433
+ cause type checker errors. This method detects such conflicts.
434
+ """
435
+ return field_name not in cls._BUILTIN_TYPE_ATTRIBUTES
436
+
437
+ def get_valid_name(
438
+ self,
439
+ name: str,
440
+ excludes: set[str] | None = None,
441
+ ignore_snake_case_field: bool = False, # noqa: FBT001, FBT002
442
+ upper_camel: bool = False, # noqa: FBT001, FBT002
443
+ ) -> str:
444
+ """Convert name to valid enum member, handling reserved names."""
445
+ return super().get_valid_name(
446
+ name="mro_" if name == "mro" else name,
447
+ excludes={"mro"} | (excludes or set()),
448
+ ignore_snake_case_field=ignore_snake_case_field,
449
+ upper_camel=upper_camel,
450
+ )
184
451
 
185
452
 
186
453
  class ModelType(Enum):
454
+ """Type of model for field name resolution strategy."""
455
+
187
456
  PYDANTIC = auto()
188
457
  ENUM = auto()
189
458
  CLASS = auto()
190
459
 
191
460
 
192
- DEFAULT_FIELD_NAME_RESOLVERS: Dict[ModelType, Type[FieldNameResolver]] = {
461
+ DEFAULT_FIELD_NAME_RESOLVERS: dict[ModelType, type[FieldNameResolver]] = {
193
462
  ModelType.ENUM: EnumFieldNameResolver,
194
463
  ModelType.PYDANTIC: PydanticFieldNameResolver,
195
464
  ModelType.CLASS: FieldNameResolver,
196
465
  }
197
466
 
198
467
 
468
+ class ClassName(NamedTuple):
469
+ """A class name with optional duplicate name for disambiguation."""
470
+
471
+ name: str
472
+ duplicate_name: str | None
473
+
474
+
199
475
  def get_relative_path(base_path: PurePath, target_path: PurePath) -> PurePath:
476
+ """Calculate relative path from base to target."""
200
477
  if base_path == target_path:
201
- return Path('.')
478
+ return Path()
202
479
  if not target_path.is_absolute():
203
480
  return target_path
204
481
  parent_count: int = 0
205
- children: List[str] = []
482
+ children: list[str] = []
206
483
  for base_part, target_part in zip_longest(base_path.parts, target_path.parts):
207
484
  if base_part == target_part and not parent_count:
208
485
  continue
@@ -210,85 +487,110 @@ def get_relative_path(base_path: PurePath, target_path: PurePath) -> PurePath:
210
487
  parent_count += 1
211
488
  if target_part:
212
489
  children.append(target_part)
213
- return Path(*['..' for _ in range(parent_count)], *children)
490
+ return Path(*[".." for _ in range(parent_count)], *children)
214
491
 
215
492
 
216
- class ModelResolver:
217
- def __init__(
493
+ class ModelResolver: # noqa: PLR0904
494
+ """Manages model references, class names, and field name resolution.
495
+
496
+ Central registry for all model references during parsing, handling
497
+ name uniqueness, path resolution, and field name transformations.
498
+ """
499
+
500
+ def __init__( # noqa: PLR0913, PLR0917
218
501
  self,
219
- exclude_names: Set[str] = None,
220
- duplicate_name_suffix: Optional[str] = None,
221
- base_url: Optional[str] = None,
222
- singular_name_suffix: Optional[str] = None,
223
- aliases: Optional[Mapping[str, str]] = None,
224
- snake_case_field: bool = False,
225
- empty_field_name: Optional[str] = None,
226
- custom_class_name_generator: Optional[Callable[[str], str]] = None,
227
- base_path: Optional[Path] = None,
228
- field_name_resolver_classes: Optional[
229
- Dict[ModelType, Type[FieldNameResolver]]
230
- ] = None,
502
+ exclude_names: set[str] | None = None,
503
+ duplicate_name_suffix: str | None = None,
504
+ base_url: str | None = None,
505
+ singular_name_suffix: str | None = None,
506
+ aliases: Mapping[str, str] | None = None,
507
+ snake_case_field: bool = False, # noqa: FBT001, FBT002
508
+ empty_field_name: str | None = None,
509
+ custom_class_name_generator: Callable[[str], str] | None = None,
510
+ base_path: Path | None = None,
511
+ field_name_resolver_classes: dict[ModelType, type[FieldNameResolver]] | None = None,
512
+ original_field_name_delimiter: str | None = None,
513
+ special_field_name_prefix: str | None = None,
514
+ remove_special_field_name_prefix: bool = False, # noqa: FBT001, FBT002
515
+ capitalise_enum_members: bool = False, # noqa: FBT001, FBT002
516
+ no_alias: bool = False, # noqa: FBT001, FBT002
517
+ remove_suffix_number: bool = False, # noqa: FBT001, FBT002
518
+ parent_scoped_naming: bool = False, # noqa: FBT001, FBT002
519
+ treat_dot_as_module: bool = False, # noqa: FBT001, FBT002
231
520
  ) -> None:
232
- self.references: Dict[str, Reference] = {}
521
+ """Initialize model resolver with naming and resolution options."""
522
+ self.references: dict[str, Reference] = {}
233
523
  self._current_root: Sequence[str] = []
234
- self._root_id: Optional[str] = None
235
- self._root_id_base_path: Optional[str] = None
236
- self.ids: DefaultDict[str, Dict[str, str]] = defaultdict(dict)
237
- self.after_load_files: Set[str] = set()
238
- self.exclude_names: Set[str] = exclude_names or set()
239
- self.duplicate_name_suffix: Optional[str] = duplicate_name_suffix
240
- self._base_url: Optional[str] = base_url
524
+ self._root_id: str | None = None
525
+ self._root_id_base_path: str | None = None
526
+ self.ids: defaultdict[str, dict[str, str]] = defaultdict(dict)
527
+ self.after_load_files: set[str] = set()
528
+ self.exclude_names: set[str] = exclude_names or set()
529
+ self.duplicate_name_suffix: str | None = duplicate_name_suffix
530
+ self._base_url: str | None = base_url
241
531
  self.singular_name_suffix: str = (
242
- singular_name_suffix
243
- if isinstance(singular_name_suffix, str)
244
- else SINGULAR_NAME_SUFFIX
532
+ singular_name_suffix if isinstance(singular_name_suffix, str) else SINGULAR_NAME_SUFFIX
245
533
  )
246
534
  merged_field_name_resolver_classes = DEFAULT_FIELD_NAME_RESOLVERS.copy()
247
535
  if field_name_resolver_classes: # pragma: no cover
248
536
  merged_field_name_resolver_classes.update(field_name_resolver_classes)
249
- self.field_name_resolvers: Dict[ModelType, FieldNameResolver] = {
537
+ self.field_name_resolvers: dict[ModelType, FieldNameResolver] = {
250
538
  k: v(
251
539
  aliases=aliases,
252
540
  snake_case_field=snake_case_field,
253
541
  empty_field_name=empty_field_name,
542
+ original_delimiter=original_field_name_delimiter,
543
+ special_field_name_prefix=special_field_name_prefix,
544
+ remove_special_field_name_prefix=remove_special_field_name_prefix,
545
+ capitalise_enum_members=capitalise_enum_members if k == ModelType.ENUM else False,
546
+ no_alias=no_alias,
254
547
  )
255
548
  for k, v in merged_field_name_resolver_classes.items()
256
549
  }
257
- self.class_name_generator = (
258
- custom_class_name_generator or self.default_class_name_generator
259
- )
550
+ self.class_name_generator = custom_class_name_generator or self.default_class_name_generator
260
551
  self._base_path: Path = base_path or Path.cwd()
261
- self._current_base_path: Optional[Path] = self._base_path
552
+ self._current_base_path: Path | None = self._base_path
553
+ self.remove_suffix_number: bool = remove_suffix_number
554
+ self.parent_scoped_naming = parent_scoped_naming
555
+ self.treat_dot_as_module = treat_dot_as_module
262
556
 
263
557
  @property
264
- def current_base_path(self) -> Optional[Path]:
558
+ def current_base_path(self) -> Path | None:
559
+ """Return the current base path for file resolution."""
265
560
  return self._current_base_path
266
561
 
267
- def set_current_base_path(self, base_path: Optional[Path]) -> None:
562
+ def set_current_base_path(self, base_path: Path | None) -> None:
563
+ """Set the current base path for file resolution."""
268
564
  self._current_base_path = base_path
269
565
 
270
566
  @property
271
- def base_url(self) -> Optional[str]:
567
+ def base_url(self) -> str | None:
568
+ """Return the base URL for reference resolution."""
272
569
  return self._base_url
273
570
 
274
- def set_base_url(self, base_url: Optional[str]) -> None:
571
+ def set_base_url(self, base_url: str | None) -> None:
572
+ """Set the base URL for reference resolution."""
275
573
  self._base_url = base_url
276
574
 
277
575
  @contextmanager
278
- def current_base_path_context(
279
- self, base_path: Optional[Path]
280
- ) -> Generator[None, None, None]:
576
+ def current_base_path_context(self, base_path: Path | None) -> Generator[None, None, None]:
577
+ """Temporarily set the current base path within a context."""
281
578
  if base_path:
282
579
  base_path = (self._base_path / base_path).resolve()
283
- with context_variable(
284
- self.set_current_base_path, self.current_base_path, base_path
285
- ):
580
+ with context_variable(self.set_current_base_path, self.current_base_path, base_path):
286
581
  yield
287
582
 
288
583
  @contextmanager
289
- def base_url_context(self, base_url: str) -> Generator[None, None, None]:
290
-
291
- if self._base_url:
584
+ def base_url_context(self, base_url: str | None) -> Generator[None, None, None]:
585
+ """Temporarily set the base URL within a context.
586
+
587
+ Only sets the base_url if:
588
+ - The new value is actually a URL (http://, https://, or file://)
589
+ - OR _base_url was already set (switching between URLs)
590
+ This preserves backward compatibility for local file parsing where
591
+ this method was previously a no-op.
592
+ """
593
+ if self._base_url or (base_url and is_url(base_url)):
292
594
  with context_variable(self.set_base_url, self.base_url, base_url):
293
595
  yield
294
596
  else:
@@ -296,134 +598,162 @@ class ModelResolver:
296
598
 
297
599
  @property
298
600
  def current_root(self) -> Sequence[str]:
299
- if len(self._current_root) > 1:
300
- return self._current_root
601
+ """Return the current root path components."""
301
602
  return self._current_root
302
603
 
303
604
  def set_current_root(self, current_root: Sequence[str]) -> None:
605
+ """Set the current root path components."""
304
606
  self._current_root = current_root
305
607
 
306
608
  @contextmanager
307
- def current_root_context(
308
- self, current_root: Sequence[str]
309
- ) -> Generator[None, None, None]:
609
+ def current_root_context(self, current_root: Sequence[str]) -> Generator[None, None, None]:
610
+ """Temporarily set the current root path within a context."""
310
611
  with context_variable(self.set_current_root, self.current_root, current_root):
311
612
  yield
312
613
 
313
614
  @property
314
- def root_id(self) -> Optional[str]:
615
+ def root_id(self) -> str | None:
616
+ """Return the root identifier for the current schema."""
315
617
  return self._root_id
316
618
 
317
619
  @property
318
- def root_id_base_path(self) -> Optional[str]:
620
+ def root_id_base_path(self) -> str | None:
621
+ """Return the base path component of the root identifier."""
319
622
  return self._root_id_base_path
320
623
 
321
- def set_root_id(self, root_id: Optional[str]) -> None:
322
- if root_id and '/' in root_id:
323
- self._root_id_base_path = root_id.rsplit('/', 1)[0]
624
+ def set_root_id(self, root_id: str | None) -> None:
625
+ """Set the root identifier and extract its base path."""
626
+ if root_id and "/" in root_id:
627
+ self._root_id_base_path = root_id.rsplit("/", 1)[0]
324
628
  else:
325
629
  self._root_id_base_path = None
326
630
 
327
631
  self._root_id = root_id
328
632
 
329
633
  def add_id(self, id_: str, path: Sequence[str]) -> None:
330
- self.ids['/'.join(self.current_root)][id_] = self.resolve_ref(path)
634
+ """Register an identifier mapping to a resolved reference path."""
635
+ self.ids["/".join(self.current_root)][id_] = self.resolve_ref(path)
331
636
 
332
- def resolve_ref(self, path: Union[Sequence[str], str]) -> str:
333
- if isinstance(path, str):
334
- joined_path = path
335
- else:
336
- joined_path = self.join_path(path)
337
- if joined_path == '#':
637
+ def resolve_ref(self, path: Sequence[str] | str) -> str: # noqa: PLR0911, PLR0912, PLR0914
638
+ """Resolve a reference path to its canonical form."""
639
+ joined_path = path if isinstance(path, str) else self.join_path(path)
640
+ if joined_path == "#":
338
641
  return f"{'/'.join(self.current_root)}#"
339
- if (
340
- self.current_base_path
341
- and not self.base_url
342
- and joined_path[0] != '#'
343
- and not is_url(joined_path)
344
- ):
642
+ if self.current_base_path and not self.base_url and joined_path[0] != "#" and not is_url(joined_path):
345
643
  # resolve local file path
346
- file_path, *object_part = joined_path.split('#', 1)
644
+ file_path, fragment = joined_path.split("#", 1) if "#" in joined_path else (joined_path, "")
347
645
  resolved_file_path = Path(self.current_base_path, file_path).resolve()
348
- joined_path = get_relative_path(
349
- self._base_path, resolved_file_path
350
- ).as_posix()
351
- if object_part:
352
- joined_path += f'#{object_part[0]}'
353
- if ID_PATTERN.match(joined_path):
354
- ref: str = self.ids['/'.join(self.current_root)][joined_path]
355
- elif (
356
- '#' not in joined_path
357
- and self.root_id_base_path
358
- and self.current_root != path
359
- ):
360
- if Path(self._base_path, joined_path).is_file():
361
- ref = f'{joined_path}#'
362
- else:
363
- ref = f'{self.root_id_base_path}/{joined_path}#'
646
+ joined_path = get_relative_path(self._base_path, resolved_file_path).as_posix()
647
+ if fragment:
648
+ joined_path += f"#{fragment}"
649
+ if ID_PATTERN.match(joined_path) and SPECIAL_PATH_MARKER not in joined_path:
650
+ id_scope = "/".join(self.current_root)
651
+ scoped_ids = self.ids[id_scope]
652
+ ref: str | None = scoped_ids.get(joined_path)
653
+ if ref is None:
654
+ msg = (
655
+ f"Unresolved $id reference '{joined_path}' in scope '{id_scope or '<root>'}'. "
656
+ f"Known $id values: {', '.join(sorted(scoped_ids)) or '<none>'}"
657
+ )
658
+ raise Error(msg)
364
659
  else:
365
- if '#' not in joined_path:
366
- joined_path += '#'
367
- if joined_path[0] == '#':
368
- joined_path = f'{"/".join(self.current_root)}{joined_path}'
369
- delimiter = joined_path.index('#')
370
- ref = f"{''.join(joined_path[:delimiter])}#{''.join(joined_path[delimiter + 1:])}"
660
+ if "#" not in joined_path:
661
+ joined_path += "#"
662
+ elif joined_path[0] == "#" and self.current_root:
663
+ joined_path = f"{'/'.join(self.current_root)}{joined_path}"
664
+
665
+ file_path, fragment = joined_path.split("#", 1)
666
+ ref = f"{file_path}#{fragment}"
667
+ if (
668
+ self.root_id_base_path
669
+ and not self.base_url
670
+ and not (is_url(joined_path) or Path(self._base_path, file_path).is_file())
671
+ ):
672
+ ref = f"{self.root_id_base_path}/{ref}"
673
+
674
+ if is_url(ref):
675
+ file_part, path_part = ref.split("#", 1)
676
+ id_scope = "/".join(self.current_root)
677
+ scoped_ids = self.ids[id_scope]
678
+ if file_part in scoped_ids:
679
+ mapped_ref = scoped_ids[file_part]
680
+ if path_part:
681
+ mapped_base, mapped_fragment = mapped_ref.split("#", 1) if "#" in mapped_ref else (mapped_ref, "")
682
+ combined_fragment = f"{mapped_fragment.rstrip('/')}/{path_part.lstrip('/')}"
683
+ return f"{mapped_base}#{combined_fragment}"
684
+ return mapped_ref
685
+
371
686
  if self.base_url:
372
- from .http import join_url
687
+ from .http import join_url # noqa: PLC0415
688
+
689
+ effective_base = self.root_id or self.base_url
690
+ joined_url = join_url(effective_base, ref)
691
+ if "#" in joined_url:
692
+ return joined_url
693
+ return f"{joined_url}#"
373
694
 
374
- return join_url(self.base_url, ref)
375
695
  if is_url(ref):
376
- file_part, path_part = ref.split('#', 1)
696
+ file_part, path_part = ref.split("#", 1)
377
697
  if file_part == self.root_id:
378
- return f'{"/".join(self.current_root)}#{path_part}'
698
+ return f"{'/'.join(self.current_root)}#{path_part}"
699
+ target_url: ParseResult = urlparse(file_part)
700
+ if not (self.root_id and self.current_base_path):
701
+ return ref
702
+ root_id_url: ParseResult = urlparse(self.root_id)
703
+ if (target_url.scheme, target_url.netloc) == (
704
+ root_id_url.scheme,
705
+ root_id_url.netloc,
706
+ ): # pragma: no cover
707
+ target_url_path = Path(target_url.path)
708
+ target_path = (
709
+ self.current_base_path
710
+ / get_relative_path(Path(root_id_url.path).parent, target_url_path.parent)
711
+ / target_url_path.name
712
+ )
713
+ if target_path.exists():
714
+ return f"{target_path.resolve().relative_to(self._base_path)}#{path_part}"
715
+
379
716
  return ref
380
717
 
381
718
  def is_after_load(self, ref: str) -> bool:
719
+ """Check if a reference points to a file loaded after the current one."""
382
720
  if is_url(ref) or not self.current_base_path:
383
721
  return False
384
- file_part, *_ = ref.split('#', 1)
722
+ file_part, *_ = ref.split("#", 1)
385
723
  absolute_path = Path(self._base_path, file_part).resolve().as_posix()
386
- if self.is_external_root_ref(ref):
387
- return absolute_path in self.after_load_files
388
- elif self.is_external_ref(ref):
724
+ if self.is_external_root_ref(ref) or self.is_external_ref(ref):
389
725
  return absolute_path in self.after_load_files
390
726
  return False # pragma: no cover
391
727
 
392
728
  @staticmethod
393
729
  def is_external_ref(ref: str) -> bool:
394
- return '#' in ref and ref[0] != '#'
730
+ """Check if a reference points to an external file."""
731
+ return "#" in ref and ref[0] != "#"
395
732
 
396
733
  @staticmethod
397
734
  def is_external_root_ref(ref: str) -> bool:
398
- return ref[-1] == '#'
735
+ """Check if a reference points to an external file root."""
736
+ return bool(ref) and ref[-1] == "#"
399
737
 
400
738
  @staticmethod
401
739
  def join_path(path: Sequence[str]) -> str:
402
- joined_path = '/'.join(p for p in path if p).replace('/#', '#')
403
- if '#' not in joined_path:
404
- joined_path += '#'
740
+ """Join path components with slashes and normalize anchors."""
741
+ joined_path = "/".join(p for p in path if p).replace("/#", "#")
742
+ if "#" not in joined_path:
743
+ joined_path += "#"
405
744
  return joined_path
406
745
 
407
- def add_ref(self, ref: str, resolved: bool = False) -> Reference:
408
- if not resolved:
409
- path = self.resolve_ref(ref)
410
- else:
411
- path = ref
412
- reference = self.references.get(path)
413
- if reference:
746
+ def add_ref(self, ref: str, resolved: bool = False) -> Reference: # noqa: FBT001, FBT002
747
+ """Add a reference and return the Reference object."""
748
+ path = self.resolve_ref(ref) if not resolved else ref
749
+ if reference := self.references.get(path):
414
750
  return reference
415
- split_ref = ref.rsplit('/', 1)
751
+ split_ref = ref.rsplit("/", 1)
416
752
  if len(split_ref) == 1:
417
- original_name = Path(
418
- split_ref[0][:-1] if self.is_external_root_ref(path) else split_ref[0]
419
- ).stem
753
+ original_name = Path(split_ref[0].rstrip("#") if self.is_external_root_ref(path) else split_ref[0]).stem
420
754
  else:
421
- original_name = (
422
- Path(split_ref[1][:-1]).stem
423
- if self.is_external_root_ref(path)
424
- else split_ref[1]
425
- )
426
- name = self.get_class_name(original_name, unique=False)
755
+ original_name = Path(split_ref[1].rstrip("#")).stem if self.is_external_root_ref(path) else split_ref[1]
756
+ name = self.get_class_name(original_name, unique=False).name
427
757
  reference = Reference(
428
758
  path=path,
429
759
  original_name=original_name,
@@ -434,7 +764,16 @@ class ModelResolver:
434
764
  self.references[path] = reference
435
765
  return reference
436
766
 
437
- def add(
767
+ def _check_parent_scope_option(self, name: str, path: Sequence[str]) -> str:
768
+ if self.parent_scoped_naming:
769
+ parent_path = path[:-1]
770
+ while parent_path:
771
+ if parent_reference := self.references.get(self.join_path(parent_path)):
772
+ return f"{parent_reference.name}_{name}"
773
+ parent_path = parent_path[:-1]
774
+ return name
775
+
776
+ def add( # noqa: PLR0913
438
777
  self,
439
778
  path: Sequence[str],
440
779
  original_name: str,
@@ -442,23 +781,22 @@ class ModelResolver:
442
781
  class_name: bool = False,
443
782
  singular_name: bool = False,
444
783
  unique: bool = True,
445
- singular_name_suffix: Optional[str] = None,
784
+ singular_name_suffix: str | None = None,
446
785
  loaded: bool = False,
447
786
  ) -> Reference:
787
+ """Add or update a model reference with the given path and name."""
448
788
  joined_path = self.join_path(path)
449
- reference: Optional[Reference] = self.references.get(joined_path)
789
+ reference: Reference | None = self.references.get(joined_path)
450
790
  if reference:
451
791
  if loaded and not reference.loaded:
452
792
  reference.loaded = True
453
- if (
454
- not original_name
455
- or original_name == reference.original_name
456
- or original_name == reference.name
457
- ):
793
+ if not original_name or original_name in {reference.original_name, reference.name}:
458
794
  return reference
459
795
  name = original_name
796
+ duplicate_name: str | None = None
460
797
  if class_name:
461
- name = self.get_class_name(
798
+ name = self._check_parent_scope_option(name, path)
799
+ name, duplicate_name = self.get_class_name(
462
800
  name=name,
463
801
  unique=unique,
464
802
  reserved_name=reference.name if reference else None,
@@ -469,129 +807,173 @@ class ModelResolver:
469
807
  # TODO: create a validate for module name
470
808
  name = self.get_valid_field_name(name, model_type=ModelType.CLASS)
471
809
  if singular_name: # pragma: no cover
472
- name = get_singular_name(
473
- name, singular_name_suffix or self.singular_name_suffix
474
- )
810
+ name = get_singular_name(name, singular_name_suffix or self.singular_name_suffix)
475
811
  elif unique: # pragma: no cover
476
- name = self._get_uniq_name(name)
812
+ unique_name = self._get_unique_name(name)
813
+ if unique_name != name:
814
+ duplicate_name = name
815
+ name = unique_name
477
816
  if reference:
478
817
  reference.original_name = original_name
479
818
  reference.name = name
480
819
  reference.loaded = loaded
820
+ reference.duplicate_name = duplicate_name
481
821
  else:
482
822
  reference = Reference(
483
- path=joined_path, original_name=original_name, name=name, loaded=loaded
823
+ path=joined_path,
824
+ original_name=original_name,
825
+ name=name,
826
+ loaded=loaded,
827
+ duplicate_name=duplicate_name,
484
828
  )
485
829
  self.references[joined_path] = reference
486
830
  return reference
487
831
 
488
- def get(self, path: Union[Sequence[str], str]) -> Optional[Reference]:
832
+ def get(self, path: Sequence[str] | str) -> Reference | None:
833
+ """Get a reference by path, returning None if not found."""
489
834
  return self.references.get(self.resolve_ref(path))
490
835
 
836
+ def delete(self, path: Sequence[str] | str) -> None:
837
+ """Delete a reference by path if it exists."""
838
+ resolved = self.resolve_ref(path)
839
+ if resolved in self.references:
840
+ del self.references[resolved]
841
+
491
842
  def default_class_name_generator(self, name: str) -> str:
843
+ """Generate a valid class name from a string."""
492
844
  # TODO: create a validate for class name
493
- name = self.field_name_resolvers[ModelType.CLASS].get_valid_name(name)
494
- return snake_to_upper_camel(name)
845
+ return self.field_name_resolvers[ModelType.CLASS].get_valid_name(
846
+ name, ignore_snake_case_field=True, upper_camel=True
847
+ )
495
848
 
496
849
  def get_class_name(
497
850
  self,
498
851
  name: str,
499
- unique: bool = True,
500
- reserved_name: Optional[str] = None,
501
- singular_name: bool = False,
502
- singular_name_suffix: Optional[str] = None,
503
- ) -> str:
504
-
505
- if '.' in name:
506
- split_name = name.split('.')
507
- prefix = '.'.join(
852
+ unique: bool = True, # noqa: FBT001, FBT002
853
+ reserved_name: str | None = None,
854
+ singular_name: bool = False, # noqa: FBT001, FBT002
855
+ singular_name_suffix: str | None = None,
856
+ ) -> ClassName:
857
+ """Generate a unique class name with optional singularization."""
858
+ if "." in name:
859
+ split_name = name.split(".")
860
+ prefix = ".".join(
508
861
  # TODO: create a validate for class name
509
- self.field_name_resolvers[ModelType.CLASS].get_valid_name(n)
862
+ self.field_name_resolvers[ModelType.CLASS].get_valid_name(n, ignore_snake_case_field=True)
510
863
  for n in split_name[:-1]
511
864
  )
512
- prefix += '.'
865
+ prefix += "."
513
866
  class_name = split_name[-1]
514
867
  else:
515
- prefix = ''
868
+ prefix = ""
516
869
  class_name = name
517
870
 
518
871
  class_name = self.class_name_generator(class_name)
519
872
 
520
873
  if singular_name:
521
- class_name = get_singular_name(
522
- class_name, singular_name_suffix or self.singular_name_suffix
523
- )
524
-
874
+ class_name = get_singular_name(class_name, singular_name_suffix or self.singular_name_suffix)
875
+ duplicate_name: str | None = None
525
876
  if unique:
526
877
  if reserved_name == class_name:
527
- return class_name
528
- class_name = self._get_uniq_name(class_name, camel=True)
529
-
530
- return f'{prefix}{class_name}'
531
-
532
- def _get_uniq_name(self, name: str, camel: bool = False) -> str:
533
- uniq_name: str = name
534
- count: int = 1
535
- reference_names = {
536
- r.name for r in self.references.values()
537
- } | self.exclude_names
538
- while uniq_name in reference_names:
878
+ return ClassName(name=class_name, duplicate_name=duplicate_name)
879
+
880
+ unique_name = self._get_unique_name(class_name, camel=True)
881
+ if unique_name != class_name:
882
+ duplicate_name = class_name
883
+ class_name = unique_name
884
+ return ClassName(name=f"{prefix}{class_name}", duplicate_name=duplicate_name)
885
+
886
+ def _get_unique_name(self, name: str, camel: bool = False) -> str: # noqa: FBT001, FBT002
887
+ unique_name: str = name
888
+ count: int = 0 if self.remove_suffix_number else 1
889
+ reference_names = {r.name for r in self.references.values()} | self.exclude_names
890
+ while unique_name in reference_names:
539
891
  if self.duplicate_name_suffix:
540
- name_parts: List[Union[str, int]] = [
892
+ name_parts: list[str | int] = [
541
893
  name,
542
894
  self.duplicate_name_suffix,
543
895
  count - 1,
544
896
  ]
545
897
  else:
546
898
  name_parts = [name, count]
547
- delimiter = '' if camel else '_'
548
- uniq_name = delimiter.join(str(p) for p in name_parts if p)
899
+ delimiter = "" if camel else "_"
900
+ unique_name = delimiter.join(str(p) for p in name_parts if p) if count else name
549
901
  count += 1
550
- return uniq_name
902
+ return unique_name
551
903
 
552
904
  @classmethod
553
905
  def validate_name(cls, name: str) -> bool:
906
+ """Check if a name is a valid Python identifier."""
554
907
  return name.isidentifier() and not iskeyword(name)
555
908
 
556
909
  def get_valid_field_name(
557
910
  self,
558
911
  name: str,
559
- excludes: Optional[Set[str]] = None,
912
+ excludes: set[str] | None = None,
560
913
  model_type: ModelType = ModelType.PYDANTIC,
561
914
  ) -> str:
915
+ """Get a valid field name for the specified model type."""
562
916
  return self.field_name_resolvers[model_type].get_valid_name(name, excludes)
563
917
 
564
918
  def get_valid_field_name_and_alias(
565
919
  self,
566
920
  field_name: str,
567
- excludes: Optional[Set[str]] = None,
921
+ excludes: set[str] | None = None,
568
922
  model_type: ModelType = ModelType.PYDANTIC,
569
- ) -> Tuple[str, Optional[str]]:
923
+ path: list[str] | None = None,
924
+ class_name: str | None = None,
925
+ ) -> tuple[str, str | None]:
926
+ """Get a valid field name and alias for the specified model type.
927
+
928
+ Args:
929
+ field_name: The original field name from the schema.
930
+ excludes: Set of names to avoid when generating valid names.
931
+ model_type: The type of model (PYDANTIC, ENUM, or CLASS).
932
+ path: Unused, kept for backward compatibility.
933
+ class_name: Optional class name for scoped alias resolution.
934
+
935
+ Returns:
936
+ A tuple of (valid_field_name, alias_or_none).
937
+ """
938
+ del path
570
939
  return self.field_name_resolvers[model_type].get_valid_field_name_and_alias(
571
- field_name, excludes
940
+ field_name, excludes, class_name=class_name
572
941
  )
573
942
 
574
943
 
575
- @lru_cache()
944
+ def _get_inflect_engine() -> inflect.engine:
945
+ """Get or create the inflect engine lazily."""
946
+ global _inflect_engine # noqa: PLW0603
947
+ if _inflect_engine is None:
948
+ import inflect # noqa: PLC0415
949
+
950
+ _inflect_engine = inflect.engine()
951
+ return _inflect_engine
952
+
953
+
954
+ _inflect_engine: inflect.engine | None = None
955
+
956
+
957
+ @lru_cache
576
958
  def get_singular_name(name: str, suffix: str = SINGULAR_NAME_SUFFIX) -> str:
577
- singular_name = inflect_engine.singular_noun(name)
959
+ """Convert a plural name to singular form."""
960
+ singular_name = _get_inflect_engine().singular_noun(cast("inflect.Word", name))
578
961
  if singular_name is False:
579
- singular_name = f'{name}{suffix}'
580
- return singular_name
962
+ singular_name = f"{name}{suffix}"
963
+ return singular_name # pyright: ignore[reportReturnType]
581
964
 
582
965
 
583
- @lru_cache()
584
- def snake_to_upper_camel(word: str) -> str:
585
- prefix = ''
586
- if word.startswith('_'):
587
- prefix = '_'
966
+ @lru_cache
967
+ def snake_to_upper_camel(word: str, delimiter: str = "_") -> str:
968
+ """Convert snake_case or delimited string to UpperCamelCase."""
969
+ prefix = ""
970
+ if word.startswith(delimiter):
971
+ prefix = "_"
588
972
  word = word[1:]
589
973
 
590
- return prefix + ''.join(x[0].upper() + x[1:] for x in word.split('_') if x)
974
+ return prefix + "".join(x[0].upper() + x[1:] for x in word.split(delimiter) if x)
591
975
 
592
976
 
593
977
  def is_url(ref: str) -> bool:
594
- return ref.startswith(('https://', 'http://'))
595
-
596
-
597
- inflect_engine = inflect.engine()
978
+ """Check if a reference string is a URL (HTTP, HTTPS, or file scheme)."""
979
+ return ref.startswith(("https://", "http://", "file://"))