Skip to content

Commit 6aa5668

Browse files
koxudaxipre-commit-ci[bot]github-actions[bot]
authored
Fix allOf partial override to inherit parent constraints and add --allof-merge-mode option (#2671)
* Fix type inheritance for nested array items in allOf partial overrides * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * docs: update command help in README 🤖 Generated by GitHub Actions * feat: implement GraphQL schema parser to generate Python data models * Fix tags initialization to use set literals for unique items in allOf partial overrides * Fix tags initialization to use set literals for unique items in allOf partial overrides * Add tests for OpenAPI allOf with parent bool property and multiple parents with same property --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent b72471d commit 6aa5668

25 files changed

Lines changed: 512 additions & 1 deletion

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,12 @@ Options:
362362
--url URL Input file URL. `--input` is ignored when `--url` is used
363363

364364
Typing customization:
365+
--allof-merge-mode {constraints,all,none}
366+
Mode for field merging in allOf schemas. ''constraints'': merge only
367+
constraints (minItems, maxItems, pattern, etc.) from parent
368+
(default). ''all'': merge constraints plus annotations (default,
369+
examples) from parent. ''none'': do not merge any fields from parent
370+
properties.
365371
--base-class BASE_CLASS
366372
Base Class (default: pydantic.BaseModel)
367373
--disable-future-imports

docs/index.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,12 @@ Options:
354354
--url URL Input file URL. `--input` is ignored when `--url` is used
355355

356356
Typing customization:
357+
--allof-merge-mode {constraints,all,none}
358+
Mode for field merging in allOf schemas. ''constraints'': merge only
359+
constraints (minItems, maxItems, pattern, etc.) from parent
360+
(default). ''all'': merge constraints plus annotations (default,
361+
examples) from parent. ''none'': do not merge any fields from parent
362+
properties.
357363
--base-class BASE_CLASS
358364
Base Class (default: pydantic.BaseModel)
359365
--disable-future-imports

src/datamodel_code_generator/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,19 @@ class AllExportsCollisionStrategy(Enum):
270270
FullPrefix = "full-prefix"
271271

272272

273+
class AllOfMergeMode(Enum):
274+
"""Mode for field merging in allOf schemas.
275+
276+
constraints: Merge only constraint fields (minItems, maxItems, pattern, etc.) from parent.
277+
all: Merge constraints plus annotation fields (default, examples) from parent.
278+
none: Do not merge any fields from parent properties.
279+
"""
280+
281+
Constraints = "constraints"
282+
All = "all"
283+
NoMerge = "none"
284+
285+
273286
class GraphQLScope(Enum):
274287
"""Scopes for GraphQL model generation."""
275288

@@ -415,6 +428,7 @@ def generate( # noqa: PLR0912, PLR0913, PLR0914, PLR0915
415428
use_title_as_name: bool = False,
416429
use_operation_id_as_name: bool = False,
417430
use_unique_items_as_set: bool = False,
431+
allof_merge_mode: AllOfMergeMode = AllOfMergeMode.Constraints,
418432
http_headers: Sequence[tuple[str, str]] | None = None,
419433
http_ignore_tls: bool = False,
420434
use_annotated: bool = False,
@@ -657,6 +671,7 @@ def get_header_and_first_line(csv_file: IO[str]) -> dict[str, Any]:
657671
use_title_as_name=use_title_as_name,
658672
use_operation_id_as_name=use_operation_id_as_name,
659673
use_unique_items_as_set=use_unique_items_as_set,
674+
allof_merge_mode=allof_merge_mode,
660675
http_headers=http_headers,
661676
http_ignore_tls=http_ignore_tls,
662677
use_annotated=use_annotated,

src/datamodel_code_generator/__main__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
DEFAULT_SHARED_MODULE_NAME,
2525
AllExportsCollisionStrategy,
2626
AllExportsScope,
27+
AllOfMergeMode,
2728
DataclassArguments,
2829
DataModelType,
2930
Error,
@@ -421,6 +422,7 @@ def validate_all_exports_collision_strategy(cls, values: dict[str, Any]) -> dict
421422
use_title_as_name: bool = False
422423
use_operation_id_as_name: bool = False
423424
use_unique_items_as_set: bool = False
425+
allof_merge_mode: AllOfMergeMode = AllOfMergeMode.Constraints
424426
http_headers: Optional[Sequence[tuple[str, str]]] = None # noqa: UP045
425427
http_ignore_tls: bool = False
426428
use_annotated: bool = False
@@ -867,6 +869,7 @@ def main(args: Sequence[str] | None = None) -> Exit: # noqa: PLR0911, PLR0912,
867869
use_title_as_name=config.use_title_as_name,
868870
use_operation_id_as_name=config.use_operation_id_as_name,
869871
use_unique_items_as_set=config.use_unique_items_as_set,
872+
allof_merge_mode=config.allof_merge_mode,
870873
http_headers=config.http_headers,
871874
http_ignore_tls=config.http_ignore_tls,
872875
use_annotated=config.use_annotated,

src/datamodel_code_generator/arguments.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
DEFAULT_SHARED_MODULE_NAME,
1919
AllExportsCollisionStrategy,
2020
AllExportsScope,
21+
AllOfMergeMode,
2122
DataclassArguments,
2223
DataModelType,
2324
InputFileType,
@@ -422,6 +423,15 @@ def start_section(self, heading: str | None) -> None:
422423
action="store_true",
423424
default=None,
424425
)
426+
typing_options.add_argument(
427+
"--allof-merge-mode",
428+
help="Mode for field merging in allOf schemas. "
429+
"'constraints': merge only constraints (minItems, maxItems, pattern, etc.) from parent (default). "
430+
"'all': merge constraints plus annotations (default, examples) from parent. "
431+
"'none': do not merge any fields from parent properties.",
432+
choices=[m.value for m in AllOfMergeMode],
433+
default=None,
434+
)
425435
typing_options.add_argument(
426436
"--use-type-alias",
427437
help="Use TypeAlias instead of root models (experimental)",

src/datamodel_code_generator/parser/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
DEFAULT_SHARED_MODULE_NAME,
2828
AllExportsCollisionStrategy,
2929
AllExportsScope,
30+
AllOfMergeMode,
3031
Error,
3132
ReadOnlyWriteOnlyModelType,
3233
ReuseScope,
@@ -698,6 +699,7 @@ def __init__( # noqa: PLR0913, PLR0915
698699
use_title_as_name: bool = False,
699700
use_operation_id_as_name: bool = False,
700701
use_unique_items_as_set: bool = False,
702+
allof_merge_mode: AllOfMergeMode = AllOfMergeMode.Constraints,
701703
http_headers: Sequence[tuple[str, str]] | None = None,
702704
http_ignore_tls: bool = False,
703705
use_annotated: bool = False,
@@ -796,6 +798,7 @@ def __init__( # noqa: PLR0913, PLR0915
796798
self.use_title_as_name: bool = use_title_as_name
797799
self.use_operation_id_as_name: bool = use_operation_id_as_name
798800
self.use_unique_items_as_set: bool = use_unique_items_as_set
801+
self.allof_merge_mode: AllOfMergeMode = allof_merge_mode
799802
self.dataclass_arguments = dataclass_arguments
800803

801804
if base_path:

src/datamodel_code_generator/parser/graphql.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from datamodel_code_generator import (
1818
DEFAULT_SHARED_MODULE_NAME,
19+
AllOfMergeMode,
1920
DataclassArguments,
2021
DefaultPutDict,
2122
LiteralType,
@@ -151,6 +152,7 @@ def __init__( # noqa: PLR0913
151152
use_title_as_name: bool = False,
152153
use_operation_id_as_name: bool = False,
153154
use_unique_items_as_set: bool = False,
155+
allof_merge_mode: AllOfMergeMode = AllOfMergeMode.Constraints,
154156
http_headers: Sequence[tuple[str, str]] | None = None,
155157
http_ignore_tls: bool = False,
156158
use_annotated: bool = False,
@@ -245,6 +247,7 @@ def __init__( # noqa: PLR0913
245247
use_title_as_name=use_title_as_name,
246248
use_operation_id_as_name=use_operation_id_as_name,
247249
use_unique_items_as_set=use_unique_items_as_set,
250+
allof_merge_mode=allof_merge_mode,
248251
http_headers=http_headers,
249252
http_ignore_tls=http_ignore_tls,
250253
use_annotated=use_annotated,

src/datamodel_code_generator/parser/jsonschema.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
from datamodel_code_generator import (
2525
DEFAULT_SHARED_MODULE_NAME,
26+
AllOfMergeMode,
2627
DataclassArguments,
2728
InvalidClassNameError,
2829
ReadOnlyWriteOnlyModelType,
@@ -562,6 +563,7 @@ def __init__( # noqa: PLR0913
562563
use_title_as_name: bool = False,
563564
use_operation_id_as_name: bool = False,
564565
use_unique_items_as_set: bool = False,
566+
allof_merge_mode: AllOfMergeMode = AllOfMergeMode.Constraints,
565567
http_headers: Sequence[tuple[str, str]] | None = None,
566568
http_ignore_tls: bool = False,
567569
use_annotated: bool = False,
@@ -655,6 +657,7 @@ def __init__( # noqa: PLR0913
655657
use_title_as_name=use_title_as_name,
656658
use_operation_id_as_name=use_operation_id_as_name,
657659
use_unique_items_as_set=use_unique_items_as_set,
660+
allof_merge_mode=allof_merge_mode,
658661
http_headers=http_headers,
659662
http_ignore_tls=http_ignore_tls,
660663
use_annotated=use_annotated,
@@ -1365,6 +1368,68 @@ def _is_list_with_any_item_type(self, data_type: DataType | None) -> bool: # no
13651368
break
13661369
return item_type.type == ANY
13671370

1371+
def _merge_property_schemas(self, parent_dict: dict[str, Any], child_dict: dict[str, Any]) -> dict[str, Any]:
1372+
"""Merge parent and child property schemas for allOf."""
1373+
if self.allof_merge_mode == AllOfMergeMode.NoMerge:
1374+
return child_dict.copy()
1375+
1376+
non_merged_fields: set[str] = set()
1377+
if self.allof_merge_mode == AllOfMergeMode.Constraints:
1378+
non_merged_fields = {"default", "examples", "example"}
1379+
1380+
result = {key: value for key, value in parent_dict.items() if key not in non_merged_fields}
1381+
1382+
for key, value in child_dict.items():
1383+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
1384+
result[key] = self._merge_property_schemas(result[key], value)
1385+
else:
1386+
result[key] = value
1387+
return result
1388+
1389+
def _merge_properties_with_parent_constraints(
1390+
self, child_obj: JsonSchemaObject, parent_refs: list[str]
1391+
) -> JsonSchemaObject:
1392+
"""Merge child properties with parent property constraints for allOf inheritance."""
1393+
if not child_obj.properties:
1394+
return child_obj
1395+
1396+
parent_properties: dict[str, JsonSchemaObject] = {}
1397+
for ref in parent_refs:
1398+
try:
1399+
parent_schema = self._load_ref_schema_object(ref)
1400+
except Exception: # pragma: no cover # noqa: BLE001, S112
1401+
continue
1402+
if parent_schema.properties:
1403+
for prop_name, prop_schema in parent_schema.properties.items():
1404+
if isinstance(prop_schema, JsonSchemaObject) and prop_name not in parent_properties:
1405+
parent_properties[prop_name] = prop_schema
1406+
1407+
if not parent_properties:
1408+
return child_obj
1409+
1410+
merged_properties: dict[str, JsonSchemaObject | bool] = {}
1411+
for prop_name, child_prop in child_obj.properties.items():
1412+
if not isinstance(child_prop, JsonSchemaObject):
1413+
merged_properties[prop_name] = child_prop
1414+
continue
1415+
1416+
parent_prop = parent_properties.get(prop_name)
1417+
if parent_prop is None:
1418+
merged_properties[prop_name] = child_prop
1419+
continue
1420+
1421+
parent_dict = parent_prop.dict(exclude_unset=True, by_alias=True)
1422+
child_dict = child_prop.dict(exclude_unset=True, by_alias=True)
1423+
merged_dict = self._merge_property_schemas(parent_dict, child_dict)
1424+
merged_properties[prop_name] = self.SCHEMA_OBJECT_TYPE.parse_obj(merged_dict)
1425+
1426+
merged_obj_dict = child_obj.dict(exclude_unset=True, by_alias=True)
1427+
merged_obj_dict["properties"] = {
1428+
k: v.dict(exclude_unset=True, by_alias=True) if isinstance(v, JsonSchemaObject) else v
1429+
for k, v in merged_properties.items()
1430+
}
1431+
return self.SCHEMA_OBJECT_TYPE.parse_obj(merged_obj_dict)
1432+
13681433
def _get_inherited_field_type(self, prop_name: str, base_classes: list[Reference]) -> DataType | None:
13691434
"""Get the data type for an inherited property from parent schemas."""
13701435
for base in base_classes:
@@ -1662,6 +1727,8 @@ def _parse_all_of_item( # noqa: PLR0912, PLR0913, PLR0917
16621727
required: list[str],
16631728
union_models: list[Reference],
16641729
) -> None:
1730+
parent_refs = [item.ref for item in obj.allOf if item.ref]
1731+
16651732
for all_of_item in obj.allOf: # noqa: PLR1702
16661733
if all_of_item.ref: # $ref
16671734
ref_schema = self._load_ref_schema_object(all_of_item.ref)
@@ -1681,9 +1748,11 @@ def _parse_all_of_item( # noqa: PLR0912, PLR0913, PLR0917
16811748
if ref.path not in {b.path for b in base_classes}:
16821749
base_classes.append(ref)
16831750
else:
1751+
# Merge child properties with parent constraints before processing
1752+
merged_item = self._merge_properties_with_parent_constraints(all_of_item, parent_refs)
16841753
module_name = get_module_name(name, None, treat_dot_as_module=self.treat_dot_as_module)
16851754
object_fields = self.parse_object_fields(
1686-
all_of_item,
1755+
merged_item,
16871756
path,
16881757
module_name,
16891758
class_name=name,

src/datamodel_code_generator/parser/openapi.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
from datamodel_code_generator import (
2121
DEFAULT_SHARED_MODULE_NAME,
22+
AllOfMergeMode,
2223
DataclassArguments,
2324
Error,
2425
LiteralType,
@@ -235,6 +236,7 @@ def __init__( # noqa: PLR0913
235236
use_title_as_name: bool = False,
236237
use_operation_id_as_name: bool = False,
237238
use_unique_items_as_set: bool = False,
239+
allof_merge_mode: AllOfMergeMode = AllOfMergeMode.Constraints,
238240
http_headers: Sequence[tuple[str, str]] | None = None,
239241
http_ignore_tls: bool = False,
240242
use_annotated: bool = False,
@@ -328,6 +330,7 @@ def __init__( # noqa: PLR0913
328330
use_title_as_name=use_title_as_name,
329331
use_operation_id_as_name=use_operation_id_as_name,
330332
use_unique_items_as_set=use_unique_items_as_set,
333+
allof_merge_mode=allof_merge_mode,
331334
http_headers=http_headers,
332335
http_ignore_tls=http_ignore_tls,
333336
use_annotated=use_annotated,
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# generated by datamodel-codegen:
2+
# filename: allof_materialize_defaults.yaml
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Optional
8+
9+
from pydantic import BaseModel, conint, constr
10+
11+
12+
class Parent(BaseModel):
13+
name: Optional[constr(min_length=1)] = 'parent_default'
14+
count: Optional[conint(ge=0)] = 10
15+
16+
17+
class Child(Parent):
18+
name: Optional[constr(min_length=1, max_length=100)] = 'parent_default'
19+
count: Optional[conint(ge=0, le=1000)] = 10

0 commit comments

Comments
 (0)