Skip to content

Commit 1d221da

Browse files
authored
Create type aliases for inline types with title when use-title-as-name is enabled (#2889)
When use_title_as_name is enabled and inline types (array, dict, enum as literal, oneOf/anyOf unions) have a title, type aliases are now created instead of using inline types directly. Also fixes Pydantic v2 compatibility issue where model_validate's extra='ignore' parameter requires Pydantic v2.12.5+. Fixes: #2887
1 parent 94bb621 commit 1d221da

8 files changed

Lines changed: 271 additions & 12 deletions

File tree

docs/cli-reference/field-customization.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3759,10 +3759,17 @@ This is useful when schemas have descriptive titles that should be preserved.
37593759
)
37603760

37613761

3762+
class ProcessingStatusUnionTitle(BaseModel):
3763+
__root__: (
3764+
ProcessingStatusDetail | ExtendedProcessingTask | ProcessingStatusTitle
3765+
) = Field(..., title='Processing Status Union Title')
3766+
3767+
37623768
class ProcessingTaskTitle(BaseModel):
3763-
processing_status_union: (
3764-
ProcessingStatusDetail | ExtendedProcessingTask | ProcessingStatusTitle | None
3765-
) = Field('COMPLETED', title='Processing Status Union Title')
3769+
processing_status_union: ProcessingStatusUnionTitle | None = Field(
3770+
default_factory=lambda: ProcessingStatusUnionTitle.parse_obj('COMPLETED'),
3771+
title='Processing Status Union Title',
3772+
)
37663773
processing_status: ProcessingStatusTitle | None = 'COMPLETED'
37673774
name: str | None = None
37683775
kind: Kind | None = None

