Skip to content

Commit 58e73ed

Browse files
authored
Add __hash__ to Pydantic v2 models used in sets (#2918)
* Add __hash__ to Pydantic v2 models used in sets * Fix lint errors: refactor to classmethods and reduce nesting * Fix cross-module set item hash detection * Refactor: use generic class_body_lines instead of set_item_hashable
1 parent 960f7f9 commit 58e73ed

File tree

9 files changed

+111
-2
lines changed

9 files changed

+111
-2
lines changed

src/datamodel_code_generator/model/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,7 @@ def __init__( # noqa: PLR0913
678678

679679
self.reference.source = self
680680

681+
self.extra_template_data: dict[str, Any]
681682
if extra_template_data is not None:
682683
# The supplied defaultdict will either create a new entry,
683684
# or already contain a predefined entry for this type

src/datamodel_code_generator/model/template/pydantic_v2/BaseModel.jinja2

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,17 @@ class {{ class_name }}({{ base_class }}):{% if comment is defined %} # {{ comme
77
{{ description | escape_docstring | indent(4) }}
88
"""
99
{%- endif %}
10-
{%- if not fields and not description and not config %}
10+
{%- if not fields and not description and not config and not class_body_lines %}
1111
pass
1212
{%- endif %}
1313
{%- if config %}
1414
{%- filter indent(4) %}
1515
{% include 'ConfigDict.jinja2' %}
1616
{%- endfilter %}
1717
{%- endif %}
18+
{%- for line in class_body_lines %}
19+
{{ line }}
20+
{%- endfor %}
1821
{%- for field in fields %}
1922
{%- if not field.annotated and field.field %}
2023
{{ field.name }}: {{ field.type_hint }} = {{ field.field }}

src/datamodel_code_generator/model/template/pydantic_v2/RootModel.jinja2

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ class {{ class_name }}({{ base_class }}{%- if fields -%}[{{get_type_hint(fields,
2727
{% include 'ConfigDict.jinja2' %}
2828
{%- endfilter %}
2929
{%- endif %}
30-
{%- if not fields and not description %}
30+
{%- for line in class_body_lines %}
31+
{{ line }}
32+
{%- endfor %}
33+
{%- if not fields and not description and not config and not class_body_lines %}
3134
pass
3235
{%- else %}
3336
{%- set field = fields[0] %}

src/datamodel_code_generator/parser/base.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1564,6 +1564,32 @@ def __replace_unique_list_to_set(self, models: list[DataModel]) -> None:
15641564
model_field.default = converted_default
15651565
model_field.replace_data_type(set_data_type)
15661566

1567+
@classmethod
1568+
def __collect_set_item_references(cls, models: list[DataModel]) -> set[str]:
1569+
"""Collect reference paths of all types used as set/frozenset items."""
1570+
references: set[str] = set()
1571+
for model in models:
1572+
for field in model.fields:
1573+
for data_type in field.data_type.all_data_types:
1574+
if data_type.is_set or data_type.is_frozen_set:
1575+
for item_type in data_type.data_types:
1576+
references.update(
1577+
nested.reference.path for nested in item_type.all_data_types if nested.reference
1578+
)
1579+
return references
1580+
1581+
@classmethod
1582+
def __mark_set_item_models_hashable(cls, models: list[DataModel]) -> None:
1583+
"""Mark models used as set/frozenset items with hash flag for __hash__ generation."""
1584+
set_item_references = cls.__collect_set_item_references(models)
1585+
1586+
for model in models:
1587+
if model.reference.path in set_item_references:
1588+
if isinstance(model, Enum):
1589+
continue
1590+
class_body_lines = model.extra_template_data.setdefault("class_body_lines", [])
1591+
class_body_lines.append("__hash__ = object.__hash__")
1592+
15671593
@classmethod
15681594
def __set_reference_default_value_to_field(cls, models: list[DataModel]) -> None:
15691595
for model in models:
@@ -2981,6 +3007,8 @@ def _finalize_modules(
29813007
module_to_import: dict[ModulePath, Imports],
29823008
) -> None:
29833009
"""Finalize module processing: apply generic base class and remove unused imports."""
3010+
all_models = [model for ctx in contexts for model in ctx.models]
3011+
self.__mark_set_item_models_hashable(all_models)
29843012
self.__apply_generic_base_class(contexts)
29853013

29863014
for ctx in contexts:
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# generated by datamodel-codegen:
2+
# filename: unique_items_enum_set.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from enum import Enum
8+
9+
from pydantic import BaseModel
10+
11+
12+
class Status(Enum):
13+
active = 'active'
14+
inactive = 'inactive'
15+
pending = 'pending'
16+
17+
18+
class Item(BaseModel):
19+
__hash__ = object.__hash__
20+
name: str | None = None
21+
22+
23+
class Container(BaseModel):
24+
statuses: set[Status] | None = None
25+
items: set[Item] | None = None

tests/data/expected/main/openapi/with_field_constraints_pydantic_v2_use_generic_container_types_set.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111

1212
class Pet(BaseModel):
13+
__hash__ = object.__hash__
1314
id: int = Field(..., ge=0, le=9223372036854775807)
1415
name: str = Field(..., max_length=256)
1516
tag: str | None = Field(None, max_length=64)

tests/data/expected/main/openapi/with_field_constraints_pydantic_v2_use_standard_collections_set.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99

1010
class Pet(BaseModel):
11+
__hash__ = object.__hash__
1112
id: int = Field(..., ge=0, le=9223372036854775807)
1213
name: str = Field(..., max_length=256)
1314
tag: str | None = Field(None, max_length=64)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"title": "Container",
4+
"type": "object",
5+
"definitions": {
6+
"Status": {
7+
"type": "string",
8+
"enum": ["active", "inactive", "pending"]
9+
},
10+
"Item": {
11+
"type": "object",
12+
"properties": {
13+
"name": {"type": "string"}
14+
}
15+
}
16+
},
17+
"properties": {
18+
"statuses": {
19+
"type": "array",
20+
"uniqueItems": true,
21+
"items": {"$ref": "#/definitions/Status"}
22+
},
23+
"items": {
24+
"type": "array",
25+
"uniqueItems": true,
26+
"items": {"$ref": "#/definitions/Item"}
27+
}
28+
}
29+
}

tests/main/jsonschema/test_main_jsonschema.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7957,3 +7957,21 @@ def test_validators_requires_pydantic_v2(output_file: Path, tmp_path: Path, caps
79577957
capsys=capsys,
79587958
expected_stderr_contains="--validators option requires Pydantic v2",
79597959
)
7960+
7961+
7962+
@PYDANTIC_V2_SKIP
7963+
def test_unique_items_enum_set(output_file: Path) -> None:
7964+
"""Test set with enum items does not add __hash__ to enum (already hashable)."""
7965+
run_main_and_assert(
7966+
input_path=JSON_SCHEMA_DATA_PATH / "unique_items_enum_set.json",
7967+
output_path=output_file,
7968+
input_file_type="jsonschema",
7969+
assert_func=assert_file_content,
7970+
expected_file="unique_items_enum_set.py",
7971+
extra_args=[
7972+
"--output-model-type",
7973+
"pydantic_v2.BaseModel",
7974+
"--use-unique-items-as-set",
7975+
"--use-standard-collections",
7976+
],
7977+
)

0 commit comments

Comments
 (0)