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.
- datamodel_code_generator/__init__.py +654 -185
- datamodel_code_generator/__main__.py +872 -388
- datamodel_code_generator/arguments.py +798 -0
- datamodel_code_generator/cli_options.py +295 -0
- datamodel_code_generator/format.py +292 -54
- datamodel_code_generator/http.py +85 -10
- datamodel_code_generator/imports.py +152 -43
- datamodel_code_generator/model/__init__.py +138 -1
- datamodel_code_generator/model/base.py +531 -120
- datamodel_code_generator/model/dataclass.py +211 -0
- datamodel_code_generator/model/enum.py +133 -12
- datamodel_code_generator/model/imports.py +22 -0
- datamodel_code_generator/model/msgspec.py +462 -0
- datamodel_code_generator/model/pydantic/__init__.py +30 -25
- datamodel_code_generator/model/pydantic/base_model.py +304 -100
- datamodel_code_generator/model/pydantic/custom_root_type.py +11 -2
- datamodel_code_generator/model/pydantic/dataclass.py +15 -4
- datamodel_code_generator/model/pydantic/imports.py +40 -27
- datamodel_code_generator/model/pydantic/types.py +188 -96
- datamodel_code_generator/model/pydantic_v2/__init__.py +51 -0
- datamodel_code_generator/model/pydantic_v2/base_model.py +268 -0
- datamodel_code_generator/model/pydantic_v2/imports.py +15 -0
- datamodel_code_generator/model/pydantic_v2/root_model.py +35 -0
- datamodel_code_generator/model/pydantic_v2/types.py +143 -0
- datamodel_code_generator/model/scalar.py +124 -0
- datamodel_code_generator/model/template/Enum.jinja2 +15 -2
- datamodel_code_generator/model/template/ScalarTypeAliasAnnotation.jinja2 +6 -0
- datamodel_code_generator/model/template/ScalarTypeAliasType.jinja2 +6 -0
- datamodel_code_generator/model/template/ScalarTypeStatement.jinja2 +6 -0
- datamodel_code_generator/model/template/TypeAliasAnnotation.jinja2 +20 -0
- datamodel_code_generator/model/template/TypeAliasType.jinja2 +20 -0
- datamodel_code_generator/model/template/TypeStatement.jinja2 +20 -0
- datamodel_code_generator/model/template/TypedDict.jinja2 +5 -0
- datamodel_code_generator/model/template/TypedDictClass.jinja2 +25 -0
- datamodel_code_generator/model/template/TypedDictFunction.jinja2 +24 -0
- datamodel_code_generator/model/template/UnionTypeAliasAnnotation.jinja2 +10 -0
- datamodel_code_generator/model/template/UnionTypeAliasType.jinja2 +10 -0
- datamodel_code_generator/model/template/UnionTypeStatement.jinja2 +10 -0
- datamodel_code_generator/model/template/dataclass.jinja2 +50 -0
- datamodel_code_generator/model/template/msgspec.jinja2 +55 -0
- datamodel_code_generator/model/template/pydantic/BaseModel.jinja2 +17 -4
- datamodel_code_generator/model/template/pydantic/BaseModel_root.jinja2 +12 -4
- datamodel_code_generator/model/template/pydantic/Config.jinja2 +1 -1
- datamodel_code_generator/model/template/pydantic/dataclass.jinja2 +15 -2
- datamodel_code_generator/model/template/pydantic_v2/BaseModel.jinja2 +57 -0
- datamodel_code_generator/model/template/pydantic_v2/ConfigDict.jinja2 +5 -0
- datamodel_code_generator/model/template/pydantic_v2/RootModel.jinja2 +48 -0
- datamodel_code_generator/model/type_alias.py +70 -0
- datamodel_code_generator/model/typed_dict.py +161 -0
- datamodel_code_generator/model/types.py +106 -0
- datamodel_code_generator/model/union.py +105 -0
- datamodel_code_generator/parser/__init__.py +30 -12
- datamodel_code_generator/parser/_graph.py +67 -0
- datamodel_code_generator/parser/_scc.py +171 -0
- datamodel_code_generator/parser/base.py +2426 -380
- datamodel_code_generator/parser/graphql.py +652 -0
- datamodel_code_generator/parser/jsonschema.py +2518 -647
- datamodel_code_generator/parser/openapi.py +631 -222
- datamodel_code_generator/py.typed +0 -0
- datamodel_code_generator/pydantic_patch.py +28 -0
- datamodel_code_generator/reference.py +672 -290
- datamodel_code_generator/types.py +521 -145
- datamodel_code_generator/util.py +155 -0
- datamodel_code_generator/watch.py +65 -0
- datamodel_code_generator-0.45.0.dist-info/METADATA +301 -0
- datamodel_code_generator-0.45.0.dist-info/RECORD +69 -0
- {datamodel_code_generator-0.11.12.dist-info → datamodel_code_generator-0.45.0.dist-info}/WHEEL +1 -1
- datamodel_code_generator-0.45.0.dist-info/entry_points.txt +2 -0
- datamodel_code_generator/version.py +0 -1
- datamodel_code_generator-0.11.12.dist-info/METADATA +0 -440
- datamodel_code_generator-0.11.12.dist-info/RECORD +0 -31
- datamodel_code_generator-0.11.12.dist-info/entry_points.txt +0 -3
- {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
|
-
|
|
15
|
-
Dict,
|
|
16
|
-
Generator,
|
|
17
|
-
List,
|
|
18
|
-
Mapping,
|
|
24
|
+
NamedTuple,
|
|
19
25
|
Optional,
|
|
20
|
-
|
|
21
|
-
Sequence,
|
|
22
|
-
Set,
|
|
23
|
-
Tuple,
|
|
24
|
-
Type,
|
|
26
|
+
Protocol,
|
|
25
27
|
TypeVar,
|
|
26
|
-
|
|
28
|
+
cast,
|
|
29
|
+
runtime_checkable,
|
|
27
30
|
)
|
|
31
|
+
from urllib.parse import ParseResult, urlparse
|
|
28
32
|
|
|
29
|
-
import
|
|
30
|
-
from
|
|
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
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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[
|
|
76
|
-
children:
|
|
77
|
-
_exclude_fields: ClassVar = {
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
+
"""Converts schema field names to valid Python identifiers."""
|
|
226
|
+
|
|
227
|
+
def __init__( # noqa: PLR0913, PLR0917
|
|
128
228
|
self,
|
|
129
|
-
aliases:
|
|
130
|
-
snake_case_field: bool = False,
|
|
131
|
-
empty_field_name:
|
|
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:
|
|
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
|
-
|
|
151
|
-
|
|
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
|
|
154
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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,
|
|
169
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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(*[
|
|
490
|
+
return Path(*[".." for _ in range(parent_count)], *children)
|
|
214
491
|
|
|
215
492
|
|
|
216
|
-
class ModelResolver:
|
|
217
|
-
|
|
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:
|
|
220
|
-
duplicate_name_suffix:
|
|
221
|
-
base_url:
|
|
222
|
-
singular_name_suffix:
|
|
223
|
-
aliases:
|
|
224
|
-
snake_case_field: bool = False,
|
|
225
|
-
empty_field_name:
|
|
226
|
-
custom_class_name_generator:
|
|
227
|
-
base_path:
|
|
228
|
-
field_name_resolver_classes:
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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:
|
|
235
|
-
self._root_id_base_path:
|
|
236
|
-
self.ids:
|
|
237
|
-
self.after_load_files:
|
|
238
|
-
self.exclude_names:
|
|
239
|
-
self.duplicate_name_suffix:
|
|
240
|
-
self._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:
|
|
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:
|
|
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) ->
|
|
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:
|
|
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) ->
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) ->
|
|
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) ->
|
|
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:
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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:
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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,
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
ref: str =
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
|
366
|
-
joined_path +=
|
|
367
|
-
|
|
368
|
-
joined_path = f'
|
|
369
|
-
|
|
370
|
-
|
|
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(
|
|
696
|
+
file_part, path_part = ref.split("#", 1)
|
|
377
697
|
if file_part == self.root_id:
|
|
378
|
-
return f'
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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
|
-
|
|
494
|
-
|
|
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:
|
|
501
|
-
singular_name: bool = False,
|
|
502
|
-
singular_name_suffix:
|
|
503
|
-
) ->
|
|
504
|
-
|
|
505
|
-
if
|
|
506
|
-
split_name = name.split(
|
|
507
|
-
prefix =
|
|
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
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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:
|
|
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 =
|
|
548
|
-
|
|
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
|
|
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:
|
|
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:
|
|
921
|
+
excludes: set[str] | None = None,
|
|
568
922
|
model_type: ModelType = ModelType.PYDANTIC,
|
|
569
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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 +
|
|
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
|
-
|
|
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://"))
|