src/datamodel_code_generator/__init__.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -457,13 +457,17 @@ def _create_parser_config(
457457
) -> _ConfigT:
458458
"""Create a parser config from GenerateConfig with additional options.
459459
460-
For Pydantic v2: Uses model_validate with extra='ignore' and model_copy.
461-
For Pydantic v1: Uses dict comprehension to filter fields.
460+
Filters GenerateConfig fields to only those expected by the parser config class,
461+
then merges with additional_options.
462462
"""
463463
if is_pydantic_v2():
464-
return config_class.model_validate(generate_config, from_attributes=True, extra="ignore").model_copy(
465-
update=additional_options
466-
)
464+
parser_config_fields = set(config_class.model_fields.keys())
465+
all_options = {
466+
k: v
467+
for k, v in generate_config.model_dump().items()
468+
if k in parser_config_fields and k not in additional_options
469+
} | dict(additional_options)
470+
return config_class.model_validate(all_options)
467471
parser_config_fields = set(config_class.__fields__.keys())
468472
all_options = {
469473
k: v for k, v in generate_config.dict().items() if k in parser_config_fields and k not in additional_options

src/datamodel_code_generator/parser/jsonschema.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2636,6 +2636,41 @@ def parse_property_names( # noqa: PLR0912
26362636
dict_key=key_type,
26372637
)
26382638

2639+
def _should_create_type_alias_for_title(self, item: JsonSchemaObject, name: str) -> bool:
2640+
"""Check if a type alias should be created for an inline type with title.
2641+
2642+
When use_title_as_name is enabled and the item has a title, certain inline types
2643+
(array, dict, oneOf/anyOf unions, enum as literal) should create a type alias
2644+
instead of being inlined.
2645+
"""
2646+
if not (self.use_title_as_name and item.title):
2647+
return False
2648+
2649+
if item.is_array:
2650+
return True
2651+
if item.anyOf or item.oneOf:
2652+
combined_items = item.anyOf or item.oneOf
2653+
const_enum_data = self._extract_const_enum_from_combined(combined_items, item.type)
2654+
if const_enum_data is None:
2655+
return True
2656+
enum_values, varnames, enum_type, nullable = const_enum_data
2657+
synthetic_obj = self._create_synthetic_enum_obj(item, enum_values, varnames, enum_type, nullable)
2658+
if self.should_parse_enum_as_literal(synthetic_obj, property_name=name, property_obj=item):
2659+
return True
2660+
if (
2661+
item.is_object
2662+
and not item.properties
2663+
and not item.patternProperties
2664+
and not item.propertyNames
2665+
and isinstance(item.additionalProperties, JsonSchemaObject)
2666+
):
2667+
return True
2668+
return bool(
2669+
item.enum
2670+
and not self.ignore_enum_constraints
2671+
and self.should_parse_enum_as_literal(item, property_name=name)
2672+
)
2673+
26392674
def parse_item( # noqa: PLR0911, PLR0912, PLR0914
26402675
self,
26412676
name: str,
@@ -2651,6 +2686,8 @@ def parse_item( # noqa: PLR0911, PLR0912, PLR0914
26512686
if self.use_title_as_name and item.title:
26522687
name = sanitize_module_name(item.title, treat_dot_as_module=self.treat_dot_as_module)
26532688
singular_name = False
2689+
if self._should_create_type_alias_for_title(item, name):
2690+
return self.parse_root_type(name, item, path)
26542691
if parent and not item.enum and item.has_constraint and (parent.has_constraint or self.field_constraints):
26552692
root_type_path = get_special_path("array", path)
26562693
return self.parse_root_type(
@@ -2893,7 +2930,7 @@ def parse_array(
28932930
self.results.append(data_model_root)
28942931
return self.data_type(reference=reference)
28952932

2896-
def parse_root_type( # noqa: PLR0912, PLR0915
2933+
def parse_root_type( # noqa: PLR0912, PLR0914, PLR0915
28972934
self,
28982935
name: str,
28992936
obj: JsonSchemaObject,
@@ -2940,6 +2977,13 @@ def parse_root_type( # noqa: PLR0912, PLR0915
29402977
data_type = self.parse_property_names(
29412978
name, obj.propertyNames, obj.additionalProperties, path, parent_obj=obj
29422979
)
2980+
elif obj.is_object and not obj.properties and isinstance(obj.additionalProperties, JsonSchemaObject):
2981+
python_type_flags = self._get_python_type_flags(obj)
2982+
dict_flags = python_type_flags or {"is_dict": True}
2983+
data_type = self.data_type(
2984+
data_types=[self.parse_item(name, obj.additionalProperties, path)],
2985+
**dict_flags,
2986+
)
29432987
elif obj.enum and not self.ignore_enum_constraints:
29442988
if self.should_parse_enum_as_literal(obj, property_name=name):
29452989
data_type = self.parse_enum_as_literal(obj)

tests/data/expected/main/jsonschema/titles_use_title_as_name.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,17 @@ class ExtendedProcessingTasksTitle(BaseModel):
4444
)
4545

4646

47+
class ProcessingStatusUnionTitle(BaseModel):
48+
__root__: (
49+
ProcessingStatusDetail | ExtendedProcessingTask | ProcessingStatusTitle
50+
) = Field(..., title='Processing Status Union Title')
51+
52+
4753
class ProcessingTaskTitle(BaseModel):
48-
processing_status_union: (
49-
ProcessingStatusDetail | ExtendedProcessingTask | ProcessingStatusTitle | None
50-
) = Field('COMPLETED', title='Processing Status Union Title')
54+
processing_status_union: ProcessingStatusUnionTitle | None = Field(
55+
default_factory=lambda: ProcessingStatusUnionTitle.parse_obj('COMPLETED'),
56+
title='Processing Status Union Title',
57+
)
5158
processing_status: ProcessingStatusTitle | None = 'COMPLETED'
5259
name: str | None = None
5360
kind: Kind | None = None
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# generated by datamodel-codegen:
2+
# filename: use_title_as_name_inline_types.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Literal, NotRequired, TypedDict
8+
9+
type MyArrayName = list[str]
10+
11+
12+
type MyObjectName = dict[str, str]
13+
14+
15+
type MyEnumName = Literal['foo', 'bar']
16+
17+
18+
type MyOneOfName = str | float
19+
20+
21+
type MyAnyOfName = bool | int
22+
23+
24+
type MyOneOfConstName = Literal['alpha', 'beta']
25+
26+
27+
class Foo(TypedDict):
28+
array: NotRequired[MyArrayName]
29+
object: NotRequired[MyObjectName]
30+
enum: NotRequired[MyEnumName]
31+
oneOf: NotRequired[MyOneOfName]
32+
anyOf: NotRequired[MyAnyOfName]
33+
oneOfConst: NotRequired[MyOneOfConstName]
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# generated by datamodel-codegen:
2+
# filename: use_title_as_name_inline_types.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from enum import Enum, StrEnum
8+
from typing import Any
9+
10+
from pydantic import BaseModel, Field, RootModel
11+
12+
13+
class Model(RootModel[Any]):
14+
root: Any
15+
16+
17+
class MyArrayName(RootModel[list[str]]):
18+
root: list[str] = Field(..., title='MyArrayName')
19+
20+
21+
class MyObjectName(RootModel[dict[str, str]]):
22+
root: dict[str, str] = Field(..., title='MyObjectName')
23+
24+
25+
class MyEnumName(Enum):
26+
foo = 'foo'
27+
bar = 'bar'
28+
29+
30+
class MyOneOfName(RootModel[str | float]):
31+
root: str | float = Field(..., title='MyOneOfName')
32+
33+
34+
class MyAnyOfName(RootModel[bool | int]):
35+
root: bool | int = Field(..., title='MyAnyOfName')
36+
37+
38+
class MyOneOfConstName(StrEnum):
39+
alpha = 'alpha'
40+
beta = 'beta'
41+
42+
43+
class Foo(BaseModel):
44+
array: MyArrayName | None = Field(None, title='MyArrayName')
45+
object: MyObjectName | None = Field(None, title='MyObjectName')
46+
enum: MyEnumName | None = Field(None, title='MyEnumName')
47+
oneOf: MyOneOfName | None = Field(None, title='MyOneOfName')
48+
anyOf: MyAnyOfName | None = Field(None, title='MyAnyOfName')
49+
oneOfConst: MyOneOfConstName | None = Field(None, title='MyOneOfConstName')
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$defs": {
4+
"Foo": {
5+
"type": "object",
6+
"properties": {
7+
"array": {
8+
"title": "MyArrayName",
9+
"type": "array",
10+
"items": {
11+
"type": "string"
12+
}
13+
},
14+
"object": {
15+
"title": "MyObjectName",
16+
"type": "object",
17+
"additionalProperties": {
18+
"type": "string"
19+
}
20+
},
21+
"enum": {
22+
"title": "MyEnumName",
23+
"enum": ["foo", "bar"]
24+
},
25+
"oneOf": {
26+
"title": "MyOneOfName",
27+
"oneOf": [
28+
{
29+
"type": "string"
30+
},
31+
{
32+
"type": "number"
33+
}
34+
]
35+
},
36+
"anyOf": {
37+
"title": "MyAnyOfName",
38+
"anyOf": [
39+
{
40+
"type": "boolean"
41+
},
42+
{
43+
"type": "integer"
44+
}
45+
]
46+
},
47+
"oneOfConst": {
48+
"title": "MyOneOfConstName",
49+
"oneOf": [
50+
{
51+
"const": "alpha"
52+
},
53+
{
54+
"const": "beta"
55+
}
56+
]
57+
}
58+
}
59+
}
60+
}
61+
}

tests/main/jsonschema/test_main_jsonschema.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3050,6 +3050,60 @@ def test_jsonschema_title_with_dots(output_file: Path) -> None:
30503050
)
30513051

30523052

3053+
@BLACK_PY313_SKIP
3054+
def test_jsonschema_use_title_as_name_inline_types(output_file: Path) -> None:
3055+
"""Test use-title-as-name creates type aliases for inline types.
3056+
3057+
When use_title_as_name is enabled and inline types (array, dict, oneOf, anyOf, enum)
3058+
have a title, type aliases should be created instead of using inline types directly.
3059+
3060+
Fixes: https://github.com/koxudaxi/datamodel-code-generator/issues/2887
3061+
"""
3062+
run_main_and_assert(
3063+
input_path=JSON_SCHEMA_DATA_PATH / "use_title_as_name_inline_types.json",
3064+
output_path=output_file,
3065+
input_file_type="jsonschema",
3066+
assert_func=assert_file_content,
3067+
expected_file="use_title_as_name_inline_types.py",
3068+
extra_args=[
3069+
"--use-title-as-name",
3070+
"--output-model-type",
3071+
"typing.TypedDict",
3072+
"--target-python-version",
3073+
"3.13",
3074+
"--use-union-operator",
3075+
"--use-standard-collections",
3076+
"--skip-root-model",
3077+
],
3078+
)
3079+
3080+
3081+
@BLACK_PY313_SKIP
3082+
def test_jsonschema_use_title_as_name_inline_types_pydantic(output_file: Path) -> None:
3083+
"""Test use-title-as-name with Pydantic v2 creates named types for inline types.
3084+
3085+
This test covers the case where should_parse_enum_as_literal returns False
3086+
(for oneOf with const values), exercising the False branch in
3087+
_should_create_type_alias_for_title.
3088+
"""
3089+
run_main_and_assert(
3090+
input_path=JSON_SCHEMA_DATA_PATH / "use_title_as_name_inline_types.json",
3091+
output_path=output_file,
3092+
input_file_type="jsonschema",
3093+
assert_func=assert_file_content,
3094+
expected_file="use_title_as_name_inline_types_pydantic.py",
3095+
extra_args=[
3096+
"--use-title-as-name",
3097+
"--output-model-type",
3098+
"pydantic_v2.BaseModel",
3099+
"--target-python-version",
3100+
"3.13",
3101+
"--use-union-operator",
3102+
"--use-standard-collections",
3103+
],
3104+
)
3105+
3106+
30533107
def test_main_jsonschema_has_default_value(output_file: Path) -> None:
30543108
"""Test default value handling."""
30553109
run_main_and_assert(

0 commit comments

Comments
 (0)