Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/datamodel_code_generator/model/pydantic/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,11 +158,17 @@ def strict_type_map_factory(data_type: type[DataType]) -> dict[StrictTypes, Data
"\t": r"\t",
})

HOSTNAME_REGEX = ( # Pydantic v1 requires \Z anchor (not $) to avoid matching trailing newline
r"^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])\.)*"
r"([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9])\Z"
)


class DataTypeManager(_DataTypeManager):
"""Manage data type mappings for Pydantic v1 models."""

PATTERN_KEY: ClassVar[str] = "regex"
HOSTNAME_REGEX: ClassVar[str] = HOSTNAME_REGEX

def __init__( # noqa: PLR0913, PLR0917
self,
Expand Down Expand Up @@ -334,6 +340,8 @@ def get_data_bytes_type(self, types: Types, **kwargs: Any) -> DataType:
def get_data_type( # noqa: PLR0911
self,
types: Types,
*,
field_constraints: bool = False,
**kwargs: Any,
) -> DataType:
"""Get data type with appropriate constraints for the given type."""
Expand All @@ -349,5 +357,10 @@ def get_data_type( # noqa: PLR0911
return self.get_data_bytes_type(types, **kwargs)
if types == Types.boolean and StrictTypes.bool in self.strict_types:
return self.strict_type_map[StrictTypes.bool]
if types == Types.hostname and field_constraints:
strict = StrictTypes.str in self.strict_types
if strict:
return self.strict_type_map[StrictTypes.str]
return self.data_type(type="str")

return self.type_map[types]
6 changes: 6 additions & 0 deletions src/datamodel_code_generator/model/pydantic_v2/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@

from datamodel_code_generator.imports import Import

HOSTNAME_REGEX = (
r"^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])\.)*"
r"([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9])$"
)


class PydanticV2DataType(DataType):
"""Pydantic v2-specific DataType with SerializeAsAny support."""
Expand Down Expand Up @@ -62,6 +67,7 @@ class DataTypeManager(_DataTypeManager):
"""Type manager for Pydantic v2 with pattern key support."""

PATTERN_KEY: ClassVar[str] = "pattern"
HOSTNAME_REGEX: ClassVar[str] = HOSTNAME_REGEX

def __init__( # noqa: PLR0913, PLR0917
self,
Expand Down
11 changes: 10 additions & 1 deletion src/datamodel_code_generator/model/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from __future__ import annotations

from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, ClassVar

from datamodel_code_generator import DatetimeClassType, PythonVersion, PythonVersionMin
from datamodel_code_generator.imports import (
Expand All @@ -19,6 +19,11 @@
if TYPE_CHECKING:
from collections.abc import Sequence

HOSTNAME_REGEX = (
r"^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])\.)*"
r"([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9])$"
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def type_map_factory(data_type: type[DataType]) -> dict[Types, DataType]:
"""Create type mapping for common schema types to Python types."""
Expand Down Expand Up @@ -66,6 +71,8 @@ def type_map_factory(data_type: type[DataType]) -> dict[Types, DataType]:
class DataTypeManager(_DataTypeManager):
"""Base type manager for model modules."""

HOSTNAME_REGEX: ClassVar[str] = HOSTNAME_REGEX

def __init__( # noqa: PLR0913, PLR0917
self,
python_version: PythonVersion = PythonVersionMin,
Expand Down Expand Up @@ -100,6 +107,8 @@ def __init__( # noqa: PLR0913, PLR0917
def get_data_type(
self,
types: Types,
*,
field_constraints: bool = False, # noqa: ARG002
**_: Any,
) -> DataType:
"""Get data type for schema type."""
Expand Down
13 changes: 11 additions & 2 deletions src/datamodel_code_generator/parser/jsonschema.py
Original file line number Diff line number Diff line change
Expand Up @@ -1018,6 +1018,8 @@ def get_object_field( # noqa: PLR0913
) -> DataModelFieldBase:
"""Create a data model field from a JSON Schema object field."""
constraints = field.dict() if self.is_constraints_field(field) else None
if constraints is not None and self.field_constraints and field.format == "hostname":
constraints["pattern"] = self.data_type_manager.HOSTNAME_REGEX
# Suppress minItems/maxItems for fixed-length tuples
if (
constraints
Expand Down Expand Up @@ -1065,6 +1067,7 @@ def get_data_type(self, obj: JsonSchemaObject) -> DataType:
def _get_data_type(type_: str, format__: str) -> DataType:
return self.data_type_manager.get_data_type(
self._get_type_with_mappings(type_, format__),
field_constraints=self.field_constraints,
**obj.dict() if not self.field_constraints else {},
)

Expand Down Expand Up @@ -2578,14 +2581,17 @@ def parse_root_type( # noqa: PLR0912
if not reference:
reference = self.model_resolver.add(path, name, loaded=True, class_name=True)
self._set_schema_metadata(reference.path, obj)
constraints = obj.dict() if self.field_constraints else {}
if self.field_constraints and obj.format == "hostname":
constraints["pattern"] = self.data_type_manager.HOSTNAME_REGEX
data_model_root_type = self.data_model_root_type(
reference=reference,
fields=[
self.data_model_field_type(
data_type=data_type,
default=obj.default,
required=required,
constraints=obj.dict() if self.field_constraints else {},
constraints=constraints,
nullable=obj.nullable
if self.strict_nullable and obj.nullable is not None
else (False if self.strict_nullable and obj.has_default else None),
Expand Down Expand Up @@ -2640,14 +2646,17 @@ def _parse_multiple_types_with_properties(
reference = self.model_resolver.add(path, name, loaded=True, class_name=True)
self._set_schema_metadata(reference.path, obj)

constraints = obj.dict() if self.field_constraints else {}
if self.field_constraints and obj.format == "hostname":
constraints["pattern"] = self.data_type_manager.HOSTNAME_REGEX
data_model_root_type = self.data_model_root_type(
reference=reference,
fields=[
self.data_model_field_type(
data_type=self.data_type(data_types=data_types),
default=obj.default,
required=required,
constraints=obj.dict() if self.field_constraints else {},
constraints=constraints,
nullable=obj.type_has_null if self.strict_nullable else None,
strip_default_none=self.strip_default_none,
extras=self.get_field_extras(obj),
Expand Down
5 changes: 5 additions & 0 deletions src/datamodel_code_generator/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,11 @@ class DataTypeManager(ABC):
Subclasses implement get_data_type() to map schema types to DataType objects.
"""

HOSTNAME_REGEX: ClassVar[str] = (
r"^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])\.)*"
r"([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9])$"
)

def __init__( # noqa: PLR0913, PLR0917
self,
python_version: PythonVersion = PythonVersionMin,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# generated by datamodel-codegen:
# filename: hostname_field_constraints.json
# timestamp: 2019-07-26T00:00:00+00:00

from __future__ import annotations

from pydantic import BaseModel, Field


class Server(BaseModel):
myhost: str | None = Field(
None,
regex='^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]{0,61}[A-Za-z0-9])\\Z',
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# generated by datamodel-codegen:
# filename: hostname_field_constraints.json
# timestamp: 2019-07-26T00:00:00+00:00

from __future__ import annotations

from pydantic import BaseModel, Field


class Server(BaseModel):
myhost: str | None = Field(
None,
pattern='^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]{0,61}[A-Za-z0-9])$',
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# generated by datamodel-codegen:
# filename: hostname_field_constraints.json
# timestamp: 2019-07-26T00:00:00+00:00

from __future__ import annotations

from pydantic import BaseModel, Field, StrictStr


class Server(BaseModel):
myhost: StrictStr | None = Field(
None,
regex='^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]{0,61}[A-Za-z0-9])\\Z',
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# generated by datamodel-codegen:
# filename: hostname_multiple_types.json
# timestamp: 2019-07-26T00:00:00+00:00

from __future__ import annotations

from pydantic import BaseModel, Field, RootModel, constr


class HostnameMultiple1(BaseModel):
name: str | None = None


class HostnameMultiple(
RootModel[
HostnameMultiple1
| constr(
pattern=r'^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9])$'
)
]
):
root: HostnameMultiple1 | constr(
pattern=r'^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9])$'
) = Field(
...,
pattern='^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]{0,61}[A-Za-z0-9])$',
title='HostnameMultiple',
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# generated by datamodel-codegen:
# filename: hostname_root_type.json
# timestamp: 2019-07-26T00:00:00+00:00

from __future__ import annotations

from pydantic import Field, RootModel


class HostnameRoot(RootModel[str]):
root: str = Field(
...,
pattern='^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]{0,61}[A-Za-z0-9])$',
title='HostnameRoot',
)
10 changes: 9 additions & 1 deletion tests/data/expected/main/openapi/pattern/msgspec_pattern.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,15 @@


class Info(Struct):
hostName: str | UnsetType = UNSET
hostName: (
Annotated[
str,
Meta(
pattern='^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]{0,61}[A-Za-z0-9])$'
),
]
| UnsetType
) = UNSET
arn: (
Annotated[
str,
Expand Down
11 changes: 11 additions & 0 deletions tests/data/jsonschema/hostname_field_constraints.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Server",
"type": "object",
"properties": {
"myhost": {
"type": "string",
"format": "hostname"
}
}
}
11 changes: 11 additions & 0 deletions tests/data/jsonschema/hostname_multiple_types.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "HostnameMultiple",
"type": ["object", "string"],
"format": "hostname",
"properties": {
"name": {
"type": "string"
}
}
}
6 changes: 6 additions & 0 deletions tests/data/jsonschema/hostname_root_type.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "HostnameRoot",
"type": "string",
"format": "hostname"
}
60 changes: 60 additions & 0 deletions tests/main/jsonschema/test_main_jsonschema.py
Original file line number Diff line number Diff line change
Expand Up @@ -1675,6 +1675,66 @@ def test_main_strict_types_all_with_field_constraints(output_file: Path) -> None
)


def test_main_hostname_field_constraints_pydantic_v2(output_file: Path) -> None:
"""Test hostname format uses Field(pattern=) instead of constr with --field-constraints."""
run_main_and_assert(
input_path=JSON_SCHEMA_DATA_PATH / "hostname_field_constraints.json",
output_path=output_file,
input_file_type="jsonschema",
assert_func=assert_file_content,
expected_file="hostname_field_constraints_pydantic_v2.py",
extra_args=["--output-model-type", "pydantic_v2.BaseModel", "--field-constraints"],
)


def test_main_hostname_field_constraints_pydantic_v1(output_file: Path) -> None:
"""Test hostname format uses Field(regex=) instead of constr with --field-constraints for v1."""
run_main_and_assert(
input_path=JSON_SCHEMA_DATA_PATH / "hostname_field_constraints.json",
output_path=output_file,
input_file_type="jsonschema",
assert_func=assert_file_content,
expected_file="hostname_field_constraints_pydantic_v1.py",
extra_args=["--output-model-type", "pydantic.BaseModel", "--field-constraints"],
)


def test_main_hostname_field_constraints_strict_pydantic_v1(output_file: Path) -> None:
"""Test hostname format uses StrictStr with --field-constraints and --strict-types."""
run_main_and_assert(
input_path=JSON_SCHEMA_DATA_PATH / "hostname_field_constraints.json",
output_path=output_file,
input_file_type="jsonschema",
assert_func=assert_file_content,
expected_file="hostname_field_constraints_strict_pydantic_v1.py",
extra_args=["--output-model-type", "pydantic.BaseModel", "--field-constraints", "--strict-types", "str"],
)


def test_main_hostname_root_type_pydantic_v2(output_file: Path) -> None:
"""Test hostname format in root type uses Field(pattern=) with --field-constraints."""
run_main_and_assert(
input_path=JSON_SCHEMA_DATA_PATH / "hostname_root_type.json",
output_path=output_file,
input_file_type="jsonschema",
assert_func=assert_file_content,
expected_file="hostname_root_type_pydantic_v2.py",
extra_args=["--output-model-type", "pydantic_v2.BaseModel", "--field-constraints"],
)


def test_main_hostname_multiple_types_pydantic_v2(output_file: Path) -> None:
"""Test hostname format with multiple types uses Field(pattern=) with --field-constraints."""
run_main_and_assert(
input_path=JSON_SCHEMA_DATA_PATH / "hostname_multiple_types.json",
output_path=output_file,
input_file_type="jsonschema",
assert_func=assert_file_content,
expected_file="hostname_multiple_types_pydantic_v2.py",
extra_args=["--output-model-type", "pydantic_v2.BaseModel", "--field-constraints"],
)


def test_main_jsonschema_special_enum(output_file: Path) -> None:
"""Test special enum handling."""
run_main_and_assert(
Expand Down
Loading