diff --git a/docs/cli-reference/index.md b/docs/cli-reference/index.md index ee15402fd..7e65c4a77 100644 --- a/docs/cli-reference/index.md +++ b/docs/cli-reference/index.md @@ -9,7 +9,7 @@ This documentation is auto-generated from test cases. | Category | Options | Description | |----------|---------|-------------| | 📁 [Base Options](base-options.md) | 7 | Input/output configuration | -| 🔧 [Typing Customization](typing-customization.md) | 26 | Type annotation and import behavior | +| 🔧 [Typing Customization](typing-customization.md) | 27 | Type annotation and import behavior | | 🏷️ [Field Customization](field-customization.md) | 22 | Field naming and docstring behavior | | 🏗️ [Model Customization](model-customization.md) | 36 | Model generation behavior | | 🎨 [Template Customization](template-customization.md) | 18 | Output formatting and custom rendering | @@ -28,6 +28,7 @@ This documentation is auto-generated from test cases. - [`--aliases`](field-customization.md#aliases) - [`--all-exports-collision-strategy`](general-options.md#all-exports-collision-strategy) - [`--all-exports-scope`](general-options.md#all-exports-scope) +- [`--allof-class-hierarchy`](typing-customization.md#allof-class-hierarchy) - [`--allof-merge-mode`](typing-customization.md#allof-merge-mode) - [`--allow-extra-fields`](model-customization.md#allow-extra-fields) - [`--allow-population-by-field-name`](model-customization.md#allow-population-by-field-name) diff --git a/docs/cli-reference/quick-reference.md b/docs/cli-reference/quick-reference.md index ee9d51b6a..74ba629c6 100644 --- a/docs/cli-reference/quick-reference.md +++ b/docs/cli-reference/quick-reference.md @@ -28,6 +28,7 @@ datamodel-codegen [OPTIONS] | Option | Description | |--------|-------------| +| [`--allof-class-hierarchy`](typing-customization.md#allof-class-hierarchy) | Controls how allOf schemas are represented in the generated class hierarchy. | | [`--allof-merge-mode`](typing-customization.md#allof-merge-mode) | Merge constraints from root model references in allOf schemas. | | [`--disable-future-imports`](typing-customization.md#disable-future-imports) | Prevent automatic addition of __future__ imports in generated code. | | [`--enum-field-as-literal`](typing-customization.md#enum-field-as-literal) | Convert all enum fields to Literal types instead of Enum classes. | @@ -198,6 +199,7 @@ All options sorted alphabetically: - [`--aliases`](field-customization.md#aliases) - Apply custom field and class name aliases from JSON file. - [`--all-exports-collision-strategy`](general-options.md#all-exports-collision-strategy) - Handle name collisions when exporting recursive module hiera... - [`--all-exports-scope`](general-options.md#all-exports-scope) - Generate __all__ exports for child modules in __init__.py fi... +- [`--allof-class-hierarchy`](typing-customization.md#allof-class-hierarchy) - Controls how allOf schemas are represented in the generated ... - [`--allof-merge-mode`](typing-customization.md#allof-merge-mode) - Merge constraints from root model references in allOf schema... - [`--allow-extra-fields`](model-customization.md#allow-extra-fields) - Allow extra fields in generated Pydantic models (extra='allo... - [`--allow-population-by-field-name`](model-customization.md#allow-population-by-field-name) - Allow Pydantic model population by field name (not just alia... diff --git a/docs/cli-reference/typing-customization.md b/docs/cli-reference/typing-customization.md index a392de205..03c47e3aa 100644 --- a/docs/cli-reference/typing-customization.md +++ b/docs/cli-reference/typing-customization.md @@ -4,6 +4,7 @@ | Option | Description | |--------|-------------| +| [`--allof-class-hierarchy`](#allof-class-hierarchy) | Controls how allOf schemas are represented in the generated ... | | [`--allof-merge-mode`](#allof-merge-mode) | Merge constraints from root model references in allOf schema... | | [`--disable-future-imports`](#disable-future-imports) | Prevent automatic addition of __future__ imports in generate... | | [`--enum-field-as-literal`](#enum-field-as-literal) | Convert all enum fields to Literal types instead of Enum cla... | @@ -33,6 +34,333 @@ --- +## `--allof-class-hierarchy` {#allof-class-hierarchy} + +Controls how allOf schemas are represented in the generated class hierarchy. +`--allof-class-hierarchy if-no-conflict` (default) creates parent classes for allOf schemas +only when there are no property conflicts between parent schemas. Otherwise, properties are merged into the child class +which is then decoupled from the parent classes and no longer inherits from them. +`--allof-class-hierarchy always` keeps class hierarchy for allOf schemas, +even in multiple inheritance scenarios where two parent schemas define the same property. + +!!! tip "Usage" + + ```bash + datamodel-codegen --input schema.json --allof-class-hierarchy always # (1)! + ``` + + 1. :material-arrow-left: `--allof-class-hierarchy` - the option documented here + +??? example "Examples" + + **Input Schema:** + + ```json + { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "StringDatatype": { + "description": "A base string type.", + "type": "string", + "pattern": "^\\S(.*\\S)?$" + }, + "ConstrainedStringDatatype": { + "description": "A constrained string.", + "allOf": [ + { "$ref": "#/definitions/StringDatatype" }, + { "type": "string", "minLength": 1, "pattern": "^[A-Z].*" } + ] + }, + "IntegerDatatype": { + "description": "A whole number.", + "type": "integer" + }, + "NonNegativeIntegerDatatype": { + "description": "Non-negative integer.", + "allOf": [ + { "$ref": "#/definitions/IntegerDatatype" }, + { "minimum": 0 } + ] + }, + "BoundedIntegerDatatype": { + "description": "Integer between 0 and 100.", + "allOf": [ + { "$ref": "#/definitions/IntegerDatatype" }, + { "minimum": 0, "maximum": 100 } + ] + }, + "EmailDatatype": { + "description": "Email with format.", + "allOf": [ + { "$ref": "#/definitions/StringDatatype" }, + { "format": "email" } + ] + }, + "FormattedStringDatatype": { + "description": "A string with email format.", + "type": "string", + "format": "email" + }, + "ObjectBase": { + "type": "object", + "properties": { + "id": { "type": "integer" } + } + }, + "ObjectWithAllOf": { + "description": "Object inheritance - not a root model.", + "allOf": [ + { "$ref": "#/definitions/ObjectBase" }, + { "type": "object", "properties": { "name": { "type": "string" } } } + ] + }, + "MultiRefAllOf": { + "description": "Multiple refs - not handled by new code.", + "allOf": [ + { "$ref": "#/definitions/StringDatatype" }, + { "$ref": "#/definitions/IntegerDatatype" } + ] + }, + "NoConstraintAllOf": { + "description": "No constraints added.", + "allOf": [ + { "$ref": "#/definitions/StringDatatype" } + ] + }, + "IncompatibleTypeAllOf": { + "description": "Incompatible types.", + "allOf": [ + { "$ref": "#/definitions/StringDatatype" }, + { "type": "boolean" } + ] + }, + "ConstraintWithProperties": { + "description": "Constraint item has properties.", + "allOf": [ + { "$ref": "#/definitions/StringDatatype" }, + { "properties": { "extra": { "type": "string" } } } + ] + }, + "ConstraintWithItems": { + "description": "Constraint item has items.", + "allOf": [ + { "$ref": "#/definitions/StringDatatype" }, + { "items": { "type": "string" } } + ] + }, + "NumberIntegerCompatible": { + "description": "Number and integer are compatible.", + "allOf": [ + { "$ref": "#/definitions/IntegerDatatype" }, + { "type": "number", "minimum": 0 } + ] + }, + "RefWithSchemaKeywords": { + "description": "Ref with additional schema keywords.", + "allOf": [ + { "$ref": "#/definitions/StringDatatype", "minLength": 5 }, + { "maxLength": 100 } + ] + }, + "ArrayDatatype": { + "type": "array", + "items": { "type": "string" } + }, + "RefToArrayAllOf": { + "description": "Ref to array - not a root model.", + "allOf": [ + { "$ref": "#/definitions/ArrayDatatype" }, + { "minItems": 1 } + ] + }, + "ObjectNoPropsDatatype": { + "type": "object" + }, + "RefToObjectNoPropsAllOf": { + "description": "Ref to object without properties - not a root model.", + "allOf": [ + { "$ref": "#/definitions/ObjectNoPropsDatatype" }, + { "minProperties": 1 } + ] + }, + "PatternPropsDatatype": { + "patternProperties": { + "^S_": { "type": "string" } + } + }, + "RefToPatternPropsAllOf": { + "description": "Ref to patternProperties - not a root model.", + "allOf": [ + { "$ref": "#/definitions/PatternPropsDatatype" }, + { "minProperties": 1 } + ] + }, + "NestedAllOfDatatype": { + "allOf": [ + { "type": "string" }, + { "minLength": 1 } + ] + }, + "RefToNestedAllOfAllOf": { + "description": "Ref to nested allOf - not a root model.", + "allOf": [ + { "$ref": "#/definitions/NestedAllOfDatatype" }, + { "maxLength": 100 } + ] + }, + "ConstraintsOnlyDatatype": { + "description": "Constraints only, no type.", + "minLength": 1, + "pattern": "^[A-Z]" + }, + "RefToConstraintsOnlyAllOf": { + "description": "Ref to constraints-only schema.", + "allOf": [ + { "$ref": "#/definitions/ConstraintsOnlyDatatype" }, + { "maxLength": 100 } + ] + }, + "NoDescriptionAllOf": { + "allOf": [ + { "$ref": "#/definitions/StringDatatype" }, + { "minLength": 5 } + ] + }, + "EmptyConstraintItemAllOf": { + "description": "AllOf with empty constraint item.", + "allOf": [ + { "$ref": "#/definitions/StringDatatype" }, + {}, + { "maxLength": 50 } + ] + }, + "ConflictingFormatAllOf": { + "description": "Conflicting formats - falls back to existing behavior.", + "allOf": [ + { "$ref": "#/definitions/FormattedStringDatatype" }, + { "format": "date-time" } + ] + } + }, + "type": "object", + "properties": { + "name": { "$ref": "#/definitions/ConstrainedStringDatatype" }, + "count": { "$ref": "#/definitions/NonNegativeIntegerDatatype" }, + "percentage": { "$ref": "#/definitions/BoundedIntegerDatatype" }, + "email": { "$ref": "#/definitions/EmailDatatype" }, + "obj": { "$ref": "#/definitions/ObjectWithAllOf" }, + "multi": { "$ref": "#/definitions/MultiRefAllOf" }, + "noconstraint": { "$ref": "#/definitions/NoConstraintAllOf" }, + "incompatible": { "$ref": "#/definitions/IncompatibleTypeAllOf" }, + "withprops": { "$ref": "#/definitions/ConstraintWithProperties" }, + "withitems": { "$ref": "#/definitions/ConstraintWithItems" }, + "numint": { "$ref": "#/definitions/NumberIntegerCompatible" }, + "refwithkw": { "$ref": "#/definitions/RefWithSchemaKeywords" }, + "refarr": { "$ref": "#/definitions/RefToArrayAllOf" }, + "refobjnoprops": { "$ref": "#/definitions/RefToObjectNoPropsAllOf" }, + "refpatternprops": { "$ref": "#/definitions/RefToPatternPropsAllOf" }, + "refnestedallof": { "$ref": "#/definitions/RefToNestedAllOfAllOf" }, + "refconstraintsonly": { "$ref": "#/definitions/RefToConstraintsOnlyAllOf" }, + "nodescription": { "$ref": "#/definitions/NoDescriptionAllOf" }, + "emptyconstraint": { "$ref": "#/definitions/EmptyConstraintItemAllOf" }, + "conflictingformat": { "$ref": "#/definitions/ConflictingFormatAllOf" } + } + } + ``` + + **Output:** + + === "With Option" + + ```python + # generated by datamodel-codegen: + # filename: allof_class_hierarchy.json + # timestamp: 2019-07-26T00:00:00+00:00 + + from __future__ import annotations + + from pydantic import BaseModel, Field, constr + + + class Entity(BaseModel): + type: str + type_list: list[str] | None = ['playground:Entity'] + + + class Entity2(BaseModel): + type: str + type_list: list[str] + + + class Thing(Entity): + type: str + type_list: list[str] + name: constr(min_length=1) = Field(..., description='The things name') + + + class Location(Entity2): + type: str + type_list: list[str] + address: constr(min_length=5) = Field( + ..., description='The address of the location' + ) + + + class Person(Thing, Location): + name: constr(min_length=1) | None = Field(None, description="The person's name") + type: str + type_list: list[str] + ``` + + === "Without Option" + + ```python + # generated by datamodel-codegen: + # filename: allof_class_hierarchy.json + # timestamp: 2019-07-26T00:00:00+00:00 + + from __future__ import annotations + + from typing import Any + + from pydantic import BaseModel, Field, constr + + + class Person(BaseModel): + name: constr(min_length=1) = Field(..., description='The things name') + type: Any + type_list: list[Any] + address: constr(min_length=5) = Field( + ..., description='The address of the location' + ) + + + class Entity(BaseModel): + type: str + type_list: list[str] | None = ['playground:Entity'] + + + class Entity2(BaseModel): + type: str + type_list: list[str] + + + class Thing(Entity): + type: str + type_list: list[str] + name: constr(min_length=1) = Field(..., description='The things name') + + + class Location(Entity2): + type: str + type_list: list[str] + address: constr(min_length=5) = Field( + ..., description='The address of the location' + ) + ``` + +--- + ## `--allof-merge-mode` {#allof-merge-mode} Merge constraints from root model references in allOf schemas. diff --git a/src/datamodel_code_generator/__init__.py b/src/datamodel_code_generator/__init__.py index d544eef7a..0a986625d 100644 --- a/src/datamodel_code_generator/__init__.py +++ b/src/datamodel_code_generator/__init__.py @@ -32,6 +32,7 @@ MIN_VERSION, AllExportsCollisionStrategy, AllExportsScope, + AllOfClassHierarchy, AllOfMergeMode, CollapseRootModelsNameStrategy, DataclassArguments, @@ -521,6 +522,7 @@ def generate( # noqa: PLR0912, PLR0913, PLR0914, PLR0915 use_unique_items_as_set: bool = False, use_tuple_for_fixed_items: bool = False, allof_merge_mode: AllOfMergeMode = AllOfMergeMode.Constraints, + allof_class_hierarchy: AllOfClassHierarchy = AllOfClassHierarchy.IfNoConflict, http_headers: Sequence[tuple[str, str]] | None = None, http_ignore_tls: bool = False, http_timeout: float | None = None, @@ -685,6 +687,11 @@ def generate( # noqa: PLR0912, PLR0913, PLR0914, PLR0915 allof_merge_mode = ( config.allof_merge_mode if allof_merge_mode == AllOfMergeMode.Constraints else allof_merge_mode ) + allof_class_hierarchy = ( + config.allof_class_hierarchy + if allof_class_hierarchy == AllOfClassHierarchy.IfNoConflict + else allof_class_hierarchy + ) http_headers = config.http_headers if http_headers is None else http_headers http_ignore_tls = http_ignore_tls or config.http_ignore_tls http_timeout = config.http_timeout if http_timeout is None else http_timeout @@ -1019,6 +1026,7 @@ def get_header_and_first_line(csv_file: IO[str]) -> dict[str, Any]: use_unique_items_as_set=use_unique_items_as_set, use_tuple_for_fixed_items=use_tuple_for_fixed_items, allof_merge_mode=allof_merge_mode, + allof_class_hierarchy=allof_class_hierarchy, http_headers=http_headers, http_ignore_tls=http_ignore_tls, http_timeout=http_timeout, diff --git a/src/datamodel_code_generator/__main__.py b/src/datamodel_code_generator/__main__.py index 84e6a9bfe..5f703097e 100644 --- a/src/datamodel_code_generator/__main__.py +++ b/src/datamodel_code_generator/__main__.py @@ -52,6 +52,7 @@ DEFAULT_SHARED_MODULE_NAME, AllExportsCollisionStrategy, AllExportsScope, + AllOfClassHierarchy, AllOfMergeMode, CollapseRootModelsNameStrategy, DataclassArguments, @@ -514,6 +515,7 @@ def validate_all_exports_collision_strategy(cls, values: dict[str, Any]) -> dict use_unique_items_as_set: bool = False use_tuple_for_fixed_items: bool = False allof_merge_mode: AllOfMergeMode = AllOfMergeMode.Constraints + allof_class_hierarchy: AllOfClassHierarchy = AllOfClassHierarchy.IfNoConflict http_headers: Optional[Sequence[tuple[str, str]]] = None # noqa: UP045 http_ignore_tls: bool = False http_timeout: Optional[float] = None # noqa: UP045 @@ -1595,6 +1597,7 @@ def run_generate_from_config( # noqa: PLR0913, PLR0917 use_unique_items_as_set=config.use_unique_items_as_set, use_tuple_for_fixed_items=config.use_tuple_for_fixed_items, allof_merge_mode=config.allof_merge_mode, + allof_class_hierarchy=config.allof_class_hierarchy, http_headers=config.http_headers, http_ignore_tls=config.http_ignore_tls, http_timeout=config.http_timeout, diff --git a/src/datamodel_code_generator/_types/generate_config_dict.py b/src/datamodel_code_generator/_types/generate_config_dict.py index 7cbbe3daf..5b91170c0 100644 --- a/src/datamodel_code_generator/_types/generate_config_dict.py +++ b/src/datamodel_code_generator/_types/generate_config_dict.py @@ -15,6 +15,7 @@ from datamodel_code_generator.enums import ( AllExportsCollisionStrategy, AllExportsScope, + AllOfClassHierarchy, AllOfMergeMode, CollapseRootModelsNameStrategy, DataclassArguments, @@ -104,6 +105,7 @@ class GenerateConfigDict(TypedDict): use_unique_items_as_set: NotRequired[bool] use_tuple_for_fixed_items: NotRequired[bool] allof_merge_mode: NotRequired[AllOfMergeMode] + allof_class_hierarchy: NotRequired[AllOfClassHierarchy] http_headers: NotRequired[Sequence[tuple[str, str]] | None] http_ignore_tls: NotRequired[bool] http_timeout: NotRequired[float | None] diff --git a/src/datamodel_code_generator/_types/parser_config_dict.py b/src/datamodel_code_generator/_types/parser_config_dict.py index c78b052c2..7823c6c82 100644 --- a/src/datamodel_code_generator/_types/parser_config_dict.py +++ b/src/datamodel_code_generator/_types/parser_config_dict.py @@ -13,6 +13,7 @@ from pathlib import Path from datamodel_code_generator.enums import ( + AllOfClassHierarchy, AllOfMergeMode, CollapseRootModelsNameStrategy, DataclassArguments, @@ -91,6 +92,7 @@ class ParserConfigDict(TypedDict): use_unique_items_as_set: NotRequired[bool] use_tuple_for_fixed_items: NotRequired[bool] allof_merge_mode: NotRequired[AllOfMergeMode] + allof_class_hierarchy: NotRequired[AllOfClassHierarchy] http_headers: NotRequired[Sequence[tuple[str, str]] | None] http_ignore_tls: NotRequired[bool] http_timeout: NotRequired[float | None] diff --git a/src/datamodel_code_generator/arguments.py b/src/datamodel_code_generator/arguments.py index 8267b6da6..b3078e7ea 100644 --- a/src/datamodel_code_generator/arguments.py +++ b/src/datamodel_code_generator/arguments.py @@ -17,6 +17,7 @@ DEFAULT_SHARED_MODULE_NAME, AllExportsCollisionStrategy, AllExportsScope, + AllOfClassHierarchy, AllOfMergeMode, CollapseRootModelsNameStrategy, DataclassArguments, @@ -579,6 +580,14 @@ def start_section(self, heading: str | None) -> None: choices=[m.value for m in AllOfMergeMode], default=None, ) +typing_options.add_argument( + "--allof-class-hierarchy", + help="How to map allOf references to class hierarchies. " + "'if-no-conflict': only create subclasses when parent class has no conflicting property definition. " + "'always': always create subclasses. ", + choices=[m.value for m in AllOfClassHierarchy], + default=None, +) typing_options.add_argument( "--use-type-alias", help="Use TypeAlias instead of root models (experimental)", diff --git a/src/datamodel_code_generator/cli_options.py b/src/datamodel_code_generator/cli_options.py index 8cae6834d..7c2b2a449 100644 --- a/src/datamodel_code_generator/cli_options.py +++ b/src/datamodel_code_generator/cli_options.py @@ -198,6 +198,7 @@ class CLIOptionMeta: "--type-overrides": CLIOptionMeta(name="--type-overrides", category=OptionCategory.TYPING), "--no-use-specialized-enum": CLIOptionMeta(name="--no-use-specialized-enum", category=OptionCategory.TYPING), "--allof-merge-mode": CLIOptionMeta(name="--allof-merge-mode", category=OptionCategory.TYPING), + "--allof-class-hierarchy": CLIOptionMeta(name="--allof-class-hierarchy", category=OptionCategory.TYPING), # ========================================================================== # Template Customization # ========================================================================== diff --git a/src/datamodel_code_generator/config.py b/src/datamodel_code_generator/config.py index 25635ad25..2d94013c3 100644 --- a/src/datamodel_code_generator/config.py +++ b/src/datamodel_code_generator/config.py @@ -13,6 +13,7 @@ DEFAULT_SHARED_MODULE_NAME, AllExportsCollisionStrategy, AllExportsScope, + AllOfClassHierarchy, AllOfMergeMode, CollapseRootModelsNameStrategy, DataclassArguments, @@ -137,6 +138,7 @@ class Config: use_unique_items_as_set: bool = False use_tuple_for_fixed_items: bool = False allof_merge_mode: AllOfMergeMode = AllOfMergeMode.Constraints + allof_class_hierarchy: AllOfClassHierarchy = AllOfClassHierarchy.IfNoConflict http_headers: Sequence[tuple[str, str]] | None = None http_ignore_tls: bool = False http_timeout: float | None = None @@ -265,6 +267,7 @@ class Config: use_unique_items_as_set: bool = False use_tuple_for_fixed_items: bool = False allof_merge_mode: AllOfMergeMode = AllOfMergeMode.Constraints + allof_class_hierarchy: AllOfClassHierarchy = AllOfClassHierarchy.IfNoConflict http_headers: Sequence[tuple[str, str]] | None = None http_ignore_tls: bool = False http_timeout: float | None = None diff --git a/src/datamodel_code_generator/enums.py b/src/datamodel_code_generator/enums.py index 40c68e263..34ec8debe 100644 --- a/src/datamodel_code_generator/enums.py +++ b/src/datamodel_code_generator/enums.py @@ -152,6 +152,13 @@ class AllOfMergeMode(Enum): NoMerge = "none" +class AllOfClassHierarchy(Enum): + """How to map allOf references to class hierarchies.""" + + IfNoConflict = "if-no-conflict" + Always = "always" + + class GraphQLScope(Enum): """Scopes for GraphQL model generation.""" diff --git a/src/datamodel_code_generator/parser/base.py b/src/datamodel_code_generator/parser/base.py index 3583b3c0f..27db2a86f 100644 --- a/src/datamodel_code_generator/parser/base.py +++ b/src/datamodel_code_generator/parser/base.py @@ -26,6 +26,7 @@ DEFAULT_SHARED_MODULE_NAME, AllExportsCollisionStrategy, AllExportsScope, + AllOfClassHierarchy, AllOfMergeMode, CollapseRootModelsNameStrategy, Error, @@ -751,6 +752,7 @@ def __init__( # noqa: PLR0912, PLR0913, PLR0915 use_unique_items_as_set: bool = False, use_tuple_for_fixed_items: bool = False, allof_merge_mode: AllOfMergeMode = AllOfMergeMode.Constraints, + allof_class_hierarchy: AllOfClassHierarchy = AllOfClassHierarchy.IfNoConflict, http_headers: Sequence[tuple[str, str]] | None = None, http_ignore_tls: bool = False, http_timeout: float | None = None, @@ -873,6 +875,7 @@ def __init__( # noqa: PLR0912, PLR0913, PLR0915 self.use_unique_items_as_set: bool = use_unique_items_as_set self.use_tuple_for_fixed_items: bool = use_tuple_for_fixed_items self.allof_merge_mode: AllOfMergeMode = allof_merge_mode + self.allof_class_hierarchy: AllOfClassHierarchy = allof_class_hierarchy self.dataclass_arguments = dataclass_arguments if base_path: diff --git a/src/datamodel_code_generator/parser/graphql.py b/src/datamodel_code_generator/parser/graphql.py index b583399e6..450cd3436 100644 --- a/src/datamodel_code_generator/parser/graphql.py +++ b/src/datamodel_code_generator/parser/graphql.py @@ -15,6 +15,7 @@ from datamodel_code_generator import ( DEFAULT_SHARED_MODULE_NAME, + AllOfClassHierarchy, AllOfMergeMode, CollapseRootModelsNameStrategy, DataclassArguments, @@ -166,6 +167,7 @@ def __init__( # noqa: PLR0913 use_unique_items_as_set: bool = False, use_tuple_for_fixed_items: bool = False, allof_merge_mode: AllOfMergeMode = AllOfMergeMode.Constraints, + allof_class_hierarchy: AllOfClassHierarchy = AllOfClassHierarchy.IfNoConflict, http_headers: Sequence[tuple[str, str]] | None = None, http_ignore_tls: bool = False, http_timeout: float | None = None, @@ -282,6 +284,7 @@ def __init__( # noqa: PLR0913 use_unique_items_as_set=use_unique_items_as_set, use_tuple_for_fixed_items=use_tuple_for_fixed_items, allof_merge_mode=allof_merge_mode, + allof_class_hierarchy=allof_class_hierarchy, http_headers=http_headers, http_ignore_tls=http_ignore_tls, http_timeout=http_timeout, diff --git a/src/datamodel_code_generator/parser/jsonschema.py b/src/datamodel_code_generator/parser/jsonschema.py index 02e5fbb19..c2fc02e45 100644 --- a/src/datamodel_code_generator/parser/jsonschema.py +++ b/src/datamodel_code_generator/parser/jsonschema.py @@ -25,6 +25,7 @@ from datamodel_code_generator import ( DEFAULT_SHARED_MODULE_NAME, + AllOfClassHierarchy, AllOfMergeMode, CollapseRootModelsNameStrategy, DataclassArguments, @@ -704,6 +705,7 @@ def __init__( # noqa: PLR0913 use_unique_items_as_set: bool = False, use_tuple_for_fixed_items: bool = False, allof_merge_mode: AllOfMergeMode = AllOfMergeMode.Constraints, + allof_class_hierarchy: AllOfClassHierarchy = AllOfClassHierarchy.IfNoConflict, http_headers: Sequence[tuple[str, str]] | None = None, http_ignore_tls: bool = False, http_timeout: float | None = None, @@ -819,6 +821,7 @@ def __init__( # noqa: PLR0913 use_unique_items_as_set=use_unique_items_as_set, use_tuple_for_fixed_items=use_tuple_for_fixed_items, allof_merge_mode=allof_merge_mode, + allof_class_hierarchy=allof_class_hierarchy, http_headers=http_headers, http_ignore_tls=http_ignore_tls, http_timeout=http_timeout, @@ -2031,6 +2034,10 @@ def _merge_all_of_object(self, obj: JsonSchemaObject) -> JsonSchemaObject | None Continue merging when multiple $refs have conflicting property definitions to avoid MRO issues. Child property overrides (obj.properties) are not considered conflicts. """ + if self.allof_class_hierarchy == AllOfClassHierarchy.Always: + # Skip merging when always inherit from the base classes + return None + ref_count = sum(1 for item in obj.allOf if item.ref) if ref_count == 1: return None diff --git a/src/datamodel_code_generator/parser/openapi.py b/src/datamodel_code_generator/parser/openapi.py index f11134fcf..fe6ea2038 100644 --- a/src/datamodel_code_generator/parser/openapi.py +++ b/src/datamodel_code_generator/parser/openapi.py @@ -19,6 +19,7 @@ from datamodel_code_generator import ( DEFAULT_SHARED_MODULE_NAME, + AllOfClassHierarchy, AllOfMergeMode, CollapseRootModelsNameStrategy, DataclassArguments, @@ -250,6 +251,7 @@ def __init__( # noqa: PLR0913 use_unique_items_as_set: bool = False, use_tuple_for_fixed_items: bool = False, allof_merge_mode: AllOfMergeMode = AllOfMergeMode.Constraints, + allof_class_hierarchy: AllOfClassHierarchy = AllOfClassHierarchy.IfNoConflict, http_headers: Sequence[tuple[str, str]] | None = None, http_ignore_tls: bool = False, http_timeout: float | None = None, @@ -366,6 +368,7 @@ def __init__( # noqa: PLR0913 use_unique_items_as_set=use_unique_items_as_set, use_tuple_for_fixed_items=use_tuple_for_fixed_items, allof_merge_mode=allof_merge_mode, + allof_class_hierarchy=allof_class_hierarchy, http_headers=http_headers, http_ignore_tls=http_ignore_tls, http_timeout=http_timeout, diff --git a/tests/data/expected/main/jsonschema/allof_class_hierarchy.py b/tests/data/expected/main/jsonschema/allof_class_hierarchy.py new file mode 100644 index 000000000..e328af8b2 --- /dev/null +++ b/tests/data/expected/main/jsonschema/allof_class_hierarchy.py @@ -0,0 +1,37 @@ +# generated by datamodel-codegen: +# filename: allof_class_hierarchy.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel, Field, constr + + +class Entity(BaseModel): + type: str + type_list: list[str] | None = ['playground:Entity'] + + +class Entity2(BaseModel): + type: str + type_list: list[str] + + +class Thing(Entity): + type: str | None = 'playground:Thing' + type_list: list[str] | None = ['playground:Thing'] + name: constr(min_length=1) = Field(..., description='The things name') + + +class Location(Entity2): + type: str | None = 'playground:Location' + type_list: list[str] | None = ['playground:Location'] + address: constr(min_length=5) = Field( + ..., description='The address of the location' + ) + + +class Person(Thing, Location): + name: constr(min_length=1) | None = Field(None, description="The person's name") + type: str | None = 'playground:Person' + type_list: list[str] | None = ['playground:Person'] diff --git a/tests/data/expected/main/jsonschema/allof_class_hierarchy_ref.py b/tests/data/expected/main/jsonschema/allof_class_hierarchy_ref.py new file mode 100644 index 000000000..b7279c9de --- /dev/null +++ b/tests/data/expected/main/jsonschema/allof_class_hierarchy_ref.py @@ -0,0 +1,46 @@ +# generated by datamodel-codegen: +# filename: allof_class_hierarchy.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field, constr + + +class Person(BaseModel): + name: constr(min_length=1) = Field(..., description='The things name') + type: Any | None = 'playground:Location' + type_list: list[Any] | None = [ + 'playground:Person', + 'playground:Thing', + 'playground:Location', + ] + address: constr(min_length=5) = Field( + ..., description='The address of the location' + ) + + +class Entity(BaseModel): + type: str + type_list: list[str] | None = ['playground:Entity'] + + +class Entity2(BaseModel): + type: str + type_list: list[str] + + +class Thing(Entity): + type: str | None = 'playground:Thing' + type_list: list[str] | None = ['playground:Thing'] + name: constr(min_length=1) = Field(..., description='The things name') + + +class Location(Entity2): + type: str | None = 'playground:Location' + type_list: list[str] | None = ['playground:Location'] + address: constr(min_length=5) = Field( + ..., description='The address of the location' + ) diff --git a/tests/data/jsonschema/allof_class_hierarchy.json b/tests/data/jsonschema/allof_class_hierarchy.json new file mode 100644 index 000000000..1d791f3bc --- /dev/null +++ b/tests/data/jsonschema/allof_class_hierarchy.json @@ -0,0 +1,144 @@ +{ + "$defs": { + "Entity": { + "title": "Entity", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "default": "playground:Entity" + }, + "type_list": { + "type": "array", + "default": [ + "playground:Entity" + ], + "items": { + "type": "string", + "options": { + "hidden": true + } + } + }, + } + }, + "Entity2": { + "title": "Entity2", + "type": "object", + "required": [ + "type", + "type_list" + ], + "properties": { + "type": { + "type": "string", + "default": "playground:Entity2" + }, + "type_list": { + "type": "array", + "default": [ + "playground:Entity2" + ], + "items": { + "type": "string", + "options": { + "hidden": true + } + } + }, + } + }, + "Thing": { + "allOf": [ + { + "$ref": "#/$defs/Entity" + } + ], + "title": "Thing", + "type": "object", + "required": [ + "name" + ], + "properties": { + "type": { + "default": "playground:Thing", + "options": { + "hidden": true + } + }, + "type_list": { + "default": [ + "playground:Thing" + ], + "items": { + "title": "Some other title" + } + }, + "name": { + "type": "string", + "description": "The things name", + "minLength": 1, + "default": "A Thing" + } + } + }, + "Location": { + "allOf": [ + { + "$ref": "#/$defs/Entity2" + } + ], + "title": "Location", + "type": "object", + "required": [ + "address" + ], + "properties": { + "type": { + "default": "playground:Location" + }, + "type_list": { + "default": [ + "playground:Location" + ], + "items": { + "title": "Some other title" + } + }, + "address": { + "type": "string", + "description": "The address of the location", + "minLength": 5, + "default": "123 Main St" + } + } + } + }, + "allOf": [ + { + "$ref": "#/$defs/Thing" + }, + { + "$ref": "#/$defs/Location" + } + ], + "title": "Person", + "type": "object", + "properties": { + "name": { + "description": "The person's name" + }, + "type": { + "$comment": "Already defined in playground:Thing -> we override just the default", + "default": "playground:Person" + }, + "type_list": { + "default": [ + "playground:Person" + ] + } + } +} \ No newline at end of file diff --git a/tests/main/jsonschema/test_main_jsonschema.py b/tests/main/jsonschema/test_main_jsonschema.py index c0a02942e..48a3bf6f3 100644 --- a/tests/main/jsonschema/test_main_jsonschema.py +++ b/tests/main/jsonschema/test_main_jsonschema.py @@ -6347,6 +6347,37 @@ def test_main_allof_root_model_constraints_merge(output_file: Path) -> None: ) +@pytest.mark.cli_doc( + options=["--allof-class-hierarchy"], + option_description="""Controls how allOf schemas are represented in the generated class hierarchy. +`--allof-class-hierarchy if-no-conflict` (default) creates parent classes for allOf schemas +only when there are no property conflicts between parent schemas. Otherwise, properties are merged into the child class +which is then decoupled from the parent classes and no longer inherits from them. +`--allof-class-hierarchy always` keeps class hierarchy for allOf schemas, +even in multiple inheritance scenarios where two parent schemas define the same property.""", + input_schema="jsonschema/allof_root_model_constraints.json", + cli_args=["--allof-class-hierarchy", "always"], + golden_output="main/jsonschema/allof_class_hierarchy.py", + comparison_output="main/jsonschema/allof_class_hierarchy_ref.py", +) +@pytest.mark.benchmark +def test_main_allof_class_hierarchy(output_file: Path) -> None: + """Control how allOf schemas are represented in the generated class hierarchy. + + The `--allof-class-hierarchy` option configures whether the generator preserves + parent classes for allOf schemas (even when there are property conflicts) or + falls back to merging properties into a single class. + """ + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "allof_class_hierarchy.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="allof_class_hierarchy.py", + extra_args=["--allof-class-hierarchy", "always"], + ) + + @pytest.mark.benchmark def test_main_allof_root_model_constraints_none(output_file: Path) -> None: """Test allOf with root model reference without merging (issue #1901).""" diff --git a/tests/main/test_public_api_signature_baseline.py b/tests/main/test_public_api_signature_baseline.py index 7042596bd..7110241c4 100644 --- a/tests/main/test_public_api_signature_baseline.py +++ b/tests/main/test_public_api_signature_baseline.py @@ -13,6 +13,7 @@ from datamodel_code_generator.enums import ( AllExportsCollisionStrategy, AllExportsScope, + AllOfClassHierarchy, AllOfMergeMode, CollapseRootModelsNameStrategy, DataModelType, @@ -122,6 +123,7 @@ def _baseline_generate( use_unique_items_as_set: bool = False, use_tuple_for_fixed_items: bool = False, allof_merge_mode: AllOfMergeMode = AllOfMergeMode.Constraints, + allof_class_hierarchy: AllOfClassHierarchy = AllOfClassHierarchy.IfNoConflict, http_headers: Sequence[tuple[str, str]] | None = None, http_ignore_tls: bool = False, http_timeout: float | None = None, @@ -244,6 +246,7 @@ def __init__( use_unique_items_as_set: bool = False, use_tuple_for_fixed_items: bool = False, allof_merge_mode: AllOfMergeMode = AllOfMergeMode.Constraints, + allof_class_hierarchy: AllOfClassHierarchy = AllOfClassHierarchy.IfNoConflict, http_headers: Sequence[tuple[str, str]] | None = None, http_ignore_tls: bool = False, http_timeout: float | None = None,