diff --git a/pyproject.toml b/pyproject.toml index 22eb1b1fb..ba82bb84a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -186,10 +186,12 @@ lint.per-file-ignores."tests/**/*.py" = [ "INP001", # no implicit namespace "PLC0415", # local imports in tests are fine "PLC2701", # private import is fine + "PLR0904", # too many public methods in test classes is fine "PLR0913", # as many arguments as want "PLR0915", # can have longer test methods "PLR0917", # as many arguments as want "PLR2004", # Magic value used in comparison, consider replacing with a constant variable + "PLR6301", # test methods don't need to use self "S", # no safety concerns "SLF001", # can test private methods ] diff --git a/src/datamodel_code_generator/model/base.py b/src/datamodel_code_generator/model/base.py index c70fbef13..9f3dbde0f 100644 --- a/src/datamodel_code_generator/model/base.py +++ b/src/datamodel_code_generator/model/base.py @@ -220,6 +220,15 @@ def _build_union_type_hint(self) -> str | None: return f"Union[{', '.join(parts)}]" return None # pragma: no cover + def _build_base_union_type_hint(self) -> str | None: # pragma: no cover + """Build Union[] base type hint from data_type.data_types if forward reference requires it.""" + if not (self._use_union_operator != self.data_type.use_union_operator and self.data_type.is_union): + return None + parts = [dt.base_type_hint for dt in self.data_type.data_types if dt.base_type_hint] + if len(parts) > 1: + return f"Union[{', '.join(parts)}]" + return None + @property def type_hint(self) -> str: # noqa: PLR0911 """Get the type hint string for this field, including nullability.""" @@ -241,6 +250,33 @@ def type_hint(self) -> str: # noqa: PLR0911 return get_optional_type(type_hint, self._use_union_operator) return type_hint + @property + def base_type_hint(self) -> str: + """Get the base type hint without constrained type kwargs. + + This returns the type without kwargs (e.g., 'str' instead of 'constr(pattern=...)'). + Used in RootModel generics when regex_engine config is needed for lookaround patterns. + """ + base_hint = self._build_base_union_type_hint() or self.data_type.base_type_hint + + if not base_hint: # pragma: no cover + return NONE + + needs_optional = ( + (self.nullable is True) + or (self.required and self.type_has_null) + or (self.nullable is None and not self.required and self.fall_back_to_nullable) + ) + skip_optional = ( + self.has_default_factory + or (self.data_type.is_optional and self.data_type.type != ANY) + or (self.nullable is False) + ) + + if needs_optional and not skip_optional: # pragma: no cover + return get_optional_type(base_hint, self._use_union_operator) + return base_hint + @property def imports(self) -> tuple[Import, ...]: """Get all imports required for this field's type hint.""" 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 712ec5da0..9da6698dd 100644 --- a/src/datamodel_code_generator/model/pydantic_v2/base_model.py +++ b/src/datamodel_code_generator/model/pydantic_v2/base_model.py @@ -228,15 +228,8 @@ def __init__( # noqa: PLR0913 config_parameters["arbitrary_types_allowed"] = True break - for field in self.fields: - # Check if a regex pattern uses lookarounds. - # Depending on the generation configuration, the pattern may end up in two different places. - pattern = (isinstance(field.constraints, Constraints) and field.constraints.pattern) or ( - field.data_type.kwargs or {} - ).get("pattern") - if pattern and re.search(r"\(\? Literal["'allow'", "'forbid'", "'ignore'"] | None elif additional_properties is False: config_extra = "'forbid'" return config_extra + + def _has_lookaround_pattern(self) -> bool: + """Check if any field has a regex pattern with lookaround assertions.""" + lookaround_regex = re.compile(r"\(\? str: @property def all_data_types(self) -> Iterator[DataType]: - """Recursively yield all nested DataTypes including self.""" + """Recursively yield all nested DataTypes including self and dict_key.""" for data_type in self.data_types: yield from data_type.all_data_types + if self.dict_key: + yield from self.dict_key.all_data_types yield self def find_source(self, source_type: type[SourceT]) -> SourceT | None: @@ -618,6 +620,124 @@ def is_union(self) -> bool: """Return whether this DataType represents a union of multiple types.""" return len(self.data_types) > 1 + # Mapping from constrained type functions to their base Python types. + # Only constr is included because it's the only type with a 'pattern' parameter + # that can trigger lookaround regex detection. Other constrained types (conint, + # confloat, condecimal, conbytes) don't have pattern constraints, so they will + # never need base_type_hint conversion in the regex_engine context. + _CONSTRAINED_TYPE_TO_BASE: ClassVar[dict[str, str]] = { + "constr": "str", + } + + @property + def base_type_hint(self) -> str: # noqa: PLR0912, PLR0915 + """Return the base type hint without constrained type kwargs. + + For types like constr(pattern=..., min_length=...), this returns just 'str'. + This works recursively for nested types like list[constr(pattern=...)] -> list[str]. + + This is useful when the pattern contains lookaround assertions that require + regex_engine="python-re", which must be set in model_config. In such cases, + the RootModel generic cannot use the constrained type because it would be + evaluated at class definition time before model_config is processed. + """ + if self.is_func and self.kwargs: + type_: str | None = self.alias or self.type + if type_: # pragma: no branch + base_type = self._CONSTRAINED_TYPE_TO_BASE.get(type_) + if base_type is None: + # Not a constrained type we convert (e.g., conint, confloat) + # Return the full type_hint with kwargs to avoid returning bare function name + return self.type_hint + if self.is_optional and base_type != ANY: # pragma: no cover + return get_optional_type(base_type, self.use_union_operator) + return base_type + + type_: str | None = self.alias or self.type + if not type_: + if self.is_tuple: # pragma: no cover + tuple_type = STANDARD_TUPLE if self.use_standard_collections else TUPLE + inner_types = [item.base_type_hint or ANY for item in self.data_types] + type_ = f"{tuple_type}[{', '.join(inner_types)}]" if inner_types else f"{tuple_type}[()]" + elif self.is_union: + data_types: list[str] = [] + for data_type in self.data_types: + data_type_type = data_type.base_type_hint + if not data_type_type or data_type_type in data_types: # pragma: no cover + continue + + if data_type_type == NONE: + self.is_optional = True + continue + + non_optional_data_type_type = _remove_none_from_union( + data_type_type, use_union_operator=self.use_union_operator + ) + + if non_optional_data_type_type != data_type_type: # pragma: no cover + self.is_optional = True + + data_types.append(non_optional_data_type_type) + if not data_types: # pragma: no cover + type_ = ANY + self.import_ = self.import_ or IMPORT_ANY + elif len(data_types) == 1: + type_ = data_types[0] + elif self.use_union_operator: + type_ = UNION_OPERATOR_DELIMITER.join(data_types) + else: # pragma: no cover + type_ = f"{UNION_PREFIX}{UNION_DELIMITER.join(data_types)}]" + elif len(self.data_types) == 1: + type_ = self.data_types[0].base_type_hint + elif self.enum_member_literals: # pragma: no cover + parts = [f"{enum_class}.{member}" for enum_class, member in self.enum_member_literals] + type_ = f"{LITERAL}[{', '.join(parts)}]" + elif self.literals: # pragma: no cover + type_ = f"{LITERAL}[{', '.join(repr(literal) for literal in self.literals)}]" + elif self.reference: # pragma: no cover + type_ = self.reference.short_name + type_ = self._get_wrapped_reference_type_hint(type_) + else: # pragma: no cover + type_ = "" + if self.reference: # pragma: no cover + source = self.reference.source + if isinstance(source, Nullable) and source.nullable: + self.is_optional = True + if self.is_list: + if self.use_generic_container: + list_ = SEQUENCE + elif self.use_standard_collections: + list_ = STANDARD_LIST + else: # pragma: no cover + list_ = LIST + type_ = f"{list_}[{type_}]" if type_ else list_ + elif self.is_set: # pragma: no cover + if self.use_generic_container: + set_ = STANDARD_FROZEN_SET if self.use_standard_collections else FROZEN_SET + elif self.use_standard_collections: + set_ = STANDARD_SET + else: + set_ = SET + type_ = f"{set_}[{type_}]" if type_ else set_ + elif self.is_dict: + if self.use_generic_container: + dict_ = MAPPING + elif self.use_standard_collections: + dict_ = STANDARD_DICT + else: # pragma: no cover + dict_ = DICT + if self.dict_key or type_: + key = self.dict_key.base_type_hint if self.dict_key else STR + type_ = f"{dict_}[{key}, {type_ or ANY}]" + else: # pragma: no cover + type_ = dict_ + + if self.is_optional and type_ != ANY: + return get_optional_type(type_, self.use_union_operator) + if self.is_func: # pragma: no cover + return f"{type_}()" + return type_ + DataTypeT = TypeVar("DataTypeT", bound=DataType) diff --git a/tests/cli_doc/test_cli_doc_coverage.py b/tests/cli_doc/test_cli_doc_coverage.py index cca7425ca..4ea65741e 100644 --- a/tests/cli_doc/test_cli_doc_coverage.py +++ b/tests/cli_doc/test_cli_doc_coverage.py @@ -46,9 +46,7 @@ def collected_options(collection_data: dict[str, Any]) -> set[str]: # pragma: n class TestCLIDocCoverage: # pragma: no cover """Documentation coverage tests.""" - def test_all_options_have_cli_doc_markers( # noqa: PLR6301 - self, collected_options: set[str] - ) -> None: + def test_all_options_have_cli_doc_markers(self, collected_options: set[str]) -> None: """Verify that all CLI options (except MANUAL_DOCS) have cli_doc markers.""" all_options = get_all_canonical_options() documentable_options = all_options - MANUAL_DOCS @@ -60,7 +58,7 @@ def test_all_options_have_cli_doc_markers( # noqa: PLR6301 + "\n\nAdd @pytest.mark.cli_doc(...) to tests for these options." ) - def test_meta_options_not_manual(self) -> None: # noqa: PLR6301 + def test_meta_options_not_manual(self) -> None: """Verify that CLI_OPTION_META options are not in MANUAL_DOCS.""" meta_options = set(CLI_OPTION_META.keys()) overlap = meta_options & MANUAL_DOCS @@ -70,9 +68,7 @@ def test_meta_options_not_manual(self) -> None: # noqa: PLR6301 + "\n".join(f" - {opt}" for opt in sorted(overlap)) ) - def test_collection_schema_version( # noqa: PLR6301 - self, collection_data: dict[str, Any] - ) -> None: + def test_collection_schema_version(self, collection_data: dict[str, Any]) -> None: """Verify that collection data has expected schema version.""" version = collection_data.get("schema_version") assert version is not None, "Collection data missing 'schema_version'" @@ -83,7 +79,7 @@ class TestCoverageStats: # pragma: no cover """Informational tests for coverage statistics.""" @pytest.mark.skip(reason="Informational: run with -v --no-skip to see stats") - def test_show_coverage_stats(self, collected_options: set[str]) -> None: # noqa: PLR6301 + def test_show_coverage_stats(self, collected_options: set[str]) -> None: """Display documentation coverage statistics.""" all_options = get_all_canonical_options() documentable = all_options - MANUAL_DOCS @@ -94,9 +90,7 @@ def test_show_coverage_stats(self, collected_options: set[str]) -> None: # noqa print(f" {opt}") # noqa: T201 @pytest.mark.skip(reason="Informational: run with -v --no-skip to see stats") - def test_show_documented_options( # noqa: PLR6301 - self, collected_options: set[str] - ) -> None: + def test_show_documented_options(self, collected_options: set[str]) -> None: """Display currently documented options.""" print(f"\nDocumented options ({len(collected_options)}):") # noqa: T201 for opt in sorted(collected_options): diff --git a/tests/cli_doc/test_cli_options_sync.py b/tests/cli_doc/test_cli_options_sync.py index b5e3a791c..7c6f0abd6 100644 --- a/tests/cli_doc/test_cli_options_sync.py +++ b/tests/cli_doc/test_cli_options_sync.py @@ -31,7 +31,7 @@ def test_get_canonical_option() -> None: class TestCLIOptionMetaSync: # pragma: no cover """Synchronization tests for CLI_OPTION_META.""" - def test_all_registered_options_exist_in_argparse(self) -> None: # noqa: PLR6301 + def test_all_registered_options_exist_in_argparse(self) -> None: """Verify that all options in CLI_OPTION_META exist in argparse.""" argparse_options = get_all_canonical_options() registered = set(CLI_OPTION_META.keys()) @@ -44,7 +44,7 @@ def test_all_registered_options_exist_in_argparse(self) -> None: # noqa: PLR630 + "\n\nRemove them from CLI_OPTION_META or add them to arguments.py." ) - def test_manual_doc_options_exist_in_argparse(self) -> None: # noqa: PLR6301 + def test_manual_doc_options_exist_in_argparse(self) -> None: """Verify that all options in MANUAL_DOCS exist in argparse.""" argparse_options = get_all_canonical_options() @@ -56,7 +56,7 @@ def test_manual_doc_options_exist_in_argparse(self) -> None: # noqa: PLR6301 + "\n\nRemove them from MANUAL_DOCS or add them to arguments.py." ) - def test_no_overlap_between_meta_and_manual(self) -> None: # noqa: PLR6301 + def test_no_overlap_between_meta_and_manual(self) -> None: """Verify that CLI_OPTION_META and MANUAL_DOCS don't overlap.""" overlap = set(CLI_OPTION_META.keys()) & MANUAL_DOCS if overlap: @@ -66,7 +66,7 @@ def test_no_overlap_between_meta_and_manual(self) -> None: # noqa: PLR6301 + "\n\nAn option should be in one or the other, not both." ) - def test_meta_names_match_keys(self) -> None: # noqa: PLR6301 + def test_meta_names_match_keys(self) -> None: """Verify that CLIOptionMeta.name matches the dict key.""" mismatches = [] for key, meta in CLI_OPTION_META.items(): @@ -76,7 +76,7 @@ def test_meta_names_match_keys(self) -> None: # noqa: PLR6301 if mismatches: pytest.fail("CLIOptionMeta.name mismatches:\n" + "\n".join(mismatches)) - def test_all_argparse_options_are_documented_or_excluded(self) -> None: # noqa: PLR6301 + def test_all_argparse_options_are_documented_or_excluded(self) -> None: """Verify that all argparse options are either documented or explicitly excluded. This test fails when a new CLI option is added to arguments.py @@ -96,7 +96,7 @@ def test_all_argparse_options_are_documented_or_excluded(self) -> None: # noqa: "or add to MANUAL_DOCS if they should have manual documentation." ) - def test_canonical_option_determination_is_stable(self) -> None: # noqa: PLR6301 + def test_canonical_option_determination_is_stable(self) -> None: """Verify that canonical option determination is deterministic. The canonical option should be the longest option string for each action. diff --git a/tests/data/expected/main/jsonschema/allof_root_model_constraints_merge_pydantic_v2.py b/tests/data/expected/main/jsonschema/allof_root_model_constraints_merge_pydantic_v2.py new file mode 100644 index 000000000..1dcbefa15 --- /dev/null +++ b/tests/data/expected/main/jsonschema/allof_root_model_constraints_merge_pydantic_v2.py @@ -0,0 +1,161 @@ +# generated by datamodel-codegen: +# filename: allof_root_model_constraints.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, EmailStr, Field, RootModel, conint, constr + + +class StringDatatype(RootModel[constr(pattern=r'^\S(.*\S)?$')]): + root: constr(pattern=r'^\S(.*\S)?$') = Field(..., description='A base string type.') + + +class ConstrainedStringDatatype(RootModel[str]): + model_config = ConfigDict( + regex_engine="python-re", + ) + root: constr(pattern=r'(?=^\S(.*\S)?$)(?=^[A-Z].*)', min_length=1) = Field( + ..., description='A constrained string.' + ) + + +class IntegerDatatype(RootModel[int]): + root: int = Field(..., description='A whole number.') + + +class NonNegativeIntegerDatatype(RootModel[conint(ge=0)]): + root: conint(ge=0) = Field(..., description='Non-negative integer.') + + +class BoundedIntegerDatatype(RootModel[conint(ge=0, le=100)]): + root: conint(ge=0, le=100) = Field(..., description='Integer between 0 and 100.') + + +class EmailDatatype(RootModel[EmailStr]): + root: EmailStr = Field(..., description='Email with format.') + + +class FormattedStringDatatype(RootModel[EmailStr]): + root: EmailStr = Field(..., description='A string with email format.') + + +class ObjectBase(BaseModel): + id: int | None = None + + +class ObjectWithAllOf(ObjectBase): + name: str | None = None + + +class MultiRefAllOf(BaseModel): + pass + + +class NoConstraintAllOf(BaseModel): + pass + + +class IncompatibleTypeAllOf(BaseModel): + pass + + +class ConstraintWithProperties(BaseModel): + extra: str | None = None + + +class ConstraintWithItems(BaseModel): + pass + + +class NumberIntegerCompatible(RootModel[conint(ge=0)]): + root: conint(ge=0) = Field(..., description='Number and integer are compatible.') + + +class RefWithSchemaKeywords( + RootModel[constr(pattern=r'^\S(.*\S)?$', min_length=5, max_length=100)] +): + root: constr(pattern=r'^\S(.*\S)?$', min_length=5, max_length=100) = Field( + ..., description='Ref with additional schema keywords.' + ) + + +class ArrayDatatype(RootModel[list[str]]): + root: list[str] + + +class RefToArrayAllOf(BaseModel): + pass + + +class ObjectNoPropsDatatype(BaseModel): + pass + + +RefToObjectNoPropsAllOf = ObjectNoPropsDatatype + + +class PatternPropsDatatype(RootModel[dict[constr(pattern=r'^S_'), str]]): + root: dict[constr(pattern=r'^S_'), str] + + +class RefToPatternPropsAllOf(BaseModel): + pass + + +class NestedAllOfDatatype(BaseModel): + pass + + +RefToNestedAllOfAllOf = NestedAllOfDatatype + + +class ConstraintsOnlyDatatype(RootModel[Any]): + root: Any = Field(..., description='Constraints only, no type.') + + +class RefToConstraintsOnlyAllOf(RootModel[Any]): + root: Any = Field(..., description='Ref to constraints-only schema.') + + +class NoDescriptionAllOf(RootModel[constr(pattern=r'^\S(.*\S)?$', min_length=5)]): + root: constr(pattern=r'^\S(.*\S)?$', min_length=5) = Field( + ..., description='A base string type.' + ) + + +class EmptyConstraintItemAllOf( + RootModel[constr(pattern=r'^\S(.*\S)?$', max_length=50)] +): + root: constr(pattern=r'^\S(.*\S)?$', max_length=50) = Field( + ..., description='AllOf with empty constraint item.' + ) + + +class ConflictingFormatAllOf(BaseModel): + pass + + +class Model(BaseModel): + name: ConstrainedStringDatatype | None = None + count: NonNegativeIntegerDatatype | None = None + percentage: BoundedIntegerDatatype | None = None + email: EmailDatatype | None = None + obj: ObjectWithAllOf | None = None + multi: MultiRefAllOf | None = None + noconstraint: NoConstraintAllOf | None = None + incompatible: IncompatibleTypeAllOf | None = None + withprops: ConstraintWithProperties | None = None + withitems: ConstraintWithItems | None = None + numint: NumberIntegerCompatible | None = None + refwithkw: RefWithSchemaKeywords | None = None + refarr: RefToArrayAllOf | None = None + refobjnoprops: RefToObjectNoPropsAllOf | None = None + refpatternprops: RefToPatternPropsAllOf | None = None + refnestedallof: RefToNestedAllOfAllOf | None = None + refconstraintsonly: RefToConstraintsOnlyAllOf | None = None + nodescription: NoDescriptionAllOf | None = None + emptyconstraint: EmptyConstraintItemAllOf | None = None + conflictingformat: ConflictingFormatAllOf | None = None diff --git a/tests/data/expected/main/jsonschema/lookaround_anyof_nullable_pydantic_v2.py b/tests/data/expected/main/jsonschema/lookaround_anyof_nullable_pydantic_v2.py new file mode 100644 index 000000000..afa140f65 --- /dev/null +++ b/tests/data/expected/main/jsonschema/lookaround_anyof_nullable_pydantic_v2.py @@ -0,0 +1,20 @@ +# generated by datamodel-codegen: +# filename: lookaround_anyof_nullable.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field, RootModel, constr + + +class LookaroundNullableString(RootModel[str | None]): + model_config = ConfigDict( + regex_engine="python-re", + ) + root: constr(pattern=r'^(?=.*[A-Z]).+$') | None = Field( + ..., description='Nullable string with lookaround pattern.' + ) + + +class Model(BaseModel): + value: LookaroundNullableString | None = None diff --git a/tests/data/expected/main/jsonschema/lookaround_dict_generic_container.py b/tests/data/expected/main/jsonschema/lookaround_dict_generic_container.py new file mode 100644 index 000000000..bc8652e3e --- /dev/null +++ b/tests/data/expected/main/jsonschema/lookaround_dict_generic_container.py @@ -0,0 +1,20 @@ +# generated by datamodel-codegen: +# filename: lookaround_dict.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from collections.abc import Mapping + +from pydantic import BaseModel, ConfigDict, RootModel, constr + + +class LookaroundDict(RootModel[Mapping[str, str]]): + model_config = ConfigDict( + regex_engine="python-re", + ) + root: Mapping[str, constr(pattern=r'^(?=.*[A-Z]).+$')] + + +class Model(BaseModel): + data: LookaroundDict | None = None diff --git a/tests/data/expected/main/jsonschema/lookaround_dict_key_pydantic_v2.py b/tests/data/expected/main/jsonschema/lookaround_dict_key_pydantic_v2.py new file mode 100644 index 000000000..79748a337 --- /dev/null +++ b/tests/data/expected/main/jsonschema/lookaround_dict_key_pydantic_v2.py @@ -0,0 +1,21 @@ +# generated by datamodel-codegen: +# filename: lookaround_dict_key.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field, RootModel, constr + + +class LookaroundKeyDict(RootModel[dict[str, str]]): + model_config = ConfigDict( + regex_engine="python-re", + ) + root: dict[constr(pattern=r'^(?=.*[A-Z]).+$'), str] = Field( + ..., + description='Dict with lookaround pattern on key constraint via patternProperties.', + ) + + +class Model(BaseModel): + data: LookaroundKeyDict | None = None diff --git a/tests/data/expected/main/jsonschema/lookaround_dict_pydantic_v2.py b/tests/data/expected/main/jsonschema/lookaround_dict_pydantic_v2.py new file mode 100644 index 000000000..8ddbf767c --- /dev/null +++ b/tests/data/expected/main/jsonschema/lookaround_dict_pydantic_v2.py @@ -0,0 +1,18 @@ +# generated by datamodel-codegen: +# filename: lookaround_dict.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, RootModel, constr + + +class LookaroundDict(RootModel[dict[str, str]]): + model_config = ConfigDict( + regex_engine="python-re", + ) + root: dict[str, constr(pattern=r'^(?=.*[A-Z]).+$')] + + +class Model(BaseModel): + data: LookaroundDict | None = None diff --git a/tests/data/expected/main/jsonschema/lookaround_dict_standard_collections.py b/tests/data/expected/main/jsonschema/lookaround_dict_standard_collections.py new file mode 100644 index 000000000..8ddbf767c --- /dev/null +++ b/tests/data/expected/main/jsonschema/lookaround_dict_standard_collections.py @@ -0,0 +1,18 @@ +# generated by datamodel-codegen: +# filename: lookaround_dict.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, RootModel, constr + + +class LookaroundDict(RootModel[dict[str, str]]): + model_config = ConfigDict( + regex_engine="python-re", + ) + root: dict[str, constr(pattern=r'^(?=.*[A-Z]).+$')] + + +class Model(BaseModel): + data: LookaroundDict | None = None diff --git a/tests/data/expected/main/jsonschema/lookaround_mixed_constraints_pydantic_v2.py b/tests/data/expected/main/jsonschema/lookaround_mixed_constraints_pydantic_v2.py new file mode 100644 index 000000000..240f149c0 --- /dev/null +++ b/tests/data/expected/main/jsonschema/lookaround_mixed_constraints_pydantic_v2.py @@ -0,0 +1,20 @@ +# generated by datamodel-codegen: +# filename: lookaround_mixed_constraints.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import ConfigDict, Field, RootModel, conint, constr + + +class LookaroundMixedConstraints(RootModel[str | conint(ge=0, le=100)]): + model_config = ConfigDict( + regex_engine="python-re", + ) + root: constr(pattern=r'^(?=.*[A-Z]).+$', min_length=1) | conint(ge=0, le=100) = ( + Field( + ..., + description='RootModel with union of constr (lookahead) and conint to test base_type_hint fallback.', + title='LookaroundMixedConstraints', + ) + ) diff --git a/tests/data/expected/main/jsonschema/lookaround_union_types_pydantic_v2.py b/tests/data/expected/main/jsonschema/lookaround_union_types_pydantic_v2.py new file mode 100644 index 000000000..d820d451c --- /dev/null +++ b/tests/data/expected/main/jsonschema/lookaround_union_types_pydantic_v2.py @@ -0,0 +1,20 @@ +# generated by datamodel-codegen: +# filename: lookaround_union_types.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field, RootModel, constr + + +class LookaroundUnion(RootModel[str | int]): + model_config = ConfigDict( + regex_engine="python-re", + ) + root: constr(pattern=r'^(?=.*[A-Z]).+$') | int = Field( + ..., description='Union with lookaround pattern.' + ) + + +class Model(BaseModel): + value: LookaroundUnion | None = None diff --git a/tests/data/expected/main/jsonschema/nested_lookaround_array_generic_container.py b/tests/data/expected/main/jsonschema/nested_lookaround_array_generic_container.py new file mode 100644 index 000000000..915ddaad4 --- /dev/null +++ b/tests/data/expected/main/jsonschema/nested_lookaround_array_generic_container.py @@ -0,0 +1,22 @@ +# generated by datamodel-codegen: +# filename: nested_lookaround_array.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from collections.abc import Sequence + +from pydantic import BaseModel, ConfigDict, Field, RootModel, constr + + +class LookaroundStringArray(RootModel[Sequence[str]]): + model_config = ConfigDict( + regex_engine="python-re", + ) + root: Sequence[constr(pattern=r'^(?=.*[A-Z])(?=.*[0-9]).+$', min_length=1)] = Field( + ..., description='Array of strings with lookaround pattern applied to items.' + ) + + +class Model(BaseModel): + codes: LookaroundStringArray | None = None diff --git a/tests/data/expected/main/jsonschema/nested_lookaround_array_pydantic_v2.py b/tests/data/expected/main/jsonschema/nested_lookaround_array_pydantic_v2.py new file mode 100644 index 000000000..bc45c4b2d --- /dev/null +++ b/tests/data/expected/main/jsonschema/nested_lookaround_array_pydantic_v2.py @@ -0,0 +1,20 @@ +# generated by datamodel-codegen: +# filename: nested_lookaround_array.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field, RootModel, constr + + +class LookaroundStringArray(RootModel[list[str]]): + model_config = ConfigDict( + regex_engine="python-re", + ) + root: list[constr(pattern=r'^(?=.*[A-Z])(?=.*[0-9]).+$', min_length=1)] = Field( + ..., description='Array of strings with lookaround pattern applied to items.' + ) + + +class Model(BaseModel): + codes: LookaroundStringArray | None = None diff --git a/tests/data/expected/main/jsonschema/nested_lookaround_array_standard_collections.py b/tests/data/expected/main/jsonschema/nested_lookaround_array_standard_collections.py new file mode 100644 index 000000000..bc45c4b2d --- /dev/null +++ b/tests/data/expected/main/jsonschema/nested_lookaround_array_standard_collections.py @@ -0,0 +1,20 @@ +# generated by datamodel-codegen: +# filename: nested_lookaround_array.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field, RootModel, constr + + +class LookaroundStringArray(RootModel[list[str]]): + model_config = ConfigDict( + regex_engine="python-re", + ) + root: list[constr(pattern=r'^(?=.*[A-Z])(?=.*[0-9]).+$', min_length=1)] = Field( + ..., description='Array of strings with lookaround pattern applied to items.' + ) + + +class Model(BaseModel): + codes: LookaroundStringArray | None = None diff --git a/tests/data/jsonschema/lookaround_anyof_nullable.json b/tests/data/jsonschema/lookaround_anyof_nullable.json new file mode 100644 index 000000000..be0bcc943 --- /dev/null +++ b/tests/data/jsonschema/lookaround_anyof_nullable.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "LookaroundNullableString": { + "description": "Nullable string with lookaround pattern.", + "anyOf": [ + { + "type": "string", + "pattern": "^(?=.*[A-Z]).+$" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "properties": { + "value": { "$ref": "#/definitions/LookaroundNullableString" } + } +} diff --git a/tests/data/jsonschema/lookaround_dict.json b/tests/data/jsonschema/lookaround_dict.json new file mode 100644 index 000000000..e58ee4562 --- /dev/null +++ b/tests/data/jsonschema/lookaround_dict.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "LookaroundDict": { + "description": "Dict with lookaround pattern in values.", + "type": "object", + "additionalProperties": { + "type": "string", + "pattern": "^(?=.*[A-Z]).+$" + } + } + }, + "type": "object", + "properties": { + "data": { "$ref": "#/definitions/LookaroundDict" } + } +} diff --git a/tests/data/jsonschema/lookaround_dict_key.json b/tests/data/jsonschema/lookaround_dict_key.json new file mode 100644 index 000000000..bacc6a027 --- /dev/null +++ b/tests/data/jsonschema/lookaround_dict_key.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "LookaroundKeyDict": { + "description": "Dict with lookaround pattern on key constraint via patternProperties.", + "type": "object", + "patternProperties": { + "^(?=.*[A-Z]).+$": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "type": "object", + "properties": { + "data": { "$ref": "#/definitions/LookaroundKeyDict" } + } +} diff --git a/tests/data/jsonschema/lookaround_mixed_constraints.json b/tests/data/jsonschema/lookaround_mixed_constraints.json new file mode 100644 index 000000000..cf1ef7087 --- /dev/null +++ b/tests/data/jsonschema/lookaround_mixed_constraints.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "LookaroundMixedConstraints", + "description": "RootModel with union of constr (lookahead) and conint to test base_type_hint fallback.", + "oneOf": [ + { + "type": "string", + "pattern": "^(?=.*[A-Z]).+$", + "minLength": 1 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 100 + } + ] +} diff --git a/tests/data/jsonschema/lookaround_union_types.json b/tests/data/jsonschema/lookaround_union_types.json new file mode 100644 index 000000000..306038bcf --- /dev/null +++ b/tests/data/jsonschema/lookaround_union_types.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "LookaroundUnion": { + "description": "Union with lookaround pattern.", + "anyOf": [ + { + "type": "string", + "pattern": "^(?=.*[A-Z]).+$" + }, + { + "type": "integer" + } + ] + } + }, + "type": "object", + "properties": { + "value": { "$ref": "#/definitions/LookaroundUnion" } + } +} diff --git a/tests/data/jsonschema/nested_lookaround_array.json b/tests/data/jsonschema/nested_lookaround_array.json new file mode 100644 index 000000000..b32c70721 --- /dev/null +++ b/tests/data/jsonschema/nested_lookaround_array.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "LookaroundStringArray": { + "description": "Array of strings with lookaround pattern applied to items.", + "type": "array", + "items": { + "type": "string", + "pattern": "^(?=.*[A-Z])(?=.*[0-9]).+$", + "minLength": 1 + } + } + }, + "type": "object", + "properties": { + "codes": { "$ref": "#/definitions/LookaroundStringArray" } + } +} diff --git a/tests/main/jsonschema/test_main_jsonschema.py b/tests/main/jsonschema/test_main_jsonschema.py index b998fa554..a56be28f1 100644 --- a/tests/main/jsonschema/test_main_jsonschema.py +++ b/tests/main/jsonschema/test_main_jsonschema.py @@ -4593,6 +4593,202 @@ def test_main_allof_root_model_constraints_none(output_file: Path) -> None: ) +@pytest.mark.benchmark +def test_main_allof_root_model_constraints_merge_pydantic_v2(output_file: Path) -> None: + """Test allOf with root model constraints in Pydantic v2 (issue #2232). + + When merging pattern constraints that use lookaround assertions, + the generated RootModel should use the base type in the generic + (e.g., RootModel[str]) rather than the constrained type + (e.g., RootModel[constr(pattern=...)]) to avoid regex evaluation + before model_config with regex_engine='python-re' is processed. + """ + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "allof_root_model_constraints.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="allof_root_model_constraints_merge_pydantic_v2.py", + extra_args=[ + "--allof-merge-mode", + "constraints", + "--output-model-type", + "pydantic_v2.BaseModel", + ], + ) + + +@pytest.mark.benchmark +def test_main_nested_lookaround_array_pydantic_v2(output_file: Path) -> None: + """Test nested lookaround pattern detection in array items (issue #2232). + + When array items have patterns with lookaround assertions, the lookaround + should be detected in nested types and regex_engine='python-re' should be + added. The RootModel generic should use the base type (list[str]) rather + than the constrained type (list[constr(pattern=...)]). + """ + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "nested_lookaround_array.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="nested_lookaround_array_pydantic_v2.py", + extra_args=[ + "--output-model-type", + "pydantic_v2.BaseModel", + ], + ) + + +@pytest.mark.benchmark +def test_main_lookaround_anyof_nullable_pydantic_v2(output_file: Path) -> None: + """Test lookaround pattern with anyOf null for union/optional path.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "lookaround_anyof_nullable.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="lookaround_anyof_nullable_pydantic_v2.py", + extra_args=[ + "--output-model-type", + "pydantic_v2.BaseModel", + ], + ) + + +@LEGACY_BLACK_SKIP +@pytest.mark.benchmark +def test_main_lookaround_mixed_constraints_pydantic_v2(output_file: Path) -> None: + """Test lookaround pattern with union of constr and conint to test base_type_hint fallback for non-constr types.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "lookaround_mixed_constraints.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="lookaround_mixed_constraints_pydantic_v2.py", + extra_args=[ + "--output-model-type", + "pydantic_v2.BaseModel", + ], + ) + + +@pytest.mark.benchmark +def test_main_lookaround_dict_pydantic_v2(output_file: Path) -> None: + """Test lookaround pattern in dict values for base_type_hint dict path.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "lookaround_dict.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="lookaround_dict_pydantic_v2.py", + extra_args=[ + "--output-model-type", + "pydantic_v2.BaseModel", + ], + ) + + +@pytest.mark.benchmark +def test_main_lookaround_union_types_pydantic_v2(output_file: Path) -> None: + """Test lookaround pattern in union for base_type_hint union path.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "lookaround_union_types.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="lookaround_union_types_pydantic_v2.py", + extra_args=[ + "--output-model-type", + "pydantic_v2.BaseModel", + ], + ) + + +@pytest.mark.benchmark +def test_main_nested_lookaround_array_generic_container(output_file: Path) -> None: + """Test lookaround pattern with --use-generic-container-types for Sequence path.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "nested_lookaround_array.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="nested_lookaround_array_generic_container.py", + extra_args=[ + "--output-model-type", + "pydantic_v2.BaseModel", + "--use-generic-container-types", + ], + ) + + +@pytest.mark.benchmark +def test_main_lookaround_dict_generic_container(output_file: Path) -> None: + """Test lookaround dict pattern with --use-generic-container-types for Mapping path.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "lookaround_dict.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="lookaround_dict_generic_container.py", + extra_args=[ + "--output-model-type", + "pydantic_v2.BaseModel", + "--use-generic-container-types", + ], + ) + + +@pytest.mark.benchmark +def test_main_nested_lookaround_array_standard_collections(output_file: Path) -> None: + """Test lookaround pattern with --use-standard-collections for list path.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "nested_lookaround_array.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="nested_lookaround_array_standard_collections.py", + extra_args=[ + "--output-model-type", + "pydantic_v2.BaseModel", + "--use-standard-collections", + ], + ) + + +@pytest.mark.benchmark +def test_main_lookaround_dict_standard_collections(output_file: Path) -> None: + """Test lookaround dict pattern with --use-standard-collections for dict path.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "lookaround_dict.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="lookaround_dict_standard_collections.py", + extra_args=[ + "--output-model-type", + "pydantic_v2.BaseModel", + "--use-standard-collections", + ], + ) + + +@pytest.mark.benchmark +def test_main_lookaround_dict_key_pydantic_v2(output_file: Path) -> None: + """Test lookaround pattern on dict key for dict_key.all_data_types path.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "lookaround_dict_key.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="lookaround_dict_key_pydantic_v2.py", + extra_args=[ + "--output-model-type", + "pydantic_v2.BaseModel", + ], + ) + + @pytest.mark.benchmark def test_main_nullable_array_items_strict_nullable(output_file: Path) -> None: """Test nullable array items with strict-nullable flag (issue #1815).""" diff --git a/tests/model/test_base.py b/tests/model/test_base.py index 6b98a38c9..f6e0fa4c2 100644 --- a/tests/model/test_base.py +++ b/tests/model/test_base.py @@ -31,7 +31,7 @@ def template_file_path(self) -> Path: """Return the template file path.""" return self._path - def render(self) -> str: # noqa: PLR6301 + def render(self) -> str: """Render the template.""" return "" diff --git a/tests/parser/test_base.py b/tests/parser/test_base.py index 66817aec6..5b8039891 100644 --- a/tests/parser/test_base.py +++ b/tests/parser/test_base.py @@ -39,7 +39,7 @@ class C(Parser): def parse_raw(self, name: str, raw: dict[str, Any]) -> None: """Parse raw data into models.""" - def parse(self) -> str: # noqa: PLR6301 + def parse(self) -> str: """Parse and return results.""" return "parsed"