Skip to content

Commit 5011903

Browse files
authored
Fix extra underscore on enum members like replace with --capitalise-enum-members (#2999)
1 parent 907a1a8 commit 5011903

10 files changed

+175
-97
lines changed

src/datamodel_code_generator/format.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ class PythonVersion(Enum):
7474
PY_313 = "3.13"
7575
PY_314 = "3.14"
7676

77+
@cached_property
78+
def version_key(self) -> tuple[int, int]:
79+
"""Return (major, minor) tuple for version comparison."""
80+
major, minor = self.value.split(".")
81+
return int(major), int(minor)
82+
7783
@cached_property
7884
def _is_py_310_or_later(self) -> bool: # pragma: no cover
7985
return True # 3.10+ always true since minimum is PY_310

src/datamodel_code_generator/parser/base.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -126,17 +126,12 @@ def __ge__(self, value: Any, /) -> bool: ... # noqa: D105
126126
}
127127

128128

129-
def _python_version_key(python_version: PythonVersion) -> tuple[int, int]:
130-
major, minor = python_version.value.split(".")
131-
return int(major), int(minor)
132-
133-
134129
def _get_builtin_names_for_target(target_python_version: PythonVersion) -> frozenset[str]:
135130
builtin_names = set(_BUILTIN_NAMES)
136-
target_key = _python_version_key(target_python_version)
131+
target_key = target_python_version.version_key
137132

