From da7047bbbae1c230e35a7e9c19d836fdbd7e5b9d Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 2 Jan 2026 18:10:19 +0000 Subject: [PATCH 01/17] Add --validators option for Pydantic v2 field validators --- docs/cli-reference/general-options.md | 76 +++++++++++++ docs/cli-reference/index.md | 3 +- docs/cli-reference/quick-reference.md | 2 + src/datamodel_code_generator/__main__.py | 23 +++- .../_types/generate_config_dict.py | 1 + .../_types/parser_config_dicts.py | 1 + src/datamodel_code_generator/arguments.py | 6 + src/datamodel_code_generator/cli_options.py | 1 + src/datamodel_code_generator/config.py | 2 + .../model/pydantic_v2/base_model.py | 65 +++++++++++ .../model/pydantic_v2/imports.py | 3 + .../template/pydantic_v2/BaseModel.jinja2 | 17 +++ src/datamodel_code_generator/parser/base.py | 6 + src/datamodel_code_generator/validators.py | 36 ++++++ .../expected/main/input_model/config_class.py | 1 + .../main/jsonschema/field_validators.py | 25 +++++ .../field_validators_multi_fields.py | 20 ++++ tests/data/jsonschema/field_validators.json | 19 ++++ .../jsonschema/field_validators_config.json | 16 +++ .../field_validators_multi_fields_config.json | 11 ++ tests/main/jsonschema/test_main_jsonschema.py | 104 ++++++++++++++++++ .../test_public_api_signature_baseline.py | 2 + tests/test_infer_input_type.py | 2 + 23 files changed, 440 insertions(+), 2 deletions(-) create mode 100644 src/datamodel_code_generator/validators.py create mode 100644 tests/data/expected/main/jsonschema/field_validators.py create mode 100644 tests/data/expected/main/jsonschema/field_validators_multi_fields.py create mode 100644 tests/data/jsonschema/field_validators.json create mode 100644 tests/data/jsonschema/field_validators_config.json create mode 100644 tests/data/jsonschema/field_validators_multi_fields_config.json diff --git a/docs/cli-reference/general-options.md b/docs/cli-reference/general-options.md index 94c07bafe..e37ef31d8 100644 --- a/docs/cli-reference/general-options.md +++ b/docs/cli-reference/general-options.md @@ -17,6 +17,7 @@ | [`--ignore-pyproject`](#ignore-pyproject) | Ignore pyproject.toml configuration file. | | [`--module-split-mode`](#module-split-mode) | Split generated models into separate files, one per model cl... | | [`--shared-module-name`](#shared-module-name) | Customize the name of the shared module for deduplicated mod... | +| [`--validators`](#validators) | Add custom field validators to generated Pydantic v2 models.... | | [`--watch`](#watch) | Watch input file(s) for changes and regenerate output automa... | | [`--watch-delay`](#watch-delay) | Set debounce delay in seconds for watch mode. | @@ -1903,6 +1904,81 @@ Note: This option only affects modular output with tree-level model reuse. --- +## `--validators` {#validators} + +Add custom field validators to generated Pydantic v2 models. + +The `--validators` option takes a JSON file defining validators per model. +Each validator specifies the field(s) to validate, the validation function +to import, and optionally the mode (before/after/wrap/plain). +This allows injecting custom validation logic into generated models. + +!!! tip "Usage" + + ```bash + datamodel-codegen --input schema.json --validators tests/data/jsonschema/field_validators_config.json --output-model-type pydantic_v2.BaseModel --disable-timestamp # (1)! + ``` + + 1. :material-arrow-left: `--validators` - the option documented here + +??? example "Examples" + + **Input Schema:** + + ```json + { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "User", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + }, + "age": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["name", "email"] + } + ``` + + **Output:** + + ```python + # generated by datamodel-codegen: + # filename: field_validators.json + + from __future__ import annotations + + from typing import Any + + from myapp.validators import validate_email, validate_name + from pydantic import BaseModel, EmailStr, ValidationInfo, conint, field_validator + + + class User(BaseModel): + name: str + email: EmailStr + age: conint(ge=0) | None = None + + @field_validator('name', mode='before') + @classmethod + def validate_name_name_validator(cls, v: Any, info: ValidationInfo) -> Any: + return validate_name(v, info) + + @field_validator('email', mode='after') + @classmethod + def validate_email_email_validator(cls, v: Any, info: ValidationInfo) -> Any: + return validate_email(v, info) + ``` + +--- + ## `--watch` {#watch} Watch input file(s) for changes and regenerate output automatically. diff --git a/docs/cli-reference/index.md b/docs/cli-reference/index.md index 66b121494..049fbf46f 100644 --- a/docs/cli-reference/index.md +++ b/docs/cli-reference/index.md @@ -14,7 +14,7 @@ This documentation is auto-generated from test cases. | 🏗️ [Model Customization](model-customization.md) | 39 | Model generation behavior | | 🎨 [Template Customization](template-customization.md) | 18 | Output formatting and custom rendering | | 📘 [OpenAPI-only Options](openapi-only-options.md) | 7 | OpenAPI-specific features | -| ⚙️ [General Options](general-options.md) | 15 | Utilities and meta options | +| ⚙️ [General Options](general-options.md) | 16 | Utilities and meta options | | 📝 [Utility Options](utility-options.md) | 6 | Help, version, debug options | ## All Options @@ -215,6 +215,7 @@ This documentation is auto-generated from test cases. ### V {#v} - [`--validation`](openapi-only-options.md#validation) +- [`--validators`](general-options.md#validators) - [`--version`](utility-options.md#version) ### W {#w} diff --git a/docs/cli-reference/quick-reference.md b/docs/cli-reference/quick-reference.md index 1cf654c8c..c94e7e2be 100644 --- a/docs/cli-reference/quick-reference.md +++ b/docs/cli-reference/quick-reference.md @@ -179,6 +179,7 @@ datamodel-codegen [OPTIONS] | [`--ignore-pyproject`](general-options.md#ignore-pyproject) | Ignore pyproject.toml configuration file. | | [`--module-split-mode`](general-options.md#module-split-mode) | Split generated models into separate files, one per model class. | | [`--shared-module-name`](general-options.md#shared-module-name) | Customize the name of the shared module for deduplicated models. | +| [`--validators`](general-options.md#validators) | Add custom field validators to generated Pydantic v2 models. | | [`--watch`](general-options.md#watch) | Watch input file(s) for changes and regenerate output automatically. | | [`--watch-delay`](general-options.md#watch-delay) | Set debounce delay in seconds for watch mode. | @@ -336,6 +337,7 @@ All options sorted alphabetically: - [`--use-union-operator`](typing-customization.md#use-union-operator) - Use | operator for Union types (PEP 604). - [`--use-unique-items-as-set`](typing-customization.md#use-unique-items-as-set) - Generate set types for arrays with uniqueItems constraint. - [`--validation`](openapi-only-options.md#validation) - Enable validation constraints (deprecated, use --field-const... +- [`--validators`](general-options.md#validators) - Add custom field validators to generated Pydantic v2 models. - [`--version`](utility-options.md#version) - Show program version and exit - [`--watch`](general-options.md#watch) - Watch input file(s) for changes and regenerate output automa... - [`--watch-delay`](general-options.md#watch-delay) - Set debounce delay in seconds for watch mode. diff --git a/src/datamodel_code_generator/__main__.py b/src/datamodel_code_generator/__main__.py index 8689f6a9c..dcd57ecaf 100644 --- a/src/datamodel_code_generator/__main__.py +++ b/src/datamodel_code_generator/__main__.py @@ -176,7 +176,7 @@ def get_fields(cls) -> dict[str, Any]: """Get model fields.""" return cls.__fields__ - @field_validator("aliases", "extra_template_data", "custom_formatters_kwargs", mode="before") + @field_validator("aliases", "extra_template_data", "custom_formatters_kwargs", "validators", mode="before") def validate_file(cls, value: Any) -> TextIOBase | None: # noqa: N805 """Validate and open file path.""" if value is None: # pragma: no cover @@ -498,6 +498,7 @@ def validate_class_name_affix_scope(cls, v: str | ClassNameAffixScope | None) -> class_decorators: Optional[list[str]] = None # noqa: UP045 custom_template_dir: Optional[Path] = None # noqa: UP045 extra_template_data: Optional[TextIOBase] = None # noqa: UP045 + validators: Optional[TextIOBase] = None # noqa: UP045 validation: bool = False field_constraints: bool = False snake_case_field: bool = False @@ -862,6 +863,7 @@ def run_generate_from_config( # noqa: PLR0913, PLR0917 command_line: str | None, custom_formatters_kwargs: dict[str, str] | None, settings_path: Path | None = None, + validators: dict[str, Any] | None = None, ) -> None: """Run code generation with the given config and parameters.""" result = generate( @@ -988,6 +990,7 @@ def run_generate_from_config( # noqa: PLR0913, PLR0917 all_exports_collision_strategy=config.all_exports_collision_strategy, field_type_collision_strategy=config.field_type_collision_strategy, module_split_mode=config.module_split_mode, + validators=validators, # pyright: ignore[reportCallIssue] ) if output is None and result is not None: # pragma: no cover @@ -1211,6 +1214,23 @@ def main(args: Sequence[str] | None = None) -> Exit: # noqa: PLR0911, PLR0912, ) return Exit.ERROR + validators_config: dict[str, Any] | None + if config.validators is None: + validators_config = None + else: + with config.validators as data: + try: + validators_config = json.load(data) + except json.JSONDecodeError as e: + print(f"Unable to load validators configuration: {e}", file=sys.stderr) # noqa: T201 + return Exit.ERROR + if not isinstance(validators_config, dict): + print( # noqa: T201 + "Validators configuration must be a JSON object with model names as keys", + file=sys.stderr, + ) + return Exit.ERROR + if config.check: config_output = cast("Path", config.output) is_directory_output = not config_output.suffix @@ -1255,6 +1275,7 @@ def main(args: Sequence[str] | None = None) -> Exit: # noqa: PLR0911, PLR0912, command_line=shlex.join(["datamodel-codegen", *args]) if config.enable_command_header else None, custom_formatters_kwargs=custom_formatters_kwargs, settings_path=config.output, + validators=validators_config, ) except InvalidClassNameError as e: print(f"{e} You have to set `--class-name` option", file=sys.stderr) # noqa: T201 diff --git a/src/datamodel_code_generator/_types/generate_config_dict.py b/src/datamodel_code_generator/_types/generate_config_dict.py index b8504e654..bed077a10 100644 --- a/src/datamodel_code_generator/_types/generate_config_dict.py +++ b/src/datamodel_code_generator/_types/generate_config_dict.py @@ -50,6 +50,7 @@ class GenerateConfigDict(TypedDict): class_decorators: NotRequired[list[str] | None] custom_template_dir: NotRequired[Path | None] extra_template_data: NotRequired[defaultdict[str, dict[str, Any]] | None] + validators: NotRequired[dict[str, Any] | None] validation: NotRequired[bool] field_constraints: NotRequired[bool] snake_case_field: NotRequired[bool] diff --git a/src/datamodel_code_generator/_types/parser_config_dicts.py b/src/datamodel_code_generator/_types/parser_config_dicts.py index 4db2ace02..d80bde248 100644 --- a/src/datamodel_code_generator/_types/parser_config_dicts.py +++ b/src/datamodel_code_generator/_types/parser_config_dicts.py @@ -43,6 +43,7 @@ class ParserConfigDict(TypedDict): class_decorators: NotRequired[list[str] | None] custom_template_dir: NotRequired[Path | None] extra_template_data: NotRequired[defaultdict[str, dict[str, Any]] | None] + validators: NotRequired[dict[str, Any] | None] target_python_version: NotRequired[PythonVersion] dump_resolve_reference_action: NotRequired[Callable[[Iterable[str]], str] | None] validation: NotRequired[bool] diff --git a/src/datamodel_code_generator/arguments.py b/src/datamodel_code_generator/arguments.py index ee52e152a..bae3cc897 100644 --- a/src/datamodel_code_generator/arguments.py +++ b/src/datamodel_code_generator/arguments.py @@ -856,6 +856,12 @@ def start_section(self, heading: str | None) -> None: "The value is a dictionary of the template data to add.", type=Path, ) +template_options.add_argument( + "--validators", + help="Validators configuration file (JSON). Defines field validators for Pydantic v2 models. " + "Keys are model names, values contain validator definitions with field, function, and mode.", + type=Path, +) template_options.add_argument( "--use-double-quotes", action="store_true", diff --git a/src/datamodel_code_generator/cli_options.py b/src/datamodel_code_generator/cli_options.py index 611bb7672..0f297aaf7 100644 --- a/src/datamodel_code_generator/cli_options.py +++ b/src/datamodel_code_generator/cli_options.py @@ -208,6 +208,7 @@ class CLIOptionMeta: "--wrap-string-literal": CLIOptionMeta(name="--wrap-string-literal", category=OptionCategory.TEMPLATE), "--custom-template-dir": CLIOptionMeta(name="--custom-template-dir", category=OptionCategory.TEMPLATE), "--extra-template-data": CLIOptionMeta(name="--extra-template-data", category=OptionCategory.TEMPLATE), + "--validators": CLIOptionMeta(name="--validators", category=OptionCategory.TEMPLATE), "--custom-file-header": CLIOptionMeta(name="--custom-file-header", category=OptionCategory.TEMPLATE), "--custom-file-header-path": CLIOptionMeta(name="--custom-file-header-path", category=OptionCategory.TEMPLATE), "--additional-imports": CLIOptionMeta(name="--additional-imports", category=OptionCategory.TEMPLATE), diff --git a/src/datamodel_code_generator/config.py b/src/datamodel_code_generator/config.py index 4536e1e0f..c46ca5b01 100644 --- a/src/datamodel_code_generator/config.py +++ b/src/datamodel_code_generator/config.py @@ -87,6 +87,7 @@ class Config: class_decorators: list[str] | None = None custom_template_dir: Path | None = None extra_template_data: ExtraTemplateDataType | None = None + validators: dict[str, Any] | None = None validation: bool = False field_constraints: bool = False snake_case_field: bool = False @@ -225,6 +226,7 @@ class Config: class_decorators: list[str] | None = None custom_template_dir: Path | None = None extra_template_data: ExtraTemplateDataType | None = None + validators: dict[str, Any] | None = None target_python_version: PythonVersion = PythonVersionMin dump_resolve_reference_action: DumpResolveReferenceAction | None = None validation: bool = False diff --git a/src/datamodel_code_generator/model/pydantic_v2/base_model.py b/src/datamodel_code_generator/model/pydantic_v2/base_model.py index 9e946222b..c0bdc7f20 100644 --- a/src/datamodel_code_generator/model/pydantic_v2/base_model.py +++ b/src/datamodel_code_generator/model/pydantic_v2/base_model.py @@ -287,6 +287,8 @@ def __init__( # noqa: PLR0913 self.extra_template_data["config"] = model_validate(ConfigDict, config_parameters) # pyright: ignore[reportArgumentType] self._additional_imports.append(IMPORT_CONFIG_DICT) + self._process_validators() + def _get_config_extra(self) -> Literal["'allow'", "'forbid'", "'ignore'"] | None: additional_properties = self.extra_template_data.get("additionalProperties") unevaluated_properties = self.extra_template_data.get("unevaluatedProperties") @@ -336,6 +338,69 @@ def _has_lookaround_pattern(self) -> bool: return True return False + def _process_validators(self) -> None: + """Process validator definitions and prepare them for template rendering.""" + from datamodel_code_generator.model.pydantic_v2.imports import ( # noqa: PLC0415 + IMPORT_FIELD_VALIDATOR, + IMPORT_VALIDATION_INFO, + ) + + validators = self.extra_template_data.get("validators") + if not validators: + return + + prepared_validators: list[dict[str, Any]] = [] + for validator in validators: + fields = validator.get("fields") or [validator.get("field")] + fields = [f for f in fields if f] + if not fields: + continue + + function_path = validator.get("function") + if not function_path: + continue + + function_name = function_path.rsplit(".", 1)[-1] + mode = validator.get("mode", "after") + + fields_str = ", ".join(f"'{f}'" for f in fields) + + if len(fields) == 1: + method_name = f"{function_name}_{fields[0]}_validator" + else: + import hashlib # noqa: PLC0415 + + fields_hash = hashlib.md5("_".join(sorted(fields)).encode()).hexdigest()[:6] # noqa: S324 + method_name = f"{function_name}_{fields_hash}_validator" + + mode_str = f"mode='{mode}'" + + prepared_validators.append({ + "fields_str": fields_str, + "mode_str": mode_str, + "method_name": method_name, + "function_name": function_name, + "mode": mode, + }) + + self._additional_imports.append(Import.from_full_path(function_path)) + + if prepared_validators: + from datamodel_code_generator.imports import IMPORT_ANY # noqa: PLC0415 + from datamodel_code_generator.model.pydantic_v2.imports import ( # noqa: PLC0415 + IMPORT_VALIDATOR_FUNCTION_WRAP_HANDLER, + ) + + self.extra_template_data["prepared_validators"] = prepared_validators # pyright: ignore[reportArgumentType] + self._additional_imports.append(IMPORT_FIELD_VALIDATOR) + self._additional_imports.append(IMPORT_ANY) + + modes = {v["mode"] for v in prepared_validators} + if modes - {"plain"}: + self._additional_imports.append(IMPORT_VALIDATION_INFO) + if "wrap" in modes: + self._additional_imports.append(IMPORT_VALIDATOR_FUNCTION_WRAP_HANDLER) + @classmethod def create_base_class_model( cls, diff --git a/src/datamodel_code_generator/model/pydantic_v2/imports.py b/src/datamodel_code_generator/model/pydantic_v2/imports.py index b1b799057..39d62f406 100644 --- a/src/datamodel_code_generator/model/pydantic_v2/imports.py +++ b/src/datamodel_code_generator/model/pydantic_v2/imports.py @@ -20,3 +20,6 @@ IMPORT_SERIALIZE_AS_ANY = Import.from_full_path("pydantic.SerializeAsAny") IMPORT_PYDANTIC_DATACLASS = Import.from_full_path("pydantic.dataclasses.dataclass") IMPORT_ROOT_MODEL = Import.from_full_path("pydantic.RootModel") +IMPORT_FIELD_VALIDATOR = Import.from_full_path("pydantic.field_validator") +IMPORT_VALIDATION_INFO = Import.from_full_path("pydantic.ValidationInfo") +IMPORT_VALIDATOR_FUNCTION_WRAP_HANDLER = Import.from_full_path("pydantic.ValidatorFunctionWrapHandler") diff --git a/src/datamodel_code_generator/model/template/pydantic_v2/BaseModel.jinja2 b/src/datamodel_code_generator/model/template/pydantic_v2/BaseModel.jinja2 index 21de1f56d..d0e8c309d 100644 --- a/src/datamodel_code_generator/model/template/pydantic_v2/BaseModel.jinja2 +++ b/src/datamodel_code_generator/model/template/pydantic_v2/BaseModel.jinja2 @@ -45,3 +45,20 @@ class {{ class_name }}({{ base_class }}):{% if comment is defined %} # {{ comme {{ method }} {%- endfor -%} {%- endfor -%} +{%- if prepared_validators %} +{% for v in prepared_validators %} + + @field_validator({{ v.fields_str }}, {{ v.mode_str }}) + @classmethod +{%- if v.mode == 'wrap' %} + def {{ v.method_name }}(cls, v: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo) -> Any: + return {{ v.function_name }}(v, handler, info) +{%- elif v.mode == 'plain' %} + def {{ v.method_name }}(cls, v: Any) -> Any: + return {{ v.function_name }}(v) +{%- else %} + def {{ v.method_name }}(cls, v: Any, info: ValidationInfo) -> Any: + return {{ v.function_name }}(v, info) +{%- endif %} +{%- endfor %} +{%- endif %} diff --git a/src/datamodel_code_generator/parser/base.py b/src/datamodel_code_generator/parser/base.py index 653382699..eebc35ccd 100644 --- a/src/datamodel_code_generator/parser/base.py +++ b/src/datamodel_code_generator/parser/base.py @@ -836,6 +836,12 @@ def __init__( # noqa: PLR0912, PLR0915 self.source: str | Path | list[Path] | ParseResult | dict[str, YamlValue] = source self.custom_template_dir = config.custom_template_dir self.extra_template_data: defaultdict[str, Any] = config.extra_template_data or defaultdict(dict) + self.validators: dict[str, Any] | None = config.validators + + if self.validators: + for model_name, model_config in self.validators.items(): + if "validators" in model_config: + self.extra_template_data[model_name]["validators"] = model_config["validators"] self.use_generic_base_class: bool = config.use_generic_base_class self.generic_base_class_config: dict[str, Any] = {} diff --git a/src/datamodel_code_generator/validators.py b/src/datamodel_code_generator/validators.py new file mode 100644 index 000000000..50bc6c7b5 --- /dev/null +++ b/src/datamodel_code_generator/validators.py @@ -0,0 +1,36 @@ +"""Validator definitions for generated Pydantic models. + +Provides types for defining custom field validators that can be added to generated models. +""" + +from __future__ import annotations + +from enum import Enum +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import TypedDict + + class ValidatorDefinition(TypedDict, total=False): + """Definition of a single validator.""" + + field: str + fields: list[str] + function: str + mode: str + + class ModelValidators(TypedDict, total=False): + """Validators configuration for a single model.""" + + validators: list[ValidatorDefinition] + + ValidatorsConfigType = dict[str, ModelValidators] + + +class ValidatorMode(str, Enum): + """Validator mode for Pydantic v2 field_validator.""" + + BEFORE = "before" + AFTER = "after" + WRAP = "wrap" + PLAIN = "plain" diff --git a/tests/data/expected/main/input_model/config_class.py b/tests/data/expected/main/input_model/config_class.py index 0d394d2f1..3262746a3 100644 --- a/tests/data/expected/main/input_model/config_class.py +++ b/tests/data/expected/main/input_model/config_class.py @@ -123,6 +123,7 @@ class GenerateConfig(TypedDict): class_decorators: NotRequired[list[str] | None] custom_template_dir: NotRequired[str | None] extra_template_data: NotRequired[defaultdict[str, dict[str, Any]] | None] + validators: NotRequired[dict[str, Any] | None] validation: NotRequired[bool] field_constraints: NotRequired[bool] snake_case_field: NotRequired[bool] diff --git a/tests/data/expected/main/jsonschema/field_validators.py b/tests/data/expected/main/jsonschema/field_validators.py new file mode 100644 index 000000000..f2245485d --- /dev/null +++ b/tests/data/expected/main/jsonschema/field_validators.py @@ -0,0 +1,25 @@ +# generated by datamodel-codegen: +# filename: field_validators.json + +from __future__ import annotations + +from typing import Any + +from myapp.validators import validate_email, validate_name +from pydantic import BaseModel, EmailStr, ValidationInfo, conint, field_validator + + +class User(BaseModel): + name: str + email: EmailStr + age: conint(ge=0) | None = None + + @field_validator('name', mode='before') + @classmethod + def validate_name_name_validator(cls, v: Any, info: ValidationInfo) -> Any: + return validate_name(v, info) + + @field_validator('email', mode='after') + @classmethod + def validate_email_email_validator(cls, v: Any, info: ValidationInfo) -> Any: + return validate_email(v, info) diff --git a/tests/data/expected/main/jsonschema/field_validators_multi_fields.py b/tests/data/expected/main/jsonschema/field_validators_multi_fields.py new file mode 100644 index 000000000..0dd5e77ac --- /dev/null +++ b/tests/data/expected/main/jsonschema/field_validators_multi_fields.py @@ -0,0 +1,20 @@ +# generated by datamodel-codegen: +# filename: field_validators.json + +from __future__ import annotations + +from typing import Any + +from myapp.validators import validate_contact +from pydantic import BaseModel, EmailStr, ValidationInfo, conint, field_validator + + +class User(BaseModel): + name: str + email: EmailStr + age: conint(ge=0) | None = None + + @field_validator('name', 'email', mode='after') + @classmethod + def validate_contact_84d627_validator(cls, v: Any, info: ValidationInfo) -> Any: + return validate_contact(v, info) diff --git a/tests/data/jsonschema/field_validators.json b/tests/data/jsonschema/field_validators.json new file mode 100644 index 000000000..cc46c0720 --- /dev/null +++ b/tests/data/jsonschema/field_validators.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "User", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + }, + "age": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["name", "email"] +} diff --git a/tests/data/jsonschema/field_validators_config.json b/tests/data/jsonschema/field_validators_config.json new file mode 100644 index 000000000..6b136c078 --- /dev/null +++ b/tests/data/jsonschema/field_validators_config.json @@ -0,0 +1,16 @@ +{ + "User": { + "validators": [ + { + "field": "name", + "function": "myapp.validators.validate_name", + "mode": "before" + }, + { + "field": "email", + "function": "myapp.validators.validate_email", + "mode": "after" + } + ] + } +} diff --git a/tests/data/jsonschema/field_validators_multi_fields_config.json b/tests/data/jsonschema/field_validators_multi_fields_config.json new file mode 100644 index 000000000..f4fab5911 --- /dev/null +++ b/tests/data/jsonschema/field_validators_multi_fields_config.json @@ -0,0 +1,11 @@ +{ + "User": { + "validators": [ + { + "fields": ["name", "email"], + "function": "myapp.validators.validate_contact", + "mode": "after" + } + ] + } +} diff --git a/tests/main/jsonschema/test_main_jsonschema.py b/tests/main/jsonschema/test_main_jsonschema.py index 3b5c37656..9273c2ae5 100644 --- a/tests/main/jsonschema/test_main_jsonschema.py +++ b/tests/main/jsonschema/test_main_jsonschema.py @@ -7557,3 +7557,107 @@ def test_reduce_duplicate_field_types(output_file: Path) -> None: expected_file="reduce_duplicate_field_types.py", extra_args=["--output-model-type", "pydantic_v2.BaseModel", "--use-type-alias"], ) + + +@pytest.mark.cli_doc( + options=["--validators"], + option_description="""Add custom field validators to generated Pydantic v2 models. + +The `--validators` option takes a JSON file defining validators per model. +Each validator specifies the field(s) to validate, the validation function +to import, and optionally the mode (before/after/wrap/plain). +This allows injecting custom validation logic into generated models.""", + input_schema="jsonschema/field_validators.json", + cli_args=[ + "--validators", + "tests/data/jsonschema/field_validators_config.json", + "--output-model-type", + "pydantic_v2.BaseModel", + "--disable-timestamp", + ], + golden_output="jsonschema/field_validators.py", +) +def test_field_validators(output_file: Path) -> None: + """Add custom field validators to generated Pydantic v2 models. + + The `--validators` option takes a JSON file defining validators per model. + Each validator specifies the field(s) to validate, the validation function + to import, and optionally the mode (before/after/wrap/plain). + This allows injecting custom validation logic into generated models. + """ + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "field_validators.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="field_validators.py", + extra_args=[ + "--validators", + str(JSON_SCHEMA_DATA_PATH / "field_validators_config.json"), + "--output-model-type", + "pydantic_v2.BaseModel", + "--disable-timestamp", + ], + skip_code_validation=True, + ) + + +def test_field_validators_multi_fields(output_file: Path) -> None: + """Test validators with multiple fields.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "field_validators.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="field_validators_multi_fields.py", + extra_args=[ + "--validators", + str(JSON_SCHEMA_DATA_PATH / "field_validators_multi_fields_config.json"), + "--output-model-type", + "pydantic_v2.BaseModel", + "--disable-timestamp", + ], + skip_code_validation=True, + ) + + +def test_validators_invalid_json(output_file: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Test error handling for invalid validators JSON file.""" + invalid_json = tmp_path / "invalid.json" + invalid_json.write_text("not valid json{") + + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "field_validators.json", + output_path=output_file, + input_file_type="jsonschema", + expected_exit=Exit.ERROR, + extra_args=[ + "--validators", + str(invalid_json), + "--output-model-type", + "pydantic_v2.BaseModel", + ], + capsys=capsys, + expected_stderr_contains="Unable to load validators configuration", + ) + + +def test_validators_invalid_structure(output_file: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Test error handling for validators JSON with invalid structure (not an object).""" + invalid_structure = tmp_path / "invalid_structure.json" + invalid_structure.write_text('["not", "an", "object"]') + + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "field_validators.json", + output_path=output_file, + input_file_type="jsonschema", + expected_exit=Exit.ERROR, + extra_args=[ + "--validators", + str(invalid_structure), + "--output-model-type", + "pydantic_v2.BaseModel", + ], + capsys=capsys, + expected_stderr_contains="Validators configuration must be a JSON object", + ) diff --git a/tests/main/test_public_api_signature_baseline.py b/tests/main/test_public_api_signature_baseline.py index a93257d4c..b25012668 100644 --- a/tests/main/test_public_api_signature_baseline.py +++ b/tests/main/test_public_api_signature_baseline.py @@ -69,6 +69,7 @@ def _baseline_generate( class_decorators: list[str] | None = None, custom_template_dir: Path | None = None, extra_template_data: defaultdict[str, dict[str, Any]] | None = None, + validators: dict[str, Any] | None = None, validation: bool = False, field_constraints: bool = False, snake_case_field: bool = False, @@ -201,6 +202,7 @@ def __init__( class_decorators: list[str] | None = None, custom_template_dir: Path | None = None, extra_template_data: defaultdict[str, dict[str, Any]] | None = None, + validators: dict[str, Any] | None = None, target_python_version: PythonVersion = PythonVersionMin, dump_resolve_reference_action: Callable[[Iterable[str]], str] | None = None, validation: bool = False, diff --git a/tests/test_infer_input_type.py b/tests/test_infer_input_type.py index dd3f638cc..5388c2e91 100644 --- a/tests/test_infer_input_type.py +++ b/tests/test_infer_input_type.py @@ -45,6 +45,8 @@ def assert_invalid_infer_input_type(file: Path) -> None: "external_child.json", "external_child.yaml", "extra_data_msgspec.json", + "field_validators_config.json", + "field_validators_multi_fields_config.json", "list_only.json", "list_only.yaml", "whitespace_only.yaml", From 4146987a08c19fededc370f929de1af532a724e7 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 2 Jan 2026 18:14:28 +0000 Subject: [PATCH 02/17] Add validators documentation page --- docs/validators.md | 197 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 docs/validators.md diff --git a/docs/validators.md b/docs/validators.md new file mode 100644 index 000000000..7ab9ce41c --- /dev/null +++ b/docs/validators.md @@ -0,0 +1,197 @@ + + +# Field Validators + +The `--validators` option allows you to add custom field validators to generated Pydantic v2 models. This enables you to inject validation logic into generated code without manually editing it. + +## Basic Usage + +```bash +datamodel-codegen --input schema.json --output model.py \ + --validators validators.json \ + --output-model-type pydantic_v2.BaseModel +``` + +## Validators File Format + +The validators file is a JSON file that maps model names to their validator definitions. + +### Structure + +```json +{ + "ModelName": { + "validators": [ + { + "field": "field_name", + "function": "module.path.to.validator_function", + "mode": "after" + } + ] + } +} +``` + +### Fields + +| Field | Description | Required | +|-------|-------------|----------| +| `field` | Single field name to validate | One of `field` or `fields` | +| `fields` | List of field names (for multi-field validators) | One of `field` or `fields` | +| `function` | Fully qualified path to the validator function | Yes | +| `mode` | Validator mode: `before`, `after`, `wrap`, or `plain` | No (default: `after`) | + +## Validator Modes + +Pydantic v2 supports different validator modes, each with its own signature: + +### `before` / `after` Mode + +Standard validators that run before or after Pydantic's own validation: + +```python +def validate_name(v: Any, info: ValidationInfo) -> Any: + if not v: + raise ValueError("Name cannot be empty") + return v.strip() +``` + +### `wrap` Mode + +Wrap validators receive a handler to call the next validator in the chain: + +```python +def wrap_validate_name( + v: Any, + handler: ValidatorFunctionWrapHandler, + info: ValidationInfo +) -> Any: + # Pre-processing + v = v.strip() if isinstance(v, str) else v + # Call next validator + result = handler(v) + # Post-processing + return result.upper() +``` + +### `plain` Mode + +Plain validators replace Pydantic's validation entirely: + +```python +def plain_validate_name(v: Any) -> str: + if not isinstance(v, str): + raise TypeError("Expected string") + return v +``` + +## Example + +### Input Schema + +```json +{ + "type": "object", + "title": "User", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string", "format": "email"}, + "age": {"type": "integer", "minimum": 0} + }, + "required": ["name", "email"] +} +``` + +### Validators File + +```json +{ + "User": { + "validators": [ + { + "field": "name", + "function": "myapp.validators.validate_name", + "mode": "before" + }, + { + "field": "email", + "function": "myapp.validators.validate_email", + "mode": "after" + }, + { + "fields": ["name", "email"], + "function": "myapp.validators.validate_contact_info", + "mode": "after" + } + ] + } +} +``` + +### Validator Functions (myapp/validators.py) + +```python +from typing import Any +from pydantic import ValidationInfo + +def validate_name(v: Any, info: ValidationInfo) -> Any: + if isinstance(v, str): + return v.strip() + return v + +def validate_email(v: Any, info: ValidationInfo) -> Any: + if isinstance(v, str) and not v.endswith("@example.com"): + # Custom email domain validation + pass + return v + +def validate_contact_info(v: Any, info: ValidationInfo) -> Any: + # This runs for both name and email fields + return v +``` + +### Generated Output + +```python +from __future__ import annotations + +from typing import Any + +from myapp.validators import validate_contact_info, validate_email, validate_name +from pydantic import BaseModel, EmailStr, ValidationInfo, conint, field_validator + + +class User(BaseModel): + name: str + email: EmailStr + age: conint(ge=0) | None = None + + @field_validator('name', mode='before') + @classmethod + def validate_name_name_validator(cls, v: Any, info: ValidationInfo) -> Any: + return validate_name(v, info) + + @field_validator('email', mode='after') + @classmethod + def validate_email_email_validator(cls, v: Any, info: ValidationInfo) -> Any: + return validate_email(v, info) + + @field_validator('name', 'email', mode='after') + @classmethod + def validate_contact_info_84d627_validator(cls, v: Any, info: ValidationInfo) -> Any: + return validate_contact_info(v, info) +``` + +## Notes + +- This feature only supports Pydantic v2 (`--output-model-type pydantic_v2.BaseModel`) +- The `ModelName` in the validators file must match the generated Python class name +- Validator functions are imported automatically based on the `function` path +- For multi-field validators, a hash suffix is added to the method name to ensure uniqueness + +--- + +## See Also + +- [CLI Reference: `--validators`](cli-reference/general-options.md) - CLI option documentation +- [Pydantic v2 Validators Documentation](https://docs.pydantic.dev/latest/concepts/validators/) - Official Pydantic documentation From a116d143732d57cef5b62fd79b142d4299001891 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 2 Jan 2026 18:17:50 +0000 Subject: [PATCH 03/17] docs: update CLI reference documentation and prompt data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated by GitHub Actions --- docs/cli-reference/general-options.md | 76 ------------------- docs/cli-reference/index.md | 6 +- docs/cli-reference/quick-reference.md | 4 +- docs/cli-reference/template-customization.md | 78 ++++++++++++++++++++ src/datamodel_code_generator/prompt_data.py | 1 + 5 files changed, 84 insertions(+), 81 deletions(-) diff --git a/docs/cli-reference/general-options.md b/docs/cli-reference/general-options.md index e37ef31d8..94c07bafe 100644 --- a/docs/cli-reference/general-options.md +++ b/docs/cli-reference/general-options.md @@ -17,7 +17,6 @@ | [`--ignore-pyproject`](#ignore-pyproject) | Ignore pyproject.toml configuration file. | | [`--module-split-mode`](#module-split-mode) | Split generated models into separate files, one per model cl... | | [`--shared-module-name`](#shared-module-name) | Customize the name of the shared module for deduplicated mod... | -| [`--validators`](#validators) | Add custom field validators to generated Pydantic v2 models.... | | [`--watch`](#watch) | Watch input file(s) for changes and regenerate output automa... | | [`--watch-delay`](#watch-delay) | Set debounce delay in seconds for watch mode. | @@ -1904,81 +1903,6 @@ Note: This option only affects modular output with tree-level model reuse. --- -## `--validators` {#validators} - -Add custom field validators to generated Pydantic v2 models. - -The `--validators` option takes a JSON file defining validators per model. -Each validator specifies the field(s) to validate, the validation function -to import, and optionally the mode (before/after/wrap/plain). -This allows injecting custom validation logic into generated models. - -!!! tip "Usage" - - ```bash - datamodel-codegen --input schema.json --validators tests/data/jsonschema/field_validators_config.json --output-model-type pydantic_v2.BaseModel --disable-timestamp # (1)! - ``` - - 1. :material-arrow-left: `--validators` - the option documented here - -??? example "Examples" - - **Input Schema:** - - ```json - { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "User", - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "email": { - "type": "string", - "format": "email" - }, - "age": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["name", "email"] - } - ``` - - **Output:** - - ```python - # generated by datamodel-codegen: - # filename: field_validators.json - - from __future__ import annotations - - from typing import Any - - from myapp.validators import validate_email, validate_name - from pydantic import BaseModel, EmailStr, ValidationInfo, conint, field_validator - - - class User(BaseModel): - name: str - email: EmailStr - age: conint(ge=0) | None = None - - @field_validator('name', mode='before') - @classmethod - def validate_name_name_validator(cls, v: Any, info: ValidationInfo) -> Any: - return validate_name(v, info) - - @field_validator('email', mode='after') - @classmethod - def validate_email_email_validator(cls, v: Any, info: ValidationInfo) -> Any: - return validate_email(v, info) - ``` - ---- - ## `--watch` {#watch} Watch input file(s) for changes and regenerate output automatically. diff --git a/docs/cli-reference/index.md b/docs/cli-reference/index.md index 9ae18fe7e..cd96c3d34 100644 --- a/docs/cli-reference/index.md +++ b/docs/cli-reference/index.md @@ -12,10 +12,10 @@ This documentation is auto-generated from test cases. | 🔧 [Typing Customization](typing-customization.md) | 27 | Type annotation and import behavior | | 🏷️ [Field Customization](field-customization.md) | 24 | Field naming and docstring behavior | | 🏗️ [Model Customization](model-customization.md) | 39 | Model generation behavior | -| 🎨 [Template Customization](template-customization.md) | 18 | Output formatting and custom rendering | +| 🎨 [Template Customization](template-customization.md) | 19 | Output formatting and custom rendering | | 📘 [OpenAPI-only Options](openapi-only-options.md) | 7 | OpenAPI-specific features | | 📋 [GraphQL-only Options](graphql-only-options.md) | 1 | | -| ⚙️ [General Options](general-options.md) | 16 | Utilities and meta options | +| ⚙️ [General Options](general-options.md) | 15 | Utilities and meta options | | 📝 [Utility Options](utility-options.md) | 6 | Help, version, debug options | ## All Options @@ -219,7 +219,7 @@ This documentation is auto-generated from test cases. ### V {#v} - [`--validation`](openapi-only-options.md#validation) -- [`--validators`](general-options.md#validators) +- [`--validators`](template-customization.md#validators) - [`--version`](utility-options.md#version) ### W {#w} diff --git a/docs/cli-reference/quick-reference.md b/docs/cli-reference/quick-reference.md index 6a0b288f6..d9e57eaf9 100644 --- a/docs/cli-reference/quick-reference.md +++ b/docs/cli-reference/quick-reference.md @@ -150,6 +150,7 @@ datamodel-codegen [OPTIONS] | [`--treat-dot-as-module`](template-customization.md#treat-dot-as-module) | Treat dots in schema names as module separators. | | [`--use-double-quotes`](template-customization.md#use-double-quotes) | Use double quotes for string literals in generated code. | | [`--use-exact-imports`](template-customization.md#use-exact-imports) | Import exact types instead of modules. | +| [`--validators`](template-customization.md#validators) | Add custom field validators to generated Pydantic v2 models. | | [`--wrap-string-literal`](template-customization.md#wrap-string-literal) | Wrap long string literals across multiple lines. | ### 📘 OpenAPI-only Options @@ -187,7 +188,6 @@ datamodel-codegen [OPTIONS] | [`--ignore-pyproject`](general-options.md#ignore-pyproject) | Ignore pyproject.toml configuration file. | | [`--module-split-mode`](general-options.md#module-split-mode) | Split generated models into separate files, one per model class. | | [`--shared-module-name`](general-options.md#shared-module-name) | Customize the name of the shared module for deduplicated models. | -| [`--validators`](general-options.md#validators) | Add custom field validators to generated Pydantic v2 models. | | [`--watch`](general-options.md#watch) | Watch input file(s) for changes and regenerate output automatically. | | [`--watch-delay`](general-options.md#watch-delay) | Set debounce delay in seconds for watch mode. | @@ -348,7 +348,7 @@ All options sorted alphabetically: - [`--use-union-operator`](typing-customization.md#use-union-operator) - Use | operator for Union types (PEP 604). - [`--use-unique-items-as-set`](typing-customization.md#use-unique-items-as-set) - Generate set types for arrays with uniqueItems constraint. - [`--validation`](openapi-only-options.md#validation) - Enable validation constraints (deprecated, use --field-const... -- [`--validators`](general-options.md#validators) - Add custom field validators to generated Pydantic v2 models. +- [`--validators`](template-customization.md#validators) - Add custom field validators to generated Pydantic v2 models. - [`--version`](utility-options.md#version) - Show program version and exit - [`--watch`](general-options.md#watch) - Watch input file(s) for changes and regenerate output automa... - [`--watch-delay`](general-options.md#watch-delay) - Set debounce delay in seconds for watch mode. diff --git a/docs/cli-reference/template-customization.md b/docs/cli-reference/template-customization.md index fd54c2140..f6b67f94a 100644 --- a/docs/cli-reference/template-customization.md +++ b/docs/cli-reference/template-customization.md @@ -21,6 +21,7 @@ | [`--treat-dot-as-module`](#treat-dot-as-module) | Treat dots in schema names as module separators. | | [`--use-double-quotes`](#use-double-quotes) | Use double quotes for string literals in generated code. | | [`--use-exact-imports`](#use-exact-imports) | Import exact types instead of modules. | +| [`--validators`](#validators) | Add custom field validators to generated Pydantic v2 models.... | | [`--wrap-string-literal`](#wrap-string-literal) | Wrap long string literals across multiple lines. | --- @@ -2692,6 +2693,83 @@ modules are generated. For single-file output, the difference is minimal. --- +## `--validators` {#validators} + +Add custom field validators to generated Pydantic v2 models. + +The `--validators` option takes a JSON file defining validators per model. +Each validator specifies the field(s) to validate, the validation function +to import, and optionally the mode (before/after/wrap/plain). +This allows injecting custom validation logic into generated models. + +**See also:** [Field Validators](../validators.md) + +!!! tip "Usage" + + ```bash + datamodel-codegen --input schema.json --validators tests/data/jsonschema/field_validators_config.json --output-model-type pydantic_v2.BaseModel --disable-timestamp # (1)! + ``` + + 1. :material-arrow-left: `--validators` - the option documented here + +??? example "Examples" + + **Input Schema:** + + ```json + { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "User", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + }, + "age": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["name", "email"] + } + ``` + + **Output:** + + ```python + # generated by datamodel-codegen: + # filename: field_validators.json + + from __future__ import annotations + + from typing import Any + + from myapp.validators import validate_email, validate_name + from pydantic import BaseModel, EmailStr, ValidationInfo, conint, field_validator + + + class User(BaseModel): + name: str + email: EmailStr + age: conint(ge=0) | None = None + + @field_validator('name', mode='before') + @classmethod + def validate_name_name_validator(cls, v: Any, info: ValidationInfo) -> Any: + return validate_name(v, info) + + @field_validator('email', mode='after') + @classmethod + def validate_email_email_validator(cls, v: Any, info: ValidationInfo) -> Any: + return validate_email(v, info) + ``` + +--- + ## `--wrap-string-literal` {#wrap-string-literal} Wrap long string literals across multiple lines. diff --git a/src/datamodel_code_generator/prompt_data.py b/src/datamodel_code_generator/prompt_data.py index d63375ce3..00100dc4a 100644 --- a/src/datamodel_code_generator/prompt_data.py +++ b/src/datamodel_code_generator/prompt_data.py @@ -142,6 +142,7 @@ "--use-union-operator": "Use | operator for Union types (PEP 604).", "--use-unique-items-as-set": "Generate set types for arrays with uniqueItems constraint.", "--validation": "Enable validation constraints (deprecated, use --field-constraints).", + "--validators": "Add custom field validators to generated Pydantic v2 models.", "--watch": "Watch input file(s) for changes and regenerate output automatically.", "--watch-delay": "Set debounce delay in seconds for watch mode.", "--wrap-string-literal": "Wrap long string literals across multiple lines.", From d41d68c5376def8ffed8324e58e6437809d4e933 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 2 Jan 2026 18:21:54 +0000 Subject: [PATCH 04/17] Replace hash suffix with increment for validator method names --- docs/validators.md | 8 ++++---- .../model/pydantic_v2/base_model.py | 16 +++++++++------- .../expected/main/jsonschema/field_validators.py | 4 ++-- .../jsonschema/field_validators_multi_fields.py | 2 +- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/docs/validators.md b/docs/validators.md index 7ab9ce41c..74097e473 100644 --- a/docs/validators.md +++ b/docs/validators.md @@ -168,17 +168,17 @@ class User(BaseModel): @field_validator('name', mode='before') @classmethod - def validate_name_name_validator(cls, v: Any, info: ValidationInfo) -> Any: + def validate_name_validator(cls, v: Any, info: ValidationInfo) -> Any: return validate_name(v, info) @field_validator('email', mode='after') @classmethod - def validate_email_email_validator(cls, v: Any, info: ValidationInfo) -> Any: + def validate_email_validator(cls, v: Any, info: ValidationInfo) -> Any: return validate_email(v, info) @field_validator('name', 'email', mode='after') @classmethod - def validate_contact_info_84d627_validator(cls, v: Any, info: ValidationInfo) -> Any: + def validate_contact_info_validator(cls, v: Any, info: ValidationInfo) -> Any: return validate_contact_info(v, info) ``` @@ -187,7 +187,7 @@ class User(BaseModel): - This feature only supports Pydantic v2 (`--output-model-type pydantic_v2.BaseModel`) - The `ModelName` in the validators file must match the generated Python class name - Validator functions are imported automatically based on the `function` path -- For multi-field validators, a hash suffix is added to the method name to ensure uniqueness +- When the same validator function is used multiple times, an incrementing suffix (`_1`, `_2`, etc.) is added to ensure method name uniqueness --- diff --git a/src/datamodel_code_generator/model/pydantic_v2/base_model.py b/src/datamodel_code_generator/model/pydantic_v2/base_model.py index 6d5e8425d..07c7f63b1 100644 --- a/src/datamodel_code_generator/model/pydantic_v2/base_model.py +++ b/src/datamodel_code_generator/model/pydantic_v2/base_model.py @@ -353,6 +353,7 @@ def _process_validators(self) -> None: return prepared_validators: list[dict[str, Any]] = [] + used_method_names: set[str] = set() for validator in validators: fields = validator.get("fields") or [validator.get("field")] fields = [f for f in fields if f] @@ -368,13 +369,14 @@ def _process_validators(self) -> None: fields_str = ", ".join(f"'{f}'" for f in fields) - if len(fields) == 1: - method_name = f"{function_name}_{fields[0]}_validator" - else: - import hashlib # noqa: PLC0415 - - fields_hash = hashlib.md5("_".join(sorted(fields)).encode()).hexdigest()[:6] # noqa: S324 - method_name = f"{function_name}_{fields_hash}_validator" + # Generate unique method name with increment suffix if needed + base_method_name = f"{function_name}_validator" + method_name = base_method_name + count = 1 + while method_name in used_method_names: + method_name = f"{base_method_name}_{count}" + count += 1 + used_method_names.add(method_name) mode_str = f"mode='{mode}'" diff --git a/tests/data/expected/main/jsonschema/field_validators.py b/tests/data/expected/main/jsonschema/field_validators.py index f2245485d..8f4eb671c 100644 --- a/tests/data/expected/main/jsonschema/field_validators.py +++ b/tests/data/expected/main/jsonschema/field_validators.py @@ -16,10 +16,10 @@ class User(BaseModel): @field_validator('name', mode='before') @classmethod - def validate_name_name_validator(cls, v: Any, info: ValidationInfo) -> Any: + def validate_name_validator(cls, v: Any, info: ValidationInfo) -> Any: return validate_name(v, info) @field_validator('email', mode='after') @classmethod - def validate_email_email_validator(cls, v: Any, info: ValidationInfo) -> Any: + def validate_email_validator(cls, v: Any, info: ValidationInfo) -> Any: return validate_email(v, info) diff --git a/tests/data/expected/main/jsonschema/field_validators_multi_fields.py b/tests/data/expected/main/jsonschema/field_validators_multi_fields.py index 0dd5e77ac..207558418 100644 --- a/tests/data/expected/main/jsonschema/field_validators_multi_fields.py +++ b/tests/data/expected/main/jsonschema/field_validators_multi_fields.py @@ -16,5 +16,5 @@ class User(BaseModel): @field_validator('name', 'email', mode='after') @classmethod - def validate_contact_84d627_validator(cls, v: Any, info: ValidationInfo) -> Any: + def validate_contact_validator(cls, v: Any, info: ValidationInfo) -> Any: return validate_contact(v, info) From 101317c9960c402e91af5354782e520c4e3f0ed4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 2 Jan 2026 18:22:35 +0000 Subject: [PATCH 05/17] docs: update CLI reference documentation and prompt data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated by GitHub Actions --- docs/cli-reference/template-customization.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/cli-reference/template-customization.md b/docs/cli-reference/template-customization.md index f6b67f94a..807f419bc 100644 --- a/docs/cli-reference/template-customization.md +++ b/docs/cli-reference/template-customization.md @@ -2759,12 +2759,12 @@ This allows injecting custom validation logic into generated models. @field_validator('name', mode='before') @classmethod - def validate_name_name_validator(cls, v: Any, info: ValidationInfo) -> Any: + def validate_name_validator(cls, v: Any, info: ValidationInfo) -> Any: return validate_name(v, info) @field_validator('email', mode='after') @classmethod - def validate_email_email_validator(cls, v: Any, info: ValidationInfo) -> Any: + def validate_email_validator(cls, v: Any, info: ValidationInfo) -> Any: return validate_email(v, info) ``` From a80a5ca793e12dd75d795ac85394e33bf4aeb3a3 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 2 Jan 2026 18:25:07 +0000 Subject: [PATCH 06/17] Use ModelResolver for validator method name uniqueness --- .../model/pydantic_v2/base_model.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/datamodel_code_generator/model/pydantic_v2/base_model.py b/src/datamodel_code_generator/model/pydantic_v2/base_model.py index 07c7f63b1..6d54957f5 100644 --- a/src/datamodel_code_generator/model/pydantic_v2/base_model.py +++ b/src/datamodel_code_generator/model/pydantic_v2/base_model.py @@ -352,8 +352,10 @@ def _process_validators(self) -> None: if not validators: return + from datamodel_code_generator.reference import ModelResolver # noqa: PLC0415 + prepared_validators: list[dict[str, Any]] = [] - used_method_names: set[str] = set() + scoped_resolver = ModelResolver(custom_class_name_generator=lambda name: name) for validator in validators: fields = validator.get("fields") or [validator.get("field")] fields = [f for f in fields if f] @@ -369,14 +371,9 @@ def _process_validators(self) -> None: fields_str = ", ".join(f"'{f}'" for f in fields) - # Generate unique method name with increment suffix if needed + # Generate unique method name using ModelResolver base_method_name = f"{function_name}_validator" - method_name = base_method_name - count = 1 - while method_name in used_method_names: - method_name = f"{base_method_name}_{count}" - count += 1 - used_method_names.add(method_name) + method_name = scoped_resolver.add([base_method_name], base_method_name, unique=True, class_name=True).name mode_str = f"mode='{mode}'" From bcd6efd99c122881d3fe419ad17250588307c72a Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 2 Jan 2026 18:33:04 +0000 Subject: [PATCH 07/17] Use ValidatorsConfigType for strict typing and _load_json_config helper --- src/datamodel_code_generator/__main__.py | 26 +++++++------------ .../_types/generate_config_dict.py | 10 ++++++- .../_types/parser_config_dicts.py | 10 ++++++- src/datamodel_code_generator/config.py | 5 ++-- .../model/pydantic_v2/base_model.py | 23 +++++++--------- src/datamodel_code_generator/parser/base.py | 3 ++- src/datamodel_code_generator/validators.py | 26 +++++++++---------- 7 files changed, 54 insertions(+), 49 deletions(-) diff --git a/src/datamodel_code_generator/__main__.py b/src/datamodel_code_generator/__main__.py index f57db9c7d..053fb2596 100644 --- a/src/datamodel_code_generator/__main__.py +++ b/src/datamodel_code_generator/__main__.py @@ -99,6 +99,8 @@ from typing_extensions import Self + from datamodel_code_generator.validators import ValidatorsConfigType + # Options that should be excluded from pyproject.toml config generation EXCLUDED_CONFIG_OPTIONS: frozenset[str] = frozenset({ "check", @@ -900,7 +902,7 @@ def run_generate_from_config( # noqa: PLR0913, PLR0917 command_line: str | None, custom_formatters_kwargs: dict[str, str] | None, settings_path: Path | None = None, - validators: dict[str, Any] | None = None, + validators: ValidatorsConfigType | None = None, default_value_overrides: dict[str, Any] | None = None, ) -> None: """Run code generation with the given config and parameters.""" @@ -1257,22 +1259,12 @@ def _validate_string_mapping(data: Any) -> str | None: print(error, file=sys.stderr) # noqa: T201 return Exit.ERROR - validators_config: dict[str, Any] | None - if config.validators is None: - validators_config = None - else: - with config.validators as data: - try: - validators_config = json.load(data) - except json.JSONDecodeError as e: - print(f"Unable to load validators configuration: {e}", file=sys.stderr) # noqa: T201 - return Exit.ERROR - if not isinstance(validators_config, dict): - print( # noqa: T201 - "Validators configuration must be a JSON object with model names as keys", - file=sys.stderr, - ) - return Exit.ERROR + validators_config, error = _load_json_config( + config.validators, "validators configuration", _validate_string_key_dict + ) + if error: + print(error, file=sys.stderr) # noqa: T201 + return Exit.ERROR if config.check: config_output = cast("Path", config.output) diff --git a/src/datamodel_code_generator/_types/generate_config_dict.py b/src/datamodel_code_generator/_types/generate_config_dict.py index af8b1549d..4670e70e1 100644 --- a/src/datamodel_code_generator/_types/generate_config_dict.py +++ b/src/datamodel_code_generator/_types/generate_config_dict.py @@ -35,6 +35,7 @@ ) from datamodel_code_generator.format import DateClassType, DatetimeClassType, Formatter, PythonVersion from datamodel_code_generator.parser import LiteralType + from datamodel_code_generator.validators import ModelValidators class GenerateConfigDict(TypedDict): @@ -50,7 +51,7 @@ class GenerateConfigDict(TypedDict): class_decorators: NotRequired[list[str] | None] custom_template_dir: NotRequired[Path | None] extra_template_data: NotRequired[defaultdict[str, dict[str, Any]] | None] - validators: NotRequired[dict[str, Any] | None] + validators: NotRequired[dict[str, ModelValidators] | None] validation: NotRequired[bool] field_constraints: NotRequired[bool] snake_case_field: NotRequired[bool] @@ -167,3 +168,10 @@ class GenerateConfigDict(TypedDict): field_type_collision_strategy: NotRequired[FieldTypeCollisionStrategy | None] module_split_mode: NotRequired[ModuleSplitMode | None] default_value_overrides: NotRequired[Mapping[str, Any] | None] + + +class ValidatorDefinition(TypedDict): + field: NotRequired[str] + fields: NotRequired[list[str]] + function: NotRequired[str] + mode: NotRequired[str] diff --git a/src/datamodel_code_generator/_types/parser_config_dicts.py b/src/datamodel_code_generator/_types/parser_config_dicts.py index 0094cc1dd..bc406cb64 100644 --- a/src/datamodel_code_generator/_types/parser_config_dicts.py +++ b/src/datamodel_code_generator/_types/parser_config_dicts.py @@ -30,6 +30,14 @@ from datamodel_code_generator.model.base import DataModel, DataModelFieldBase from datamodel_code_generator.parser import DefaultPutDict, LiteralType from datamodel_code_generator.types import DataTypeManager + from datamodel_code_generator.validators import ModelValidators + + +class ValidatorDefinitionDict(TypedDict): + field: NotRequired[str] + fields: NotRequired[list[str]] + function: NotRequired[str] + mode: NotRequired[str] class ParserConfigDict(TypedDict): @@ -43,7 +51,7 @@ class ParserConfigDict(TypedDict): class_decorators: NotRequired[list[str] | None] custom_template_dir: NotRequired[Path | None] extra_template_data: NotRequired[defaultdict[str, dict[str, Any]] | None] - validators: NotRequired[dict[str, Any] | None] + validators: NotRequired[dict[str, ModelValidators] | None] target_python_version: NotRequired[PythonVersion] dump_resolve_reference_action: NotRequired[Callable[[Iterable[str]], str] | None] validation: NotRequired[bool] diff --git a/src/datamodel_code_generator/config.py b/src/datamodel_code_generator/config.py index 44672c3b0..fd3af00b8 100644 --- a/src/datamodel_code_generator/config.py +++ b/src/datamodel_code_generator/config.py @@ -46,6 +46,7 @@ from datamodel_code_generator.parser import DefaultPutDict, LiteralType from datamodel_code_generator.types import DataTypeManager, StrictTypes # noqa: TC001 - used by Pydantic at runtime from datamodel_code_generator.util import ConfigDict, is_pydantic_v2 +from datamodel_code_generator.validators import ValidatorsConfigType # noqa: TC001 - used by Pydantic at runtime if TYPE_CHECKING: from datamodel_code_generator.model.pydantic_v2 import UnionMode @@ -87,7 +88,7 @@ class Config: class_decorators: list[str] | None = None custom_template_dir: Path | None = None extra_template_data: ExtraTemplateDataType | None = None - validators: dict[str, Any] | None = None + validators: ValidatorsConfigType | None = None validation: bool = False field_constraints: bool = False snake_case_field: bool = False @@ -229,7 +230,7 @@ class Config: class_decorators: list[str] | None = None custom_template_dir: Path | None = None extra_template_data: ExtraTemplateDataType | None = None - validators: dict[str, Any] | None = None + validators: ValidatorsConfigType | None = None target_python_version: PythonVersion = PythonVersionMin dump_resolve_reference_action: DumpResolveReferenceAction | None = None validation: bool = False diff --git a/src/datamodel_code_generator/model/pydantic_v2/base_model.py b/src/datamodel_code_generator/model/pydantic_v2/base_model.py index 6d54957f5..00dd05197 100644 --- a/src/datamodel_code_generator/model/pydantic_v2/base_model.py +++ b/src/datamodel_code_generator/model/pydantic_v2/base_model.py @@ -12,7 +12,7 @@ from pydantic import Field -from datamodel_code_generator.imports import Import +from datamodel_code_generator.imports import IMPORT_ANY, Import from datamodel_code_generator.model.base import ALL_MODEL, UNDEFINED, BaseClassDataType, DataModelFieldBase from datamodel_code_generator.model.pydantic.base_model import ( BaseModelBase, @@ -23,7 +23,14 @@ from datamodel_code_generator.model.pydantic.base_model import ( DataModelField as DataModelFieldV1, ) -from datamodel_code_generator.model.pydantic_v2.imports import IMPORT_BASE_MODEL, IMPORT_CONFIG_DICT +from datamodel_code_generator.model.pydantic_v2.imports import ( + IMPORT_BASE_MODEL, + IMPORT_CONFIG_DICT, + IMPORT_FIELD_VALIDATOR, + IMPORT_VALIDATION_INFO, + IMPORT_VALIDATOR_FUNCTION_WRAP_HANDLER, +) +from datamodel_code_generator.reference import ModelResolver from datamodel_code_generator.types import chain_as_tuple from datamodel_code_generator.util import field_validator, model_validate, model_validator @@ -343,17 +350,10 @@ def _has_lookaround_pattern(self) -> bool: def _process_validators(self) -> None: """Process validator definitions and prepare them for template rendering.""" - from datamodel_code_generator.model.pydantic_v2.imports import ( # noqa: PLC0415 - IMPORT_FIELD_VALIDATOR, - IMPORT_VALIDATION_INFO, - ) - validators = self.extra_template_data.get("validators") if not validators: return - from datamodel_code_generator.reference import ModelResolver # noqa: PLC0415 - prepared_validators: list[dict[str, Any]] = [] scoped_resolver = ModelResolver(custom_class_name_generator=lambda name: name) for validator in validators: @@ -388,11 +388,6 @@ def _process_validators(self) -> None: self._additional_imports.append(Import.from_full_path(function_path)) if prepared_validators: - from datamodel_code_generator.imports import IMPORT_ANY # noqa: PLC0415 - from datamodel_code_generator.model.pydantic_v2.imports import ( # noqa: PLC0415 - IMPORT_VALIDATOR_FUNCTION_WRAP_HANDLER, - ) - self.extra_template_data["prepared_validators"] = prepared_validators # pyright: ignore[reportArgumentType] self._additional_imports.append(IMPORT_FIELD_VALIDATOR) self._additional_imports.append(IMPORT_ANY) diff --git a/src/datamodel_code_generator/parser/base.py b/src/datamodel_code_generator/parser/base.py index 036a7288d..ab3dc46d5 100644 --- a/src/datamodel_code_generator/parser/base.py +++ b/src/datamodel_code_generator/parser/base.py @@ -89,6 +89,7 @@ from datamodel_code_generator._types import ParserConfigDict from datamodel_code_generator.config import ParserConfig + from datamodel_code_generator.validators import ValidatorsConfigType ParserConfigT = TypeVar("ParserConfigT", bound="ParserConfig") @@ -836,7 +837,7 @@ def __init__( # noqa: PLR0912, PLR0915 self.source: str | Path | list[Path] | ParseResult | dict[str, YamlValue] = source self.custom_template_dir = config.custom_template_dir self.extra_template_data: defaultdict[str, Any] = config.extra_template_data or defaultdict(dict) - self.validators: dict[str, Any] | None = config.validators + self.validators: ValidatorsConfigType | None = config.validators if self.validators: for model_name, model_config in self.validators.items(): diff --git a/src/datamodel_code_generator/validators.py b/src/datamodel_code_generator/validators.py index 50bc6c7b5..12b8bc7e0 100644 --- a/src/datamodel_code_generator/validators.py +++ b/src/datamodel_code_generator/validators.py @@ -6,25 +6,25 @@ from __future__ import annotations from enum import Enum -from typing import TYPE_CHECKING +from typing import TypedDict -if TYPE_CHECKING: - from typing import TypedDict - class ValidatorDefinition(TypedDict, total=False): - """Definition of a single validator.""" +class ValidatorDefinition(TypedDict, total=False): + """Definition of a single validator.""" - field: str - fields: list[str] - function: str - mode: str + field: str + fields: list[str] + function: str + mode: str - class ModelValidators(TypedDict, total=False): - """Validators configuration for a single model.""" - validators: list[ValidatorDefinition] +class ModelValidators(TypedDict, total=False): + """Validators configuration for a single model.""" - ValidatorsConfigType = dict[str, ModelValidators] + validators: list[ValidatorDefinition] + + +ValidatorsConfigType = dict[str, ModelValidators] class ValidatorMode(str, Enum): From 57526119a61e7812e622b3e10a5896e6667e0793 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 2 Jan 2026 18:39:52 +0000 Subject: [PATCH 08/17] Use Pydantic models for validators config validation --- src/datamodel_code_generator/__main__.py | 31 ++++++++++++----- .../_types/generate_config_dict.py | 10 +----- .../_types/parser_config_dicts.py | 10 +----- src/datamodel_code_generator/config.py | 5 ++- src/datamodel_code_generator/parser/base.py | 3 +- src/datamodel_code_generator/validators.py | 34 ++++++++++--------- 6 files changed, 46 insertions(+), 47 deletions(-) diff --git a/src/datamodel_code_generator/__main__.py b/src/datamodel_code_generator/__main__.py index 053fb2596..4512d3b34 100644 --- a/src/datamodel_code_generator/__main__.py +++ b/src/datamodel_code_generator/__main__.py @@ -99,7 +99,6 @@ from typing_extensions import Self - from datamodel_code_generator.validators import ValidatorsConfigType # Options that should be excluded from pyproject.toml config generation EXCLUDED_CONFIG_OPTIONS: frozenset[str] = frozenset({ @@ -902,7 +901,7 @@ def run_generate_from_config( # noqa: PLR0913, PLR0917 command_line: str | None, custom_formatters_kwargs: dict[str, str] | None, settings_path: Path | None = None, - validators: ValidatorsConfigType | None = None, + validators: dict[str, Any] | None = None, default_value_overrides: dict[str, Any] | None = None, ) -> None: """Run code generation with the given config and parameters.""" @@ -1259,12 +1258,28 @@ def _validate_string_mapping(data: Any) -> str | None: print(error, file=sys.stderr) # noqa: T201 return Exit.ERROR - validators_config, error = _load_json_config( - config.validators, "validators configuration", _validate_string_key_dict - ) - if error: - print(error, file=sys.stderr) # noqa: T201 - return Exit.ERROR + validators_config: dict[str, Any] | None = None + if config.validators is not None: + from pydantic import ValidationError # noqa: PLC0415 + + from datamodel_code_generator.validators import ValidatorsConfig # noqa: PLC0415 + + with config.validators as data: + try: + raw_config = json.load(data) + except json.JSONDecodeError as e: + print(f"Unable to load validators configuration: {e}", file=sys.stderr) # noqa: T201 + return Exit.ERROR + try: + validated = ValidatorsConfig.model_validate(raw_config) + # Convert back to dict for downstream usage + validators_config = { + model_name: model_validators.model_dump(mode="json") + for model_name, model_validators in validated.root.items() + } + except ValidationError as e: + print(f"Invalid validators configuration: {e}", file=sys.stderr) # noqa: T201 + return Exit.ERROR if config.check: config_output = cast("Path", config.output) diff --git a/src/datamodel_code_generator/_types/generate_config_dict.py b/src/datamodel_code_generator/_types/generate_config_dict.py index 4670e70e1..af8b1549d 100644 --- a/src/datamodel_code_generator/_types/generate_config_dict.py +++ b/src/datamodel_code_generator/_types/generate_config_dict.py @@ -35,7 +35,6 @@ ) from datamodel_code_generator.format import DateClassType, DatetimeClassType, Formatter, PythonVersion from datamodel_code_generator.parser import LiteralType - from datamodel_code_generator.validators import ModelValidators class GenerateConfigDict(TypedDict): @@ -51,7 +50,7 @@ class GenerateConfigDict(TypedDict): class_decorators: NotRequired[list[str] | None] custom_template_dir: NotRequired[Path | None] extra_template_data: NotRequired[defaultdict[str, dict[str, Any]] | None] - validators: NotRequired[dict[str, ModelValidators] | None] + validators: NotRequired[dict[str, Any] | None] validation: NotRequired[bool] field_constraints: NotRequired[bool] snake_case_field: NotRequired[bool] @@ -168,10 +167,3 @@ class GenerateConfigDict(TypedDict): field_type_collision_strategy: NotRequired[FieldTypeCollisionStrategy | None] module_split_mode: NotRequired[ModuleSplitMode | None] default_value_overrides: NotRequired[Mapping[str, Any] | None] - - -class ValidatorDefinition(TypedDict): - field: NotRequired[str] - fields: NotRequired[list[str]] - function: NotRequired[str] - mode: NotRequired[str] diff --git a/src/datamodel_code_generator/_types/parser_config_dicts.py b/src/datamodel_code_generator/_types/parser_config_dicts.py index bc406cb64..0094cc1dd 100644 --- a/src/datamodel_code_generator/_types/parser_config_dicts.py +++ b/src/datamodel_code_generator/_types/parser_config_dicts.py @@ -30,14 +30,6 @@ from datamodel_code_generator.model.base import DataModel, DataModelFieldBase from datamodel_code_generator.parser import DefaultPutDict, LiteralType from datamodel_code_generator.types import DataTypeManager - from datamodel_code_generator.validators import ModelValidators - - -class ValidatorDefinitionDict(TypedDict): - field: NotRequired[str] - fields: NotRequired[list[str]] - function: NotRequired[str] - mode: NotRequired[str] class ParserConfigDict(TypedDict): @@ -51,7 +43,7 @@ class ParserConfigDict(TypedDict): class_decorators: NotRequired[list[str] | None] custom_template_dir: NotRequired[Path | None] extra_template_data: NotRequired[defaultdict[str, dict[str, Any]] | None] - validators: NotRequired[dict[str, ModelValidators] | None] + validators: NotRequired[dict[str, Any] | None] target_python_version: NotRequired[PythonVersion] dump_resolve_reference_action: NotRequired[Callable[[Iterable[str]], str] | None] validation: NotRequired[bool] diff --git a/src/datamodel_code_generator/config.py b/src/datamodel_code_generator/config.py index fd3af00b8..44672c3b0 100644 --- a/src/datamodel_code_generator/config.py +++ b/src/datamodel_code_generator/config.py @@ -46,7 +46,6 @@ from datamodel_code_generator.parser import DefaultPutDict, LiteralType from datamodel_code_generator.types import DataTypeManager, StrictTypes # noqa: TC001 - used by Pydantic at runtime from datamodel_code_generator.util import ConfigDict, is_pydantic_v2 -from datamodel_code_generator.validators import ValidatorsConfigType # noqa: TC001 - used by Pydantic at runtime if TYPE_CHECKING: from datamodel_code_generator.model.pydantic_v2 import UnionMode @@ -88,7 +87,7 @@ class Config: class_decorators: list[str] | None = None custom_template_dir: Path | None = None extra_template_data: ExtraTemplateDataType | None = None - validators: ValidatorsConfigType | None = None + validators: dict[str, Any] | None = None validation: bool = False field_constraints: bool = False snake_case_field: bool = False @@ -230,7 +229,7 @@ class Config: class_decorators: list[str] | None = None custom_template_dir: Path | None = None extra_template_data: ExtraTemplateDataType | None = None - validators: ValidatorsConfigType | None = None + validators: dict[str, Any] | None = None target_python_version: PythonVersion = PythonVersionMin dump_resolve_reference_action: DumpResolveReferenceAction | None = None validation: bool = False diff --git a/src/datamodel_code_generator/parser/base.py b/src/datamodel_code_generator/parser/base.py index ab3dc46d5..036a7288d 100644 --- a/src/datamodel_code_generator/parser/base.py +++ b/src/datamodel_code_generator/parser/base.py @@ -89,7 +89,6 @@ from datamodel_code_generator._types import ParserConfigDict from datamodel_code_generator.config import ParserConfig - from datamodel_code_generator.validators import ValidatorsConfigType ParserConfigT = TypeVar("ParserConfigT", bound="ParserConfig") @@ -837,7 +836,7 @@ def __init__( # noqa: PLR0912, PLR0915 self.source: str | Path | list[Path] | ParseResult | dict[str, YamlValue] = source self.custom_template_dir = config.custom_template_dir self.extra_template_data: defaultdict[str, Any] = config.extra_template_data or defaultdict(dict) - self.validators: ValidatorsConfigType | None = config.validators + self.validators: dict[str, Any] | None = config.validators if self.validators: for model_name, model_config in self.validators.items(): diff --git a/src/datamodel_code_generator/validators.py b/src/datamodel_code_generator/validators.py index 12b8bc7e0..c2e721705 100644 --- a/src/datamodel_code_generator/validators.py +++ b/src/datamodel_code_generator/validators.py @@ -6,31 +6,33 @@ from __future__ import annotations from enum import Enum -from typing import TypedDict +from pydantic import BaseModel, RootModel -class ValidatorDefinition(TypedDict, total=False): + +class ValidatorMode(str, Enum): + """Validator mode for Pydantic v2 field_validator.""" + + BEFORE = "before" + AFTER = "after" + WRAP = "wrap" + PLAIN = "plain" + + +class ValidatorDefinition(BaseModel): """Definition of a single validator.""" - field: str - fields: list[str] + field: str | None = None + fields: list[str] | None = None function: str - mode: str + mode: ValidatorMode = ValidatorMode.AFTER -class ModelValidators(TypedDict, total=False): +class ModelValidators(BaseModel): """Validators configuration for a single model.""" validators: list[ValidatorDefinition] -ValidatorsConfigType = dict[str, ModelValidators] - - -class ValidatorMode(str, Enum): - """Validator mode for Pydantic v2 field_validator.""" - - BEFORE = "before" - AFTER = "after" - WRAP = "wrap" - PLAIN = "plain" +class ValidatorsConfig(RootModel[dict[str, ModelValidators]]): + """Root model for validators configuration.""" From 3fb4649d196cbca41bebac427052ccc8034d3313 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 2 Jan 2026 19:11:56 +0000 Subject: [PATCH 09/17] Fix validators enum serialization and Pydantic v1 compatibility --- src/datamodel_code_generator/__main__.py | 80 +++++++++++++------ .../_types/generate_config_dict.py | 2 +- .../_types/parser_config_dicts.py | 2 +- src/datamodel_code_generator/config.py | 4 +- src/datamodel_code_generator/parser/base.py | 20 ++++- src/datamodel_code_generator/validators.py | 19 ++++- .../expected/main/input_model/config_class.py | 2 +- tests/main/jsonschema/test_main_jsonschema.py | 9 ++- .../test_public_api_signature_baseline.py | 4 +- 9 files changed, 103 insertions(+), 39 deletions(-) diff --git a/src/datamodel_code_generator/__main__.py b/src/datamodel_code_generator/__main__.py index 4512d3b34..7a48ecc38 100644 --- a/src/datamodel_code_generator/__main__.py +++ b/src/datamodel_code_generator/__main__.py @@ -39,11 +39,11 @@ import tempfile import warnings from collections import defaultdict -from collections.abc import Callable, Sequence # noqa: TC003 # pydantic needs it +from collections.abc import Callable, Mapping, Sequence # noqa: TC003 # pydantic needs it from enum import IntEnum from io import TextIOBase from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar, Optional, TypeAlias, Union, cast +from typing import TYPE_CHECKING, Any, ClassVar, Optional, TypeAlias, TypeVar, Union, cast from urllib.parse import ParseResult, urlparse from pydantic import BaseModel @@ -99,6 +99,8 @@ from typing_extensions import Self + from datamodel_code_generator.validators import ModelValidators + # Options that should be excluded from pyproject.toml config generation EXCLUDED_CONFIG_OPTIONS: frozenset[str] = frozenset({ @@ -892,6 +894,42 @@ def _load_json_config( return result, None +_ModelT = TypeVar("_ModelT") + + +def _load_json_config_with_pydantic( + file_handle: TextIOBase | None, + name: str, + model: type[_ModelT], +) -> tuple[_ModelT | None, str | None]: + """Load and validate a JSON configuration file using a Pydantic model. + + Args: + file_handle: The file handle to read from, or None. + name: The name of the config for error messages. + model: The Pydantic model class to use for validation. + + Returns: + A tuple of (validated_model, error_message). If successful, error_message is None. + If file_handle is None, returns (None, None). + """ + from pydantic import ValidationError # noqa: PLC0415 + + if file_handle is None: + return None, None + + with file_handle as data: + try: + raw = json.load(data) + except json.JSONDecodeError as e: + return None, f"Unable to load {name}: {e}" + + try: + return model.model_validate(raw), None # pyright: ignore[reportAttributeAccessIssue] + except ValidationError as e: + return None, f"Invalid {name}: {e}" + + def run_generate_from_config( # noqa: PLR0913, PLR0917 config: Config, input_: Path | str | ParseResult, @@ -901,7 +939,7 @@ def run_generate_from_config( # noqa: PLR0913, PLR0917 command_line: str | None, custom_formatters_kwargs: dict[str, str] | None, settings_path: Path | None = None, - validators: dict[str, Any] | None = None, + validators: Mapping[str, ModelValidators] | None = None, default_value_overrides: dict[str, Any] | None = None, ) -> None: """Run code generation with the given config and parameters.""" @@ -1031,7 +1069,7 @@ def run_generate_from_config( # noqa: PLR0913, PLR0917 all_exports_collision_strategy=config.all_exports_collision_strategy, field_type_collision_strategy=config.field_type_collision_strategy, module_split_mode=config.module_split_mode, - validators=validators, # pyright: ignore[reportCallIssue] + validators=validators, # pyright: ignore[reportArgumentType] default_value_overrides=default_value_overrides, ) @@ -1258,28 +1296,22 @@ def _validate_string_mapping(data: Any) -> str | None: print(error, file=sys.stderr) # noqa: T201 return Exit.ERROR - validators_config: dict[str, Any] | None = None - if config.validators is not None: - from pydantic import ValidationError # noqa: PLC0415 + from datamodel_code_generator.validators import ValidatorsConfig # noqa: PLC0415 - from datamodel_code_generator.validators import ValidatorsConfig # noqa: PLC0415 + if config.validators is not None and ValidatorsConfig is None: + print( # noqa: T201 + "Error: --validators option requires Pydantic v2. Please upgrade to Pydantic v2 or remove the option.", + file=sys.stderr, + ) + return Exit.ERROR - with config.validators as data: - try: - raw_config = json.load(data) - except json.JSONDecodeError as e: - print(f"Unable to load validators configuration: {e}", file=sys.stderr) # noqa: T201 - return Exit.ERROR - try: - validated = ValidatorsConfig.model_validate(raw_config) - # Convert back to dict for downstream usage - validators_config = { - model_name: model_validators.model_dump(mode="json") - for model_name, model_validators in validated.root.items() - } - except ValidationError as e: - print(f"Invalid validators configuration: {e}", file=sys.stderr) # noqa: T201 - return Exit.ERROR + validated_validators, error = _load_json_config_with_pydantic( + config.validators, "validators configuration", ValidatorsConfig + ) + if error: + print(error, file=sys.stderr) # noqa: T201 + return Exit.ERROR + validators_config = validated_validators.root if validated_validators else None if config.check: config_output = cast("Path", config.output) diff --git a/src/datamodel_code_generator/_types/generate_config_dict.py b/src/datamodel_code_generator/_types/generate_config_dict.py index af8b1549d..d67d6bac8 100644 --- a/src/datamodel_code_generator/_types/generate_config_dict.py +++ b/src/datamodel_code_generator/_types/generate_config_dict.py @@ -50,7 +50,7 @@ class GenerateConfigDict(TypedDict): class_decorators: NotRequired[list[str] | None] custom_template_dir: NotRequired[Path | None] extra_template_data: NotRequired[defaultdict[str, dict[str, Any]] | None] - validators: NotRequired[dict[str, Any] | None] + validators: NotRequired[Mapping[str, Any] | None] validation: NotRequired[bool] field_constraints: NotRequired[bool] snake_case_field: NotRequired[bool] diff --git a/src/datamodel_code_generator/_types/parser_config_dicts.py b/src/datamodel_code_generator/_types/parser_config_dicts.py index 0094cc1dd..553b8de3c 100644 --- a/src/datamodel_code_generator/_types/parser_config_dicts.py +++ b/src/datamodel_code_generator/_types/parser_config_dicts.py @@ -43,7 +43,7 @@ class ParserConfigDict(TypedDict): class_decorators: NotRequired[list[str] | None] custom_template_dir: NotRequired[Path | None] extra_template_data: NotRequired[defaultdict[str, dict[str, Any]] | None] - validators: NotRequired[dict[str, Any] | None] + validators: NotRequired[Mapping[str, Any] | None] target_python_version: NotRequired[PythonVersion] dump_resolve_reference_action: NotRequired[Callable[[Iterable[str]], str] | None] validation: NotRequired[bool] diff --git a/src/datamodel_code_generator/config.py b/src/datamodel_code_generator/config.py index 44672c3b0..489f6171e 100644 --- a/src/datamodel_code_generator/config.py +++ b/src/datamodel_code_generator/config.py @@ -87,7 +87,7 @@ class Config: class_decorators: list[str] | None = None custom_template_dir: Path | None = None extra_template_data: ExtraTemplateDataType | None = None - validators: dict[str, Any] | None = None + validators: Mapping[str, Any] | None = None validation: bool = False field_constraints: bool = False snake_case_field: bool = False @@ -229,7 +229,7 @@ class Config: class_decorators: list[str] | None = None custom_template_dir: Path | None = None extra_template_data: ExtraTemplateDataType | None = None - validators: dict[str, Any] | None = None + validators: Mapping[str, Any] | None = None target_python_version: PythonVersion = PythonVersionMin dump_resolve_reference_action: DumpResolveReferenceAction | None = None validation: bool = False diff --git a/src/datamodel_code_generator/parser/base.py b/src/datamodel_code_generator/parser/base.py index 036a7288d..e9b962b84 100644 --- a/src/datamodel_code_generator/parser/base.py +++ b/src/datamodel_code_generator/parser/base.py @@ -85,7 +85,7 @@ from datamodel_code_generator.util import camel_to_snake, model_copy, model_dump if TYPE_CHECKING: - from collections.abc import Iterable, Iterator, Sequence + from collections.abc import Iterable, Iterator, Mapping, Sequence from datamodel_code_generator._types import ParserConfigDict from datamodel_code_generator.config import ParserConfig @@ -836,12 +836,24 @@ def __init__( # noqa: PLR0912, PLR0915 self.source: str | Path | list[Path] | ParseResult | dict[str, YamlValue] = source self.custom_template_dir = config.custom_template_dir self.extra_template_data: defaultdict[str, Any] = config.extra_template_data or defaultdict(dict) - self.validators: dict[str, Any] | None = config.validators + self.validators: Mapping[str, Any] | None = config.validators if self.validators: for model_name, model_config in self.validators.items(): - if "validators" in model_config: - self.extra_template_data[model_name]["validators"] = model_config["validators"] + if hasattr(model_config, "validators"): + # Pydantic model (validated via ValidatorsConfig) + self.extra_template_data[model_name]["validators"] = [ + v.model_dump(mode="json") for v in model_config.validators + ] + elif "validators" in model_config: + # Dict (passed directly via API or via model_dump()) + # Ensure enum values are converted to strings + self.extra_template_data[model_name]["validators"] = [ + {k: v.value if hasattr(v, "value") else v for k, v in validator.items()} + if isinstance(validator, dict) + else validator.model_dump(mode="json") + for validator in model_config["validators"] + ] self.use_generic_base_class: bool = config.use_generic_base_class self.generic_base_class_config: dict[str, Any] = {} diff --git a/src/datamodel_code_generator/validators.py b/src/datamodel_code_generator/validators.py index c2e721705..bad8e6b79 100644 --- a/src/datamodel_code_generator/validators.py +++ b/src/datamodel_code_generator/validators.py @@ -6,8 +6,14 @@ from __future__ import annotations from enum import Enum +from typing import TYPE_CHECKING -from pydantic import BaseModel, RootModel +from pydantic import BaseModel + +from datamodel_code_generator.util import is_pydantic_v2 + +if TYPE_CHECKING: + from pydantic import RootModel class ValidatorMode(str, Enum): @@ -34,5 +40,12 @@ class ModelValidators(BaseModel): validators: list[ValidatorDefinition] -class ValidatorsConfig(RootModel[dict[str, ModelValidators]]): - """Root model for validators configuration.""" +if is_pydantic_v2(): + from pydantic import RootModel + + class ValidatorsConfig(RootModel[dict[str, ModelValidators]]): + """Root model for validators configuration.""" + +else: + # Pydantic v1 doesn't support RootModel, but validators feature is v2-only anyway + ValidatorsConfig = None # type: ignore[assignment,misc] diff --git a/tests/data/expected/main/input_model/config_class.py b/tests/data/expected/main/input_model/config_class.py index 0cffd5f1a..785a56fbf 100644 --- a/tests/data/expected/main/input_model/config_class.py +++ b/tests/data/expected/main/input_model/config_class.py @@ -123,7 +123,7 @@ class GenerateConfig(TypedDict): class_decorators: NotRequired[list[str] | None] custom_template_dir: NotRequired[str | None] extra_template_data: NotRequired[defaultdict[str, dict[str, Any]] | None] - validators: NotRequired[dict[str, Any] | None] + validators: NotRequired[Mapping[str, Any] | None] validation: NotRequired[bool] field_constraints: NotRequired[bool] snake_case_field: NotRequired[bool] diff --git a/tests/main/jsonschema/test_main_jsonschema.py b/tests/main/jsonschema/test_main_jsonschema.py index 7d19579f8..f43fe51be 100644 --- a/tests/main/jsonschema/test_main_jsonschema.py +++ b/tests/main/jsonschema/test_main_jsonschema.py @@ -24,6 +24,7 @@ from datamodel_code_generator.__main__ import Exit, main from datamodel_code_generator.format import is_supported_in_black from datamodel_code_generator.model import base as model_base +from datamodel_code_generator.util import is_pydantic_v2 from tests.conftest import assert_directory_content, freeze_time from tests.main.conftest import ( ALIASES_DATA_PATH, @@ -41,6 +42,8 @@ ) from tests.main.jsonschema.conftest import EXPECTED_JSON_SCHEMA_PATH, assert_file_content +PYDANTIC_V2_SKIP = pytest.mark.skipif(not is_pydantic_v2(), reason="Pydantic v2 required") + if TYPE_CHECKING: from pytest_mock import MockerFixture @@ -7649,6 +7652,7 @@ def test_reduce_duplicate_field_types(output_file: Path) -> None: ) +@PYDANTIC_V2_SKIP @pytest.mark.cli_doc( options=["--validators"], option_description="""Add custom field validators to generated Pydantic v2 models. @@ -7692,6 +7696,7 @@ def test_field_validators(output_file: Path) -> None: ) +@PYDANTIC_V2_SKIP def test_field_validators_multi_fields(output_file: Path) -> None: """Test validators with multiple fields.""" run_main_and_assert( @@ -7711,6 +7716,7 @@ def test_field_validators_multi_fields(output_file: Path) -> None: ) +@PYDANTIC_V2_SKIP def test_validators_invalid_json(output_file: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: """Test error handling for invalid validators JSON file.""" invalid_json = tmp_path / "invalid.json" @@ -7732,6 +7738,7 @@ def test_validators_invalid_json(output_file: Path, tmp_path: Path, capsys: pyte ) +@PYDANTIC_V2_SKIP def test_validators_invalid_structure(output_file: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: """Test error handling for validators JSON with invalid structure (not an object).""" invalid_structure = tmp_path / "invalid_structure.json" @@ -7749,5 +7756,5 @@ def test_validators_invalid_structure(output_file: Path, tmp_path: Path, capsys: "pydantic_v2.BaseModel", ], capsys=capsys, - expected_stderr_contains="Validators configuration must be a JSON object", + expected_stderr_contains="Invalid validators configuration", ) diff --git a/tests/main/test_public_api_signature_baseline.py b/tests/main/test_public_api_signature_baseline.py index 228b52e5a..8135d5175 100644 --- a/tests/main/test_public_api_signature_baseline.py +++ b/tests/main/test_public_api_signature_baseline.py @@ -69,7 +69,7 @@ def _baseline_generate( class_decorators: list[str] | None = None, custom_template_dir: Path | None = None, extra_template_data: defaultdict[str, dict[str, Any]] | None = None, - validators: dict[str, Any] | None = None, + validators: Mapping[str, Any] | None = None, validation: bool = False, field_constraints: bool = False, snake_case_field: bool = False, @@ -205,7 +205,7 @@ def __init__( class_decorators: list[str] | None = None, custom_template_dir: Path | None = None, extra_template_data: defaultdict[str, dict[str, Any]] | None = None, - validators: dict[str, Any] | None = None, + validators: Mapping[str, Any] | None = None, target_python_version: PythonVersion = PythonVersionMin, dump_resolve_reference_action: Callable[[Iterable[str]], str] | None = None, validation: bool = False, From c6c5b9c56ea12fa4a8dc8155b745996e645491d5 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 2 Jan 2026 19:24:51 +0000 Subject: [PATCH 10/17] Use ModelValidators type instead of Any for validators config --- src/datamodel_code_generator/__main__.py | 73 ++++++------------- .../_types/generate_config_dict.py | 14 +++- .../_types/parser_config_dicts.py | 14 +++- src/datamodel_code_generator/config.py | 5 +- src/datamodel_code_generator/parser/base.py | 21 ++---- .../expected/main/input_model/config_class.py | 17 ++++- .../test_public_api_signature_baseline.py | 5 +- 7 files changed, 76 insertions(+), 73 deletions(-) diff --git a/src/datamodel_code_generator/__main__.py b/src/datamodel_code_generator/__main__.py index 7a48ecc38..5e9d391a5 100644 --- a/src/datamodel_code_generator/__main__.py +++ b/src/datamodel_code_generator/__main__.py @@ -43,7 +43,7 @@ from enum import IntEnum from io import TextIOBase from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar, Optional, TypeAlias, TypeVar, Union, cast +from typing import TYPE_CHECKING, Any, ClassVar, Optional, TypeAlias, Union, cast from urllib.parse import ParseResult, urlparse from pydantic import BaseModel @@ -894,42 +894,6 @@ def _load_json_config( return result, None -_ModelT = TypeVar("_ModelT") - - -def _load_json_config_with_pydantic( - file_handle: TextIOBase | None, - name: str, - model: type[_ModelT], -) -> tuple[_ModelT | None, str | None]: - """Load and validate a JSON configuration file using a Pydantic model. - - Args: - file_handle: The file handle to read from, or None. - name: The name of the config for error messages. - model: The Pydantic model class to use for validation. - - Returns: - A tuple of (validated_model, error_message). If successful, error_message is None. - If file_handle is None, returns (None, None). - """ - from pydantic import ValidationError # noqa: PLC0415 - - if file_handle is None: - return None, None - - with file_handle as data: - try: - raw = json.load(data) - except json.JSONDecodeError as e: - return None, f"Unable to load {name}: {e}" - - try: - return model.model_validate(raw), None # pyright: ignore[reportAttributeAccessIssue] - except ValidationError as e: - return None, f"Invalid {name}: {e}" - - def run_generate_from_config( # noqa: PLR0913, PLR0917 config: Config, input_: Path | str | ParseResult, @@ -1298,20 +1262,29 @@ def _validate_string_mapping(data: Any) -> str | None: from datamodel_code_generator.validators import ValidatorsConfig # noqa: PLC0415 - if config.validators is not None and ValidatorsConfig is None: - print( # noqa: T201 - "Error: --validators option requires Pydantic v2. Please upgrade to Pydantic v2 or remove the option.", - file=sys.stderr, - ) - return Exit.ERROR + validators_config: dict[str, ModelValidators] | None = None + if config.validators is not None: + if ValidatorsConfig is None: + print( # noqa: T201 + "Error: --validators option requires Pydantic v2. Please upgrade to Pydantic v2 or remove the option.", + file=sys.stderr, + ) + return Exit.ERROR - validated_validators, error = _load_json_config_with_pydantic( - config.validators, "validators configuration", ValidatorsConfig - ) - if error: - print(error, file=sys.stderr) # noqa: T201 - return Exit.ERROR - validators_config = validated_validators.root if validated_validators else None + from pydantic import ValidationError # noqa: PLC0415 + + with config.validators as f: + try: + raw = json.load(f) + except json.JSONDecodeError as e: + print(f"Unable to load validators configuration: {e}", file=sys.stderr) # noqa: T201 + return Exit.ERROR + + try: + validators_config = ValidatorsConfig.model_validate(raw).root + except ValidationError as e: + print(f"Invalid validators configuration: {e}", file=sys.stderr) # noqa: T201 + return Exit.ERROR if config.check: config_output = cast("Path", config.output) diff --git a/src/datamodel_code_generator/_types/generate_config_dict.py b/src/datamodel_code_generator/_types/generate_config_dict.py index d67d6bac8..ce0c52847 100644 --- a/src/datamodel_code_generator/_types/generate_config_dict.py +++ b/src/datamodel_code_generator/_types/generate_config_dict.py @@ -35,6 +35,7 @@ ) from datamodel_code_generator.format import DateClassType, DatetimeClassType, Formatter, PythonVersion from datamodel_code_generator.parser import LiteralType + from datamodel_code_generator.validators import ModelValidators, ValidatorMode class GenerateConfigDict(TypedDict): @@ -50,7 +51,7 @@ class GenerateConfigDict(TypedDict): class_decorators: NotRequired[list[str] | None] custom_template_dir: NotRequired[Path | None] extra_template_data: NotRequired[defaultdict[str, dict[str, Any]] | None] - validators: NotRequired[Mapping[str, Any] | None] + validators: NotRequired[Mapping[str, ModelValidators] | None] validation: NotRequired[bool] field_constraints: NotRequired[bool] snake_case_field: NotRequired[bool] @@ -167,3 +168,14 @@ class GenerateConfigDict(TypedDict): field_type_collision_strategy: NotRequired[FieldTypeCollisionStrategy | None] module_split_mode: NotRequired[ModuleSplitMode | None] default_value_overrides: NotRequired[Mapping[str, Any] | None] + + +class ValidatorDefinition(TypedDict): + field: NotRequired[str | None] + fields: NotRequired[list[str] | None] + function: str + mode: NotRequired[ValidatorMode] + + +class ModelValidatorsModel(TypedDict): + validators: list[ValidatorDefinition] diff --git a/src/datamodel_code_generator/_types/parser_config_dicts.py b/src/datamodel_code_generator/_types/parser_config_dicts.py index 553b8de3c..f63151bd1 100644 --- a/src/datamodel_code_generator/_types/parser_config_dicts.py +++ b/src/datamodel_code_generator/_types/parser_config_dicts.py @@ -30,6 +30,14 @@ from datamodel_code_generator.model.base import DataModel, DataModelFieldBase from datamodel_code_generator.parser import DefaultPutDict, LiteralType from datamodel_code_generator.types import DataTypeManager + from datamodel_code_generator.validators import ModelValidators, ValidatorMode + + +class ValidatorDefinitionDict(TypedDict): + field: NotRequired[str | None] + fields: NotRequired[list[str] | None] + function: str + mode: NotRequired[ValidatorMode] class ParserConfigDict(TypedDict): @@ -43,7 +51,7 @@ class ParserConfigDict(TypedDict): class_decorators: NotRequired[list[str] | None] custom_template_dir: NotRequired[Path | None] extra_template_data: NotRequired[defaultdict[str, dict[str, Any]] | None] - validators: NotRequired[Mapping[str, Any] | None] + validators: NotRequired[Mapping[str, ModelValidators] | None] target_python_version: NotRequired[PythonVersion] dump_resolve_reference_action: NotRequired[Callable[[Iterable[str]], str] | None] validation: NotRequired[bool] @@ -170,3 +178,7 @@ class OpenAPIParserConfigDict(JSONSchemaParserConfigDict): ModelDict: TypeAlias = ParserConfigDict | GraphQLParserConfigDict | JSONSchemaParserConfigDict | OpenAPIParserConfigDict + + +class ModelValidatorsDict(TypedDict): + validators: list[ValidatorDefinitionDict] diff --git a/src/datamodel_code_generator/config.py b/src/datamodel_code_generator/config.py index 489f6171e..fa9ccfaf4 100644 --- a/src/datamodel_code_generator/config.py +++ b/src/datamodel_code_generator/config.py @@ -46,6 +46,7 @@ from datamodel_code_generator.parser import DefaultPutDict, LiteralType from datamodel_code_generator.types import DataTypeManager, StrictTypes # noqa: TC001 - used by Pydantic at runtime from datamodel_code_generator.util import ConfigDict, is_pydantic_v2 +from datamodel_code_generator.validators import ModelValidators # noqa: TC001 - used by Pydantic at runtime if TYPE_CHECKING: from datamodel_code_generator.model.pydantic_v2 import UnionMode @@ -87,7 +88,7 @@ class Config: class_decorators: list[str] | None = None custom_template_dir: Path | None = None extra_template_data: ExtraTemplateDataType | None = None - validators: Mapping[str, Any] | None = None + validators: Mapping[str, ModelValidators] | None = None validation: bool = False field_constraints: bool = False snake_case_field: bool = False @@ -229,7 +230,7 @@ class Config: class_decorators: list[str] | None = None custom_template_dir: Path | None = None extra_template_data: ExtraTemplateDataType | None = None - validators: Mapping[str, Any] | None = None + validators: Mapping[str, ModelValidators] | None = None target_python_version: PythonVersion = PythonVersionMin dump_resolve_reference_action: DumpResolveReferenceAction | None = None validation: bool = False diff --git a/src/datamodel_code_generator/parser/base.py b/src/datamodel_code_generator/parser/base.py index e9b962b84..1c3c68b33 100644 --- a/src/datamodel_code_generator/parser/base.py +++ b/src/datamodel_code_generator/parser/base.py @@ -85,7 +85,7 @@ from datamodel_code_generator.util import camel_to_snake, model_copy, model_dump if TYPE_CHECKING: - from collections.abc import Iterable, Iterator, Mapping, Sequence + from collections.abc import Iterable, Iterator, Sequence from datamodel_code_generator._types import ParserConfigDict from datamodel_code_generator.config import ParserConfig @@ -836,24 +836,13 @@ def __init__( # noqa: PLR0912, PLR0915 self.source: str | Path | list[Path] | ParseResult | dict[str, YamlValue] = source self.custom_template_dir = config.custom_template_dir self.extra_template_data: defaultdict[str, Any] = config.extra_template_data or defaultdict(dict) - self.validators: Mapping[str, Any] | None = config.validators + self.validators = config.validators if self.validators: for model_name, model_config in self.validators.items(): - if hasattr(model_config, "validators"): - # Pydantic model (validated via ValidatorsConfig) - self.extra_template_data[model_name]["validators"] = [ - v.model_dump(mode="json") for v in model_config.validators - ] - elif "validators" in model_config: - # Dict (passed directly via API or via model_dump()) - # Ensure enum values are converted to strings - self.extra_template_data[model_name]["validators"] = [ - {k: v.value if hasattr(v, "value") else v for k, v in validator.items()} - if isinstance(validator, dict) - else validator.model_dump(mode="json") - for validator in model_config["validators"] - ] + self.extra_template_data[model_name]["validators"] = [ + v.model_dump(mode="json") for v in model_config.validators + ] self.use_generic_base_class: bool = config.use_generic_base_class self.generic_base_class_config: dict[str, Any] = {} diff --git a/tests/data/expected/main/input_model/config_class.py b/tests/data/expected/main/input_model/config_class.py index 785a56fbf..09fc41050 100644 --- a/tests/data/expected/main/input_model/config_class.py +++ b/tests/data/expected/main/input_model/config_class.py @@ -9,6 +9,7 @@ from typing import Any, Literal, TypeAlias, TypedDict from datamodel_code_generator.enums import StrictTypes +from datamodel_code_generator.validators import ModelValidators from typing_extensions import NotRequired AllExportsCollisionStrategy: TypeAlias = Literal[ @@ -110,6 +111,9 @@ class DataclassArguments(TypedDict): UnionMode: TypeAlias = Literal['smart', 'left_to_right'] +ValidatorMode: TypeAlias = Literal['before', 'after', 'wrap', 'plain'] + + class GenerateConfig(TypedDict): input_filename: NotRequired[str | None] input_file_type: NotRequired[InputFileType] @@ -123,7 +127,7 @@ class GenerateConfig(TypedDict): class_decorators: NotRequired[list[str] | None] custom_template_dir: NotRequired[str | None] extra_template_data: NotRequired[defaultdict[str, dict[str, Any]] | None] - validators: NotRequired[Mapping[str, Any] | None] + validators: NotRequired[Mapping[str, ModelValidators] | None] validation: NotRequired[bool] field_constraints: NotRequired[bool] snake_case_field: NotRequired[bool] @@ -242,3 +246,14 @@ class GenerateConfig(TypedDict): field_type_collision_strategy: NotRequired[FieldTypeCollisionStrategy | None] module_split_mode: NotRequired[ModuleSplitMode | None] default_value_overrides: NotRequired[Mapping[str, Any] | None] + + +class ValidatorDefinition(TypedDict): + field: NotRequired[str | None] + fields: NotRequired[list[str] | None] + function: str + mode: NotRequired[ValidatorMode] + + +class ModelValidatorsModel(TypedDict): + validators: list[ValidatorDefinition] diff --git a/tests/main/test_public_api_signature_baseline.py b/tests/main/test_public_api_signature_baseline.py index 8135d5175..ff32f7c1e 100644 --- a/tests/main/test_public_api_signature_baseline.py +++ b/tests/main/test_public_api_signature_baseline.py @@ -51,6 +51,7 @@ from datamodel_code_generator.model.pydantic_v2 import UnionMode from datamodel_code_generator.parser import DefaultPutDict, LiteralType from datamodel_code_generator.types import StrictTypes + from datamodel_code_generator.validators import ModelValidators def _baseline_generate( @@ -69,7 +70,7 @@ def _baseline_generate( class_decorators: list[str] | None = None, custom_template_dir: Path | None = None, extra_template_data: defaultdict[str, dict[str, Any]] | None = None, - validators: Mapping[str, Any] | None = None, + validators: Mapping[str, ModelValidators] | None = None, validation: bool = False, field_constraints: bool = False, snake_case_field: bool = False, @@ -205,7 +206,7 @@ def __init__( class_decorators: list[str] | None = None, custom_template_dir: Path | None = None, extra_template_data: defaultdict[str, dict[str, Any]] | None = None, - validators: Mapping[str, Any] | None = None, + validators: Mapping[str, ModelValidators] | None = None, target_python_version: PythonVersion = PythonVersionMin, dump_resolve_reference_action: Callable[[Iterable[str]], str] | None = None, validation: bool = False, From 5c22227d5bd034a764ed8a9c4b8386659f467dd6 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 2 Jan 2026 19:29:22 +0000 Subject: [PATCH 11/17] Remove unnecessary pyright ignore comment --- src/datamodel_code_generator/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/datamodel_code_generator/__main__.py b/src/datamodel_code_generator/__main__.py index 5e9d391a5..362d4a34e 100644 --- a/src/datamodel_code_generator/__main__.py +++ b/src/datamodel_code_generator/__main__.py @@ -1033,7 +1033,7 @@ def run_generate_from_config( # noqa: PLR0913, PLR0917 all_exports_collision_strategy=config.all_exports_collision_strategy, field_type_collision_strategy=config.field_type_collision_strategy, module_split_mode=config.module_split_mode, - validators=validators, # pyright: ignore[reportArgumentType] + validators=validators, default_value_overrides=default_value_overrides, ) From 95ebd3a5009af0597db75e0e2aa4fc8638d66de7 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 2 Jan 2026 19:33:31 +0000 Subject: [PATCH 12/17] Move function-internal imports to top level --- src/datamodel_code_generator/__main__.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/datamodel_code_generator/__main__.py b/src/datamodel_code_generator/__main__.py index 362d4a34e..bf262295a 100644 --- a/src/datamodel_code_generator/__main__.py +++ b/src/datamodel_code_generator/__main__.py @@ -46,7 +46,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Optional, TypeAlias, Union, cast from urllib.parse import ParseResult, urlparse -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError from datamodel_code_generator import ( DEFAULT_SHARED_MODULE_NAME, @@ -93,6 +93,7 @@ load_toml, model_validator, ) +from datamodel_code_generator.validators import ValidatorsConfig if TYPE_CHECKING: from argparse import Namespace @@ -1260,8 +1261,6 @@ def _validate_string_mapping(data: Any) -> str | None: print(error, file=sys.stderr) # noqa: T201 return Exit.ERROR - from datamodel_code_generator.validators import ValidatorsConfig # noqa: PLC0415 - validators_config: dict[str, ModelValidators] | None = None if config.validators is not None: if ValidatorsConfig is None: @@ -1271,8 +1270,6 @@ def _validate_string_mapping(data: Any) -> str | None: ) return Exit.ERROR - from pydantic import ValidationError # noqa: PLC0415 - with config.validators as f: try: raw = json.load(f) From 26a13fbc669d12471aaa0e54247206b5d4b0e52d Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 2 Jan 2026 19:35:22 +0000 Subject: [PATCH 13/17] Refactor validators loading to use helper function pattern --- src/datamodel_code_generator/__main__.py | 52 ++++++++++++++---------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/src/datamodel_code_generator/__main__.py b/src/datamodel_code_generator/__main__.py index bf262295a..2aa146da8 100644 --- a/src/datamodel_code_generator/__main__.py +++ b/src/datamodel_code_generator/__main__.py @@ -895,6 +895,33 @@ def _load_json_config( return result, None +def _load_validators_config( + file_handle: TextIOBase | None, +) -> tuple[dict[str, ModelValidators] | None, str | None]: + """Load and validate a validators configuration file. + + Returns: + A tuple of (validators_dict, error_message). If successful, error_message is None. + If file_handle is None, returns (None, None). + """ + if file_handle is None: + return None, None + + if ValidatorsConfig is None: + return None, "--validators option requires Pydantic v2. Please upgrade to Pydantic v2 or remove the option." + + with file_handle as data: + try: + raw = json.load(data) + except json.JSONDecodeError as e: + return None, f"Unable to load validators configuration: {e}" + + try: + return ValidatorsConfig.model_validate(raw).root, None + except ValidationError as e: + return None, f"Invalid validators configuration: {e}" + + def run_generate_from_config( # noqa: PLR0913, PLR0917 config: Config, input_: Path | str | ParseResult, @@ -1261,27 +1288,10 @@ def _validate_string_mapping(data: Any) -> str | None: print(error, file=sys.stderr) # noqa: T201 return Exit.ERROR - validators_config: dict[str, ModelValidators] | None = None - if config.validators is not None: - if ValidatorsConfig is None: - print( # noqa: T201 - "Error: --validators option requires Pydantic v2. Please upgrade to Pydantic v2 or remove the option.", - file=sys.stderr, - ) - return Exit.ERROR - - with config.validators as f: - try: - raw = json.load(f) - except json.JSONDecodeError as e: - print(f"Unable to load validators configuration: {e}", file=sys.stderr) # noqa: T201 - return Exit.ERROR - - try: - validators_config = ValidatorsConfig.model_validate(raw).root - except ValidationError as e: - print(f"Invalid validators configuration: {e}", file=sys.stderr) # noqa: T201 - return Exit.ERROR + validators_config, error = _load_validators_config(config.validators) + if error: + print(error, file=sys.stderr) # noqa: T201 + return Exit.ERROR if config.check: config_output = cast("Path", config.output) From c30eee36eb1436a58c0996a98ed0afcce79ce93f Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 2 Jan 2026 19:52:49 +0000 Subject: [PATCH 14/17] Remove inline comment --- src/datamodel_code_generator/model/pydantic_v2/base_model.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/datamodel_code_generator/model/pydantic_v2/base_model.py b/src/datamodel_code_generator/model/pydantic_v2/base_model.py index 00dd05197..9b2b545cc 100644 --- a/src/datamodel_code_generator/model/pydantic_v2/base_model.py +++ b/src/datamodel_code_generator/model/pydantic_v2/base_model.py @@ -371,7 +371,6 @@ def _process_validators(self) -> None: fields_str = ", ".join(f"'{f}'" for f in fields) - # Generate unique method name using ModelResolver base_method_name = f"{function_name}_validator" method_name = scoped_resolver.add([base_method_name], base_method_name, unique=True, class_name=True).name From d9e776f1464efaacaa6bbfaa0a3b56066ce94d41 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 2 Jan 2026 20:01:23 +0000 Subject: [PATCH 15/17] Add tests for validators edge cases and improve coverage --- .../model/pydantic_v2/base_model.py | 5 +- .../jsonschema/field_validators_wrap_mode.py | 29 +++++++ tests/main/jsonschema/test_main_jsonschema.py | 87 +++++++++++++++++++ 3 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 tests/data/expected/main/jsonschema/field_validators_wrap_mode.py diff --git a/src/datamodel_code_generator/model/pydantic_v2/base_model.py b/src/datamodel_code_generator/model/pydantic_v2/base_model.py index 9b2b545cc..6ad4e3da3 100644 --- a/src/datamodel_code_generator/model/pydantic_v2/base_model.py +++ b/src/datamodel_code_generator/model/pydantic_v2/base_model.py @@ -362,10 +362,7 @@ def _process_validators(self) -> None: if not fields: continue - function_path = validator.get("function") - if not function_path: - continue - + function_path: str = validator["function"] function_name = function_path.rsplit(".", 1)[-1] mode = validator.get("mode", "after") diff --git a/tests/data/expected/main/jsonschema/field_validators_wrap_mode.py b/tests/data/expected/main/jsonschema/field_validators_wrap_mode.py new file mode 100644 index 000000000..2715afa7e --- /dev/null +++ b/tests/data/expected/main/jsonschema/field_validators_wrap_mode.py @@ -0,0 +1,29 @@ +# generated by datamodel-codegen: +# filename: field_validators.json + +from __future__ import annotations + +from typing import Any + +from myapp.validators import validate_name_wrap +from pydantic import ( + BaseModel, + EmailStr, + ValidationInfo, + ValidatorFunctionWrapHandler, + conint, + field_validator, +) + + +class User(BaseModel): + name: str + email: EmailStr + age: conint(ge=0) | None = None + + @field_validator('name', mode='wrap') + @classmethod + def validate_name_wrap_validator( + cls, v: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo + ) -> Any: + return validate_name_wrap(v, handler, info) diff --git a/tests/main/jsonschema/test_main_jsonschema.py b/tests/main/jsonschema/test_main_jsonschema.py index f43fe51be..18abc6e43 100644 --- a/tests/main/jsonschema/test_main_jsonschema.py +++ b/tests/main/jsonschema/test_main_jsonschema.py @@ -43,6 +43,7 @@ from tests.main.jsonschema.conftest import EXPECTED_JSON_SCHEMA_PATH, assert_file_content PYDANTIC_V2_SKIP = pytest.mark.skipif(not is_pydantic_v2(), reason="Pydantic v2 required") +PYDANTIC_V1_ONLY = pytest.mark.skipif(is_pydantic_v2(), reason="Pydantic v1 only") if TYPE_CHECKING: from pytest_mock import MockerFixture @@ -7716,6 +7717,72 @@ def test_field_validators_multi_fields(output_file: Path) -> None: ) +@PYDANTIC_V2_SKIP +def test_field_validators_wrap_mode(output_file: Path, tmp_path: Path) -> None: + """Test validators with wrap mode.""" + config_file = tmp_path / "wrap_mode_config.json" + config_file.write_text( + """{ + "User": { + "validators": [ + {"field": "name", "function": "myapp.validators.validate_name_wrap", "mode": "wrap"} + ] + } + }""" + ) + + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "field_validators.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="field_validators_wrap_mode.py", + extra_args=[ + "--validators", + str(config_file), + "--output-model-type", + "pydantic_v2.BaseModel", + "--disable-timestamp", + ], + skip_code_validation=True, + ) + + +@PYDANTIC_V2_SKIP +def test_field_validators_with_no_field_skipped(output_file: Path, tmp_path: Path) -> None: + """Test that validators without fields are skipped gracefully.""" + config_file = tmp_path / "no_field_validators_config.json" + config_file.write_text( + """{ + "User": { + "validators": [ + {"function": "myapp.validators.validate_something"}, + {"field": "name", "function": "myapp.validators.validate_name"} + ] + } + }""" + ) + + result = run_main_with_args([ + "--input", + str(JSON_SCHEMA_DATA_PATH / "field_validators.json"), + "--output", + str(output_file), + "--input-file-type", + "jsonschema", + "--validators", + str(config_file), + "--output-model-type", + "pydantic_v2.BaseModel", + "--disable-timestamp", + ]) + + assert result == Exit.OK + content = output_file.read_text(encoding="utf-8") + assert "validate_name_validator" in content + assert "validate_something" not in content + + @PYDANTIC_V2_SKIP def test_validators_invalid_json(output_file: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: """Test error handling for invalid validators JSON file.""" @@ -7758,3 +7825,23 @@ def test_validators_invalid_structure(output_file: Path, tmp_path: Path, capsys: capsys=capsys, expected_stderr_contains="Invalid validators configuration", ) + + +@PYDANTIC_V1_ONLY +def test_validators_requires_pydantic_v2(output_file: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Test that validators option requires Pydantic v2.""" + config_file = tmp_path / "validators.json" + config_file.write_text('{"User": {"validators": []}}') + + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "field_validators.json", + output_path=output_file, + input_file_type="jsonschema", + expected_exit=Exit.ERROR, + extra_args=[ + "--validators", + str(config_file), + ], + capsys=capsys, + expected_stderr_contains="--validators option requires Pydantic v2", + ) From db5828084edf2bbc85862b55dbbb19b0985c2635 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 2 Jan 2026 20:13:05 +0000 Subject: [PATCH 16/17] Fix PR review comments: add import to docs and use model_dump helper --- docs/validators.md | 2 ++ src/datamodel_code_generator/parser/base.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/validators.md b/docs/validators.md index 74097e473..74594b210 100644 --- a/docs/validators.md +++ b/docs/validators.md @@ -61,6 +61,8 @@ def validate_name(v: Any, info: ValidationInfo) -> Any: Wrap validators receive a handler to call the next validator in the chain: ```python +from pydantic import ValidationInfo, ValidatorFunctionWrapHandler + def wrap_validate_name( v: Any, handler: ValidatorFunctionWrapHandler, diff --git a/src/datamodel_code_generator/parser/base.py b/src/datamodel_code_generator/parser/base.py index edf47605f..e34a66bce 100644 --- a/src/datamodel_code_generator/parser/base.py +++ b/src/datamodel_code_generator/parser/base.py @@ -841,7 +841,7 @@ def __init__( # noqa: PLR0912, PLR0915 if self.validators: for model_name, model_config in self.validators.items(): self.extra_template_data[model_name]["validators"] = [ - v.model_dump(mode="json") for v in model_config.validators + model_dump(v, mode="json") for v in model_config.validators ] self.use_generic_base_class: bool = config.use_generic_base_class From 233159c4fe55ed4b1ac5399cace98162270ebb2a Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 2 Jan 2026 20:25:38 +0000 Subject: [PATCH 17/17] Add tests for plain mode and all validators skipped branches --- .../jsonschema/field_validators_plain_mode.py | 20 ++++++ tests/main/jsonschema/test_main_jsonschema.py | 65 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 tests/data/expected/main/jsonschema/field_validators_plain_mode.py diff --git a/tests/data/expected/main/jsonschema/field_validators_plain_mode.py b/tests/data/expected/main/jsonschema/field_validators_plain_mode.py new file mode 100644 index 000000000..bfc80d1c1 --- /dev/null +++ b/tests/data/expected/main/jsonschema/field_validators_plain_mode.py @@ -0,0 +1,20 @@ +# generated by datamodel-codegen: +# filename: field_validators.json + +from __future__ import annotations + +from typing import Any + +from myapp.validators import validate_name_plain +from pydantic import BaseModel, EmailStr, conint, field_validator + + +class User(BaseModel): + name: str + email: EmailStr + age: conint(ge=0) | None = None + + @field_validator('name', mode='plain') + @classmethod + def validate_name_plain_validator(cls, v: Any) -> Any: + return validate_name_plain(v) diff --git a/tests/main/jsonschema/test_main_jsonschema.py b/tests/main/jsonschema/test_main_jsonschema.py index 18abc6e43..14daf87b2 100644 --- a/tests/main/jsonschema/test_main_jsonschema.py +++ b/tests/main/jsonschema/test_main_jsonschema.py @@ -7783,6 +7783,71 @@ def test_field_validators_with_no_field_skipped(output_file: Path, tmp_path: Pat assert "validate_something" not in content +@PYDANTIC_V2_SKIP +def test_field_validators_plain_mode(output_file: Path, tmp_path: Path) -> None: + """Test validators with plain mode (no ValidationInfo import).""" + config_file = tmp_path / "plain_mode_config.json" + config_file.write_text( + """{ + "User": { + "validators": [ + {"field": "name", "function": "myapp.validators.validate_name_plain", "mode": "plain"} + ] + } + }""" + ) + + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "field_validators.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="field_validators_plain_mode.py", + extra_args=[ + "--validators", + str(config_file), + "--output-model-type", + "pydantic_v2.BaseModel", + "--disable-timestamp", + ], + skip_code_validation=True, + ) + + +@PYDANTIC_V2_SKIP +def test_field_validators_all_skipped(output_file: Path, tmp_path: Path) -> None: + """Test that when all validators have no fields, output has no validators.""" + config_file = tmp_path / "all_skipped_config.json" + config_file.write_text( + """{ + "User": { + "validators": [ + {"function": "myapp.validators.validate_something"} + ] + } + }""" + ) + + result = run_main_with_args([ + "--input", + str(JSON_SCHEMA_DATA_PATH / "field_validators.json"), + "--output", + str(output_file), + "--input-file-type", + "jsonschema", + "--validators", + str(config_file), + "--output-model-type", + "pydantic_v2.BaseModel", + "--disable-timestamp", + ]) + + assert result == Exit.OK + content = output_file.read_text(encoding="utf-8") + assert "@field_validator" not in content + assert "validate_something" not in content + + @PYDANTIC_V2_SKIP def test_validators_invalid_json(output_file: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: """Test error handling for invalid validators JSON file."""