diff --git a/docs/cli-reference/field-customization.md b/docs/cli-reference/field-customization.md index aa972d56f..76c14e9f8 100644 --- a/docs/cli-reference/field-customization.md +++ b/docs/cli-reference/field-customization.md @@ -3759,10 +3759,17 @@ This is useful when schemas have descriptive titles that should be preserved. ) + class ProcessingStatusUnionTitle(BaseModel): + __root__: ( + ProcessingStatusDetail | ExtendedProcessingTask | ProcessingStatusTitle + ) = Field(..., title='Processing Status Union Title') + + class ProcessingTaskTitle(BaseModel): - processing_status_union: ( - ProcessingStatusDetail | ExtendedProcessingTask | ProcessingStatusTitle | None - ) = Field('COMPLETED', title='Processing Status Union Title') + processing_status_union: ProcessingStatusUnionTitle | None = Field( + default_factory=lambda: ProcessingStatusUnionTitle.parse_obj('COMPLETED'), + title='Processing Status Union Title', + ) processing_status: ProcessingStatusTitle | None = 'COMPLETED' name: str | None = None kind: Kind | None = None diff --git a/src/datamodel_code_generator/__init__.py b/src/datamodel_code_generator/__init__.py index 72c634ce4..a75f5da13 100644 --- a/src/datamodel_code_generator/__init__.py +++ b/src/datamodel_code_generator/__init__.py @@ -457,13 +457,17 @@ def _create_parser_config( ) -> _ConfigT: """Create a parser config from GenerateConfig with additional options. - For Pydantic v2: Uses model_validate with extra='ignore' and model_copy. - For Pydantic v1: Uses dict comprehension to filter fields. + Filters GenerateConfig fields to only those expected by the parser config class, + then merges with additional_options. """ if is_pydantic_v2(): - return config_class.model_validate(generate_config, from_attributes=True, extra="ignore").model_copy( - update=additional_options - ) + parser_config_fields = set(config_class.model_fields.keys()) + all_options = { + k: v + for k, v in generate_config.model_dump().items() + if k in parser_config_fields and k not in additional_options + } | dict(additional_options) + return config_class.model_validate(all_options) parser_config_fields = set(config_class.__fields__.keys()) all_options = { k: v for k, v in generate_config.dict().items() if k in parser_config_fields and k not in additional_options diff --git a/src/datamodel_code_generator/parser/jsonschema.py b/src/datamodel_code_generator/parser/jsonschema.py index 5fb96f610..00ab39444 100644 --- a/src/datamodel_code_generator/parser/jsonschema.py +++ b/src/datamodel_code_generator/parser/jsonschema.py @@ -2636,6 +2636,41 @@ def parse_property_names( # noqa: PLR0912 dict_key=key_type, ) + def _should_create_type_alias_for_title(self, item: JsonSchemaObject, name: str) -> bool: + """Check if a type alias should be created for an inline type with title. + + When use_title_as_name is enabled and the item has a title, certain inline types + (array, dict, oneOf/anyOf unions, enum as literal) should create a type alias + instead of being inlined. + """ + if not (self.use_title_as_name and item.title): + return False + + if item.is_array: + return True + if item.anyOf or item.oneOf: + combined_items = item.anyOf or item.oneOf + const_enum_data = self._extract_const_enum_from_combined(combined_items, item.type) + if const_enum_data is None: + return True + enum_values, varnames, enum_type, nullable = const_enum_data + synthetic_obj = self._create_synthetic_enum_obj(item, enum_values, varnames, enum_type, nullable) + if self.should_parse_enum_as_literal(synthetic_obj, property_name=name, property_obj=item): + return True + if ( + item.is_object + and not item.properties + and not item.patternProperties + and not item.propertyNames + and isinstance(item.additionalProperties, JsonSchemaObject) + ): + return True + return bool( + item.enum + and not self.ignore_enum_constraints + and self.should_parse_enum_as_literal(item, property_name=name) + ) + def parse_item( # noqa: PLR0911, PLR0912, PLR0914 self, name: str, @@ -2651,6 +2686,8 @@ def parse_item( # noqa: PLR0911, PLR0912, PLR0914 if self.use_title_as_name and item.title: name = sanitize_module_name(item.title, treat_dot_as_module=self.treat_dot_as_module) singular_name = False + if self._should_create_type_alias_for_title(item, name): + return self.parse_root_type(name, item, path) if parent and not item.enum and item.has_constraint and (parent.has_constraint or self.field_constraints): root_type_path = get_special_path("array", path) return self.parse_root_type( @@ -2893,7 +2930,7 @@ def parse_array( self.results.append(data_model_root) return self.data_type(reference=reference) - def parse_root_type( # noqa: PLR0912, PLR0915 + def parse_root_type( # noqa: PLR0912, PLR0914, PLR0915 self, name: str, obj: JsonSchemaObject, @@ -2940,6 +2977,13 @@ def parse_root_type( # noqa: PLR0912, PLR0915 data_type = self.parse_property_names( name, obj.propertyNames, obj.additionalProperties, path, parent_obj=obj ) + elif obj.is_object and not obj.properties and isinstance(obj.additionalProperties, JsonSchemaObject): + python_type_flags = self._get_python_type_flags(obj) + dict_flags = python_type_flags or {"is_dict": True} + data_type = self.data_type( + data_types=[self.parse_item(name, obj.additionalProperties, path)], + **dict_flags, + ) elif obj.enum and not self.ignore_enum_constraints: if self.should_parse_enum_as_literal(obj, property_name=name): data_type = self.parse_enum_as_literal(obj) diff --git a/tests/data/expected/main/jsonschema/titles_use_title_as_name.py b/tests/data/expected/main/jsonschema/titles_use_title_as_name.py index eb9c9b2bd..6124c3eea 100644 --- a/tests/data/expected/main/jsonschema/titles_use_title_as_name.py +++ b/tests/data/expected/main/jsonschema/titles_use_title_as_name.py @@ -44,10 +44,17 @@ class ExtendedProcessingTasksTitle(BaseModel): ) +class ProcessingStatusUnionTitle(BaseModel): + __root__: ( + ProcessingStatusDetail | ExtendedProcessingTask | ProcessingStatusTitle + ) = Field(..., title='Processing Status Union Title') + + class ProcessingTaskTitle(BaseModel): - processing_status_union: ( - ProcessingStatusDetail | ExtendedProcessingTask | ProcessingStatusTitle | None - ) = Field('COMPLETED', title='Processing Status Union Title') + processing_status_union: ProcessingStatusUnionTitle | None = Field( + default_factory=lambda: ProcessingStatusUnionTitle.parse_obj('COMPLETED'), + title='Processing Status Union Title', + ) processing_status: ProcessingStatusTitle | None = 'COMPLETED' name: str | None = None kind: Kind | None = None diff --git a/tests/data/expected/main/jsonschema/use_title_as_name_inline_types.py b/tests/data/expected/main/jsonschema/use_title_as_name_inline_types.py new file mode 100644 index 000000000..3bbce54ef --- /dev/null +++ b/tests/data/expected/main/jsonschema/use_title_as_name_inline_types.py @@ -0,0 +1,33 @@ +# generated by datamodel-codegen: +# filename: use_title_as_name_inline_types.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Literal, NotRequired, TypedDict + +type MyArrayName = list[str] + + +type MyObjectName = dict[str, str] + + +type MyEnumName = Literal['foo', 'bar'] + + +type MyOneOfName = str | float + + +type MyAnyOfName = bool | int + + +type MyOneOfConstName = Literal['alpha', 'beta'] + + +class Foo(TypedDict): + array: NotRequired[MyArrayName] + object: NotRequired[MyObjectName] + enum: NotRequired[MyEnumName] + oneOf: NotRequired[MyOneOfName] + anyOf: NotRequired[MyAnyOfName] + oneOfConst: NotRequired[MyOneOfConstName] diff --git a/tests/data/expected/main/jsonschema/use_title_as_name_inline_types_pydantic.py b/tests/data/expected/main/jsonschema/use_title_as_name_inline_types_pydantic.py new file mode 100644 index 000000000..1ae86f014 --- /dev/null +++ b/tests/data/expected/main/jsonschema/use_title_as_name_inline_types_pydantic.py @@ -0,0 +1,49 @@ +# generated by datamodel-codegen: +# filename: use_title_as_name_inline_types.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from enum import Enum, StrEnum +from typing import Any + +from pydantic import BaseModel, Field, RootModel + + +class Model(RootModel[Any]): + root: Any + + +class MyArrayName(RootModel[list[str]]): + root: list[str] = Field(..., title='MyArrayName') + + +class MyObjectName(RootModel[dict[str, str]]): + root: dict[str, str] = Field(..., title='MyObjectName') + + +class MyEnumName(Enum): + foo = 'foo' + bar = 'bar' + + +class MyOneOfName(RootModel[str | float]): + root: str | float = Field(..., title='MyOneOfName') + + +class MyAnyOfName(RootModel[bool | int]): + root: bool | int = Field(..., title='MyAnyOfName') + + +class MyOneOfConstName(StrEnum): + alpha = 'alpha' + beta = 'beta' + + +class Foo(BaseModel): + array: MyArrayName | None = Field(None, title='MyArrayName') + object: MyObjectName | None = Field(None, title='MyObjectName') + enum: MyEnumName | None = Field(None, title='MyEnumName') + oneOf: MyOneOfName | None = Field(None, title='MyOneOfName') + anyOf: MyAnyOfName | None = Field(None, title='MyAnyOfName') + oneOfConst: MyOneOfConstName | None = Field(None, title='MyOneOfConstName') diff --git a/tests/data/jsonschema/use_title_as_name_inline_types.json b/tests/data/jsonschema/use_title_as_name_inline_types.json new file mode 100644 index 000000000..7e8b9ddfe --- /dev/null +++ b/tests/data/jsonschema/use_title_as_name_inline_types.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Foo": { + "type": "object", + "properties": { + "array": { + "title": "MyArrayName", + "type": "array", + "items": { + "type": "string" + } + }, + "object": { + "title": "MyObjectName", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "enum": { + "title": "MyEnumName", + "enum": ["foo", "bar"] + }, + "oneOf": { + "title": "MyOneOfName", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "anyOf": { + "title": "MyAnyOfName", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer" + } + ] + }, + "oneOfConst": { + "title": "MyOneOfConstName", + "oneOf": [ + { + "const": "alpha" + }, + { + "const": "beta" + } + ] + } + } + } + } +} diff --git a/tests/main/jsonschema/test_main_jsonschema.py b/tests/main/jsonschema/test_main_jsonschema.py index 9b7cb2fb9..925f1cc80 100644 --- a/tests/main/jsonschema/test_main_jsonschema.py +++ b/tests/main/jsonschema/test_main_jsonschema.py @@ -3050,6 +3050,60 @@ def test_jsonschema_title_with_dots(output_file: Path) -> None: ) +@BLACK_PY313_SKIP +def test_jsonschema_use_title_as_name_inline_types(output_file: Path) -> None: + """Test use-title-as-name creates type aliases for inline types. + + When use_title_as_name is enabled and inline types (array, dict, oneOf, anyOf, enum) + have a title, type aliases should be created instead of using inline types directly. + + Fixes: https://github.com/koxudaxi/datamodel-code-generator/issues/2887 + """ + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "use_title_as_name_inline_types.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="use_title_as_name_inline_types.py", + extra_args=[ + "--use-title-as-name", + "--output-model-type", + "typing.TypedDict", + "--target-python-version", + "3.13", + "--use-union-operator", + "--use-standard-collections", + "--skip-root-model", + ], + ) + + +@BLACK_PY313_SKIP +def test_jsonschema_use_title_as_name_inline_types_pydantic(output_file: Path) -> None: + """Test use-title-as-name with Pydantic v2 creates named types for inline types. + + This test covers the case where should_parse_enum_as_literal returns False + (for oneOf with const values), exercising the False branch in + _should_create_type_alias_for_title. + """ + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "use_title_as_name_inline_types.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="use_title_as_name_inline_types_pydantic.py", + extra_args=[ + "--use-title-as-name", + "--output-model-type", + "pydantic_v2.BaseModel", + "--target-python-version", + "3.13", + "--use-union-operator", + "--use-standard-collections", + ], + ) + + def test_main_jsonschema_has_default_value(output_file: Path) -> None: """Test default value handling.""" run_main_and_assert(