138133
for introduced_version, names in _BUILTIN_NAMES_INTRODUCED_IN.items():
139-
if target_key >= _python_version_key(introduced_version):
134+
if target_key >= introduced_version.version_key:
140135
builtin_names.update(names)
141136
else:
142137
builtin_names.difference_update(names)
@@ -1061,6 +1056,8 @@ def __init__( # noqa: PLR0912, PLR0915
10611056
remove_special_field_name_prefix=config.remove_special_field_name_prefix,
10621057
capitalise_enum_members=config.capitalise_enum_members,
10631058
no_alias=config.no_alias,
1059+
use_subclass_enum=config.use_subclass_enum,
1060+
target_python_version=config.target_python_version,
10641061
parent_scoped_naming=config.parent_scoped_naming,
10651062
treat_dot_as_module=config.treat_dot_as_module,
10661063
naming_strategy=config.naming_strategy,

src/datamodel_code_generator/reference.py

Lines changed: 48 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636

3737
from datamodel_code_generator import Error, NamingStrategy
3838
from datamodel_code_generator.enums import ClassNameAffixScope
39+
from datamodel_code_generator.format import PythonVersion
3940
from datamodel_code_generator.util import ConfigDict, camel_to_snake, is_pydantic_v2, model_validator
4041

4142
if TYPE_CHECKING:
@@ -221,6 +222,27 @@ def context_variable(setter: Callable[[T], None], current_value: T, new_value: T
221222
setter(previous_value)
222223

223224

225+
_BUILTIN_TYPE_ATTRIBUTES: frozenset[str] = frozenset(
226+
name for cls in (str, int, float, bytes) for name in dir(cls) if not name.startswith("_")
227+
)
228+
229+
_BUILTIN_TYPE_ATTRIBUTES_INTRODUCED_IN: dict[PythonVersion, frozenset[str]] = {
230+
PythonVersion.PY_314: frozenset({"from_number"}),
231+
}
232+
233+
234+
def _get_builtin_type_attributes_for_target(target: PythonVersion) -> frozenset[str]:
235+
"""Get builtin type attributes adjusted for the target Python version."""
236+
attrs = set(_BUILTIN_TYPE_ATTRIBUTES)
237+
target_key = target.version_key
238+
for ver, names in _BUILTIN_TYPE_ATTRIBUTES_INTRODUCED_IN.items():
239+
if target_key >= ver.version_key:
240+
attrs.update(names)
241+
else:
242+
attrs.difference_update(names)
243+
return frozenset(attrs)
244+
245+
224246
class FieldNameResolver:
225247
"""Converts schema field names to valid Python identifiers."""
226248

@@ -234,6 +256,8 @@ def __init__( # noqa: PLR0913, PLR0917
234256
remove_special_field_name_prefix: bool = False, # noqa: FBT001, FBT002
235257
capitalise_enum_members: bool = False, # noqa: FBT001, FBT002
236258
no_alias: bool = False, # noqa: FBT001, FBT002
259+
use_subclass_enum: bool = False, # noqa: FBT001, FBT002
260+
target_python_version: PythonVersion | None = None,
237261
) -> None:
238262
"""Initialize field name resolver with transformation options."""
239263
self.aliases: Mapping[str, str | list[str]] = {} if aliases is None else {**aliases}
@@ -246,9 +270,10 @@ def __init__( # noqa: PLR0913, PLR0917
246270
self.remove_special_field_name_prefix: bool = remove_special_field_name_prefix
247271
self.capitalise_enum_members: bool = capitalise_enum_members
248272
self.no_alias = no_alias
273+
self.use_subclass_enum: bool = use_subclass_enum
274+
self.target_python_version = target_python_version
249275

250-
@classmethod
251-
def _validate_field_name(cls, field_name: str) -> bool: # noqa: ARG003
276+
def _validate_field_name(self, field_name: str) -> bool: # noqa: ARG002, PLR6301
252277
"""Check if a field name is valid. Subclasses may override."""
253278
return True
254279

@@ -283,7 +308,8 @@ def get_valid_name( # noqa: PLR0912
283308
if self.capitalise_enum_members or (self.snake_case_field and not ignore_snake_case_field):
284309
name = camel_to_snake(name)
285310
count = 1
286-
if iskeyword(name) or not self._validate_field_name(name):
311+
validated_name = name.upper() if self.capitalise_enum_members else name
312+
if iskeyword(validated_name) or not self._validate_field_name(validated_name):
287313
name += "_"
288314
if upper_camel:
289315
new_name = snake_to_upper_camel(name)
@@ -357,100 +383,28 @@ def get_valid_field_name_and_alias(
357383
class PydanticFieldNameResolver(FieldNameResolver):
358384
"""Field name resolver that avoids Pydantic reserved names."""
359385

360-
@classmethod
361-
def _validate_field_name(cls, field_name: str) -> bool:
386+
def _validate_field_name(self, field_name: str) -> bool: # noqa: PLR6301
362387
"""Check field name doesn't conflict with BaseModel attributes."""
363388
# TODO: Support Pydantic V2
364389
return not hasattr(BaseModel, field_name)
365390

366391

367392
class EnumFieldNameResolver(FieldNameResolver):
368-
"""Field name resolver for enum members with special handling for reserved names.
369-
370-
When using --use-subclass-enum, enums inherit from types like str or int.
371-
Member names that conflict with methods of these types cause type checker errors.
372-
This class detects and handles such conflicts by adding underscore suffixes.
373-
374-
The _BUILTIN_TYPE_ATTRIBUTES set is intentionally static (not using hasattr)
375-
to avoid runtime Python version differences affecting code generation.
376-
Based on Python 3.8-3.14 method names (union of all versions for safety).
377-
Note: 'mro' is handled explicitly in get_valid_name for backward compatibility.
378-
"""
379-
380-
_BUILTIN_TYPE_ATTRIBUTES: ClassVar[frozenset[str]] = frozenset({
381-
"as_integer_ratio",
382-
"bit_count",
383-
"bit_length",
384-
"capitalize",
385-
"casefold",
386-
"center",
387-
"conjugate",
388-
"count",
389-
"decode",
390-
"denominator",
391-
"encode",
392-
"endswith",
393-
"expandtabs",
394-
"find",
395-
"format",
396-
"format_map",
397-
"from_bytes",
398-
"from_number",
399-
"fromhex",
400-
"hex",
401-
"imag",
402-
"index",
403-
"isalnum",
404-
"isalpha",
405-
"isascii",
406-
"isdecimal",
407-
"isdigit",
408-
"isidentifier",
409-
"islower",
410-
"isnumeric",
411-
"isprintable",
412-
"isspace",
413-
"istitle",
414-
"isupper",
415-
"is_integer",
416-
"join",
417-
"ljust",
418-
"lower",
419-
"lstrip",
420-
"maketrans",
421-
"numerator",
422-
"partition",
423-
"real",
424-
"removeprefix",
425-
"removesuffix",
426-
"replace",
427-
"rfind",
428-
"rindex",
429-
"rjust",
430-
"rpartition",
431-
"rsplit",
432-
"rstrip",
433-
"split",
434-
"splitlines",
435-
"startswith",
436-
"strip",
437-
"swapcase",
438-
"title",
439-
"to_bytes",
440-
"translate",
441-
"upper",
442-
"zfill",
443-
})
444-
445-
@classmethod
446-
def _validate_field_name(cls, field_name: str) -> bool:
447-
"""Check field name doesn't conflict with subclass enum base type attributes.
393+
"""Field name resolver for enum members with special handling for reserved names."""
394+
395+
def __init__(self, **kwargs: Any) -> None:
396+
"""Initialize with version-aware builtin type attributes."""
397+
super().__init__(**kwargs)
398+
target = self.target_python_version
399+
self._builtin_type_attributes = (
400+
_get_builtin_type_attributes_for_target(target) if target is not None else _BUILTIN_TYPE_ATTRIBUTES
401+
)
448402

449-
When using --use-subclass-enum, enums inherit from types like str or int.
450-
Member names that conflict with methods of these types (e.g., 'count' for str)
451-
cause type checker errors. This method detects such conflicts.
452-
"""
453-
return field_name not in cls._BUILTIN_TYPE_ATTRIBUTES
403+
def _validate_field_name(self, field_name: str) -> bool:
404+
"""Check field name doesn't conflict with subclass enum base type attributes."""
405+
if not self.use_subclass_enum:
406+
return True
407+
return field_name not in self._builtin_type_attributes
454408

455409
def get_valid_name(
456410
self,
@@ -538,6 +492,8 @@ def __init__( # noqa: PLR0913, PLR0917
538492
remove_special_field_name_prefix: bool = False, # noqa: FBT001, FBT002
539493
capitalise_enum_members: bool = False, # noqa: FBT001, FBT002
540494
no_alias: bool = False, # noqa: FBT001, FBT002
495+
use_subclass_enum: bool = False, # noqa: FBT001, FBT002
496+
target_python_version: PythonVersion | None = None,
541497
remove_suffix_number: bool = False, # noqa: FBT001, FBT002
542498
parent_scoped_naming: bool = False, # noqa: FBT001, FBT002
543499
treat_dot_as_module: bool | None = None, # noqa: FBT001
@@ -575,6 +531,8 @@ def __init__( # noqa: PLR0913, PLR0917
575531
remove_special_field_name_prefix=remove_special_field_name_prefix,
576532
capitalise_enum_members=capitalise_enum_members if k == ModelType.ENUM else False,
577533
no_alias=no_alias,
534+
use_subclass_enum=use_subclass_enum if k == ModelType.ENUM else False,
535+
target_python_version=target_python_version if k == ModelType.ENUM else None,
578536
)
579537
for k, v in merged_field_name_resolver_classes.items()
580538
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# generated by datamodel-codegen:
2+
# filename: enum_builtin_conflict_two.json
3+
4+
from __future__ import annotations
5+
6+
from enum import Enum
7+
8+
9+
class Model(str, Enum):
10+
REPLACE = 'replace'
11+
COUNT = 'count'
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# generated by datamodel-codegen:
2+
# filename: enum_builtin_conflict.json
3+
4+
from __future__ import annotations
5+
6+
from enum import Enum
7+
8+
9+
class Model(Enum):
10+
REPLACE = 'replace'
11+
COUNT = 'count'
12+
INDEX = 'index'
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# generated by datamodel-codegen:
2+
# filename: enum_builtin_conflict_two.json
3+
4+
from __future__ import annotations
5+
6+
from enum import Enum
7+
8+
9+
class Model(Enum):
10+
replace = 'replace'
11+
count = 'count'
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# generated by datamodel-codegen:
2+
# filename: enum_builtin_conflict_two.json
3+
4+
from __future__ import annotations
5+
6+
from enum import Enum
7+
8+
9+
class Model(str, Enum):
10+
replace_ = 'replace'
11+
count_ = 'count'
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "string",
3+
"enum": ["replace", "count", "index"]
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "string",
3+
"enum": ["replace", "count"]
4+
}

tests/test_main_kr.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,70 @@ class EnumSystems(str, Enum):
586586
)
587587

588588

589+
def test_capitalise_enum_members_builtin_conflict(output_file: Path) -> None:
590+
"""Test capitalise-enum-members does not add underscore to builtin names (#2970)."""
591+
run_main_and_assert(
592+
input_path=JSON_SCHEMA_DATA_PATH / "enum_builtin_conflict.json",
593+
output_path=output_file,
594+
assert_func=assert_file_content,
595+
input_file_type="jsonschema",
596+
extra_args=[
597+
"--output-model-type",
598+
"pydantic_v2.BaseModel",
599+
"--disable-timestamp",
600+
"--capitalise-enum-members",
601+
],
602+
)
603+
604+
605+
def test_capitalise_enum_members_and_use_subclass_enum_builtin_conflict(output_file: Path) -> None:
606+
"""Test capitalise-enum-members + use-subclass-enum with builtin names (#2970)."""
607+
run_main_and_assert(
608+
input_path=JSON_SCHEMA_DATA_PATH / "enum_builtin_conflict_two.json",
609+
output_path=output_file,
610+
assert_func=assert_file_content,
611+
input_file_type="jsonschema",
612+
extra_args=[
613+
"--output-model-type",
614+
"pydantic_v2.BaseModel",
615+
"--disable-timestamp",
616+
"--capitalise-enum-members",
617+
"--use-subclass-enum",
618+
],
619+
)
620+
621+
622+
def test_use_subclass_enum_builtin_conflict_no_capitalise(output_file: Path) -> None:
623+
"""Test use-subclass-enum without capitalise adds underscore for builtin conflicts."""
624+
run_main_and_assert(
625+
input_path=JSON_SCHEMA_DATA_PATH / "enum_builtin_conflict_two.json",
626+
output_path=output_file,
627+
assert_func=assert_file_content,
628+
input_file_type="jsonschema",
629+
extra_args=[
630+
"--output-model-type",
631+
"pydantic_v2.BaseModel",
632+
"--disable-timestamp",
633+
"--use-subclass-enum",
634+
],
635+
)
636+
637+
638+
def test_no_subclass_enum_no_capitalise_builtin_names(output_file: Path) -> None:
639+
"""Test default behavior with builtin names has no underscore suffix."""
640+
run_main_and_assert(
641+
input_path=JSON_SCHEMA_DATA_PATH / "enum_builtin_conflict_two.json",
642+
output_path=output_file,
643+
assert_func=assert_file_content,
644+
input_file_type="jsonschema",
645+
extra_args=[
646+
"--output-model-type",
647+
"pydantic_v2.BaseModel",
648+
"--disable-timestamp",
649+
],
650+
)
651+
652+
589653
EXPECTED_GENERATE_PYPROJECT_CONFIG_PATH = EXPECTED_MAIN_KR_PATH / "generate_pyproject_config"
590654

591655

0 commit comments

Comments
 (0)