diff --git a/src/schema2validataclass/_version.py b/src/schema2validataclass/_version.py new file mode 100644 index 0000000..0b25865 --- /dev/null +++ b/src/schema2validataclass/_version.py @@ -0,0 +1,24 @@ +# file generated by vcs-versioning +# don't change, don't track in version control +from __future__ import annotations + +__all__ = [ + '__version__', + '__version_tuple__', + 'version', + 'version_tuple', + '__commit_id__', + 'commit_id', +] + +version: str +__version__: str +__version_tuple__: tuple[int | str, ...] +version_tuple: tuple[int | str, ...] +commit_id: str | None +__commit_id__: str | None + +__version__ = version = '0.0.post8+g5df0c6455.d20260404' +__version_tuple__ = version_tuple = (0, 0, 'post8', 'g5df0c6455.d20260404') + +__commit_id__ = commit_id = 'g5df0c6455' diff --git a/src/schema2validataclass/app.py b/src/schema2validataclass/app.py index c543cc8..5aaf62f 100644 --- a/src/schema2validataclass/app.py +++ b/src/schema2validataclass/app.py @@ -13,16 +13,17 @@ from schema2validataclass.common.uri import URI, UriType from schema2validataclass.config import Config, OutputFormat, PostProcessing from schema2validataclass.generator.generator import Generator -from schema2validataclass.schema.base_outputs import ( +from schema2validataclass.output.base_outputs import ( BaseOutput, EnumBaseOutput, ListBaseOutput, NestedObjectBaseOutput, ObjectBaseOutput, ) -from schema2validataclass.schema.dataclass_outputs import DATACLASS_OUTPUT_CLASSES, DataclassObjectOutput +from schema2validataclass.output.dataclass_outputs import DATACLASS_OUTPUT_CLASSES, DataclassObjectOutput +from schema2validataclass.output.pydantic_outputs import PYDANTIC_OUTPUT_CLASSES, PydanticObjectOutput +from schema2validataclass.output.validataclass_outputs import VALIDATACLASS_OUTPUT_CLASSES, ValidataclassObjectOutput from schema2validataclass.schema.models import BaseField, Object, Schema -from schema2validataclass.schema.validataclass_outputs import VALIDATACLASS_OUTPUT_CLASSES, ValidataclassObjectOutput logger = logging.getLogger(__name__) @@ -36,12 +37,12 @@ def __init__(self, config: Config | None = None): self.generator = Generator(config=self.config) def generate(self, schema_uri: URI, output_path: Path): - if self.config.output_format == OutputFormat.DATACLASS: - object_output_class = DataclassObjectOutput - output_classes = DATACLASS_OUTPUT_CLASSES - else: - object_output_class = ValidataclassObjectOutput - output_classes = VALIDATACLASS_OUTPUT_CLASSES + output_format_map = { + OutputFormat.VALIDATACLASS: (ValidataclassObjectOutput, VALIDATACLASS_OUTPUT_CLASSES), + OutputFormat.DATACLASS: (DataclassObjectOutput, DATACLASS_OUTPUT_CLASSES), + OutputFormat.PYDANTIC: (PydanticObjectOutput, PYDANTIC_OUTPUT_CLASSES), + } + object_output_class, output_classes = output_format_map[self.config.output_format] main_schema_dict = self.read_schema(schema_uri) diff --git a/src/schema2validataclass/config.py b/src/schema2validataclass/config.py index 3a4ca11..0635a2f 100644 --- a/src/schema2validataclass/config.py +++ b/src/schema2validataclass/config.py @@ -3,6 +3,7 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE.txt. """ +import keyword from dataclasses import dataclass, field from enum import Enum from pathlib import Path @@ -24,6 +25,7 @@ def to_output(self) -> str: class OutputFormat(Enum): VALIDATACLASS = 'validataclass' DATACLASS = 'dataclass' + PYDANTIC = 'pydantic' class PostProcessing(Enum): @@ -40,6 +42,9 @@ class Config: ) output_format: OutputFormat = OutputFormat.VALIDATACLASS set_validataclass_mixin: bool = True + renamed_properties: list[str] = field( + default_factory=lambda: keyword.kwlist, + ) detect_looping_references: bool = True post_processing: list[PostProcessing] = field( default_factory=lambda: [PostProcessing.RUFF_FORMAT, PostProcessing.RUFF_CHECK], diff --git a/src/schema2validataclass/generator/generator.py b/src/schema2validataclass/generator/generator.py index 0130fef..b45a448 100644 --- a/src/schema2validataclass/generator/generator.py +++ b/src/schema2validataclass/generator/generator.py @@ -6,7 +6,7 @@ from jinja2 import Environment, PackageLoader, select_autoescape from schema2validataclass.config import Config, OutputFormat -from schema2validataclass.schema.base_outputs import EnumBaseOutput, ObjectBaseOutput +from schema2validataclass.output.base_outputs import EnumBaseOutput, ObjectBaseOutput class Generator: @@ -18,10 +18,12 @@ def generate_init(self) -> str: return self.env.get_template('init.jinja2').render(config=self.config) def generate_object(self, model: ObjectBaseOutput) -> str: - if self.config.output_format == OutputFormat.DATACLASS: - template = 'dataclass.jinja2' - else: - template = 'validataclass.jinja2' + templates = { + OutputFormat.VALIDATACLASS: 'validataclass.jinja2', + OutputFormat.DATACLASS: 'dataclass.jinja2', + OutputFormat.PYDANTIC: 'pydantic.jinja2', + } + template = templates[self.config.output_format] return self.env.get_template(template).render(model=model, config=self.config) def generate_enum(self, model: EnumBaseOutput) -> str: diff --git a/src/schema2validataclass/output/__init__.py b/src/schema2validataclass/output/__init__.py new file mode 100644 index 0000000..a83c119 --- /dev/null +++ b/src/schema2validataclass/output/__init__.py @@ -0,0 +1,4 @@ +""" +Copyright 2025 binary butterfly GmbH +Use of this source code is governed by an MIT-style license that can be found in the LICENSE.txt. +""" diff --git a/src/schema2validataclass/schema/base_outputs.py b/src/schema2validataclass/output/base_outputs.py similarity index 92% rename from src/schema2validataclass/schema/base_outputs.py rename to src/schema2validataclass/output/base_outputs.py index e35fb3f..422cb86 100644 --- a/src/schema2validataclass/schema/base_outputs.py +++ b/src/schema2validataclass/output/base_outputs.py @@ -9,11 +9,10 @@ from dataclasses import dataclass from typing import Any -from schema2validataclass.common.helper import get_class_name, get_enum_name, to_snake_case +from schema2validataclass.common.helper import get_class_name, get_enum_name from schema2validataclass.common.uri import URI from schema2validataclass.config import Config - -from .models import ( +from schema2validataclass.schema.models import ( Array, BaseField, Boolean, @@ -33,6 +32,7 @@ class BaseOutput(ABC): field: BaseField config: Config key: str + original_key: str | None = None title: str | None = None description: str | None = None default: Any | None = None @@ -47,6 +47,13 @@ def __init__(self, field: BaseField, config: Config, references: list[Reference] for reference in reversed(references): self.apply_field(reference) + # Rename properties that conflict with Python reserved words + if self.key in self.config.renamed_properties: + self.original_key = self.key + self.key = f'{self.key}_' + else: + self.original_key = None + def apply_field(self, field: BaseField) -> None: self.key = field.uri.key @@ -128,7 +135,7 @@ def apply_field(self, field: Number): @staticmethod def get_type() -> str: - return 'int' + return 'float' @dataclass(kw_only=True, init=False) @@ -178,7 +185,7 @@ def get_type(self) -> str: def render_enum_values(self) -> list[str]: result: list[str] = [] for enum_value in self.enum_values: - result.append(f'{get_enum_name(enum_value)} = "{enum_value}"') + result.append(f"{get_enum_name(enum_value)} = '{enum_value}'") return result @@ -211,7 +218,9 @@ def __init__( output_classes: dict, **kwargs, ): - super().__init__(field, config=config, referencable_fields=referencable_fields, output_classes=output_classes, **kwargs) + super().__init__( + field, config=config, referencable_fields=referencable_fields, output_classes=output_classes, **kwargs + ) item_field = field.items @@ -325,6 +334,13 @@ def _format_imports(raw_imports: list[str]) -> list[str]: return result_imports + def get_field_mapping(self) -> dict[str, str]: + mapping = {} + for output in self.outputs: + if output.original_key is not None: + mapping[output.original_key] = output.key + return mapping + def get_enum_outputs(self) -> list[EnumBaseOutput]: enum_outputs: list[EnumBaseOutput] = [] diff --git a/src/schema2validataclass/schema/dataclass_outputs.py b/src/schema2validataclass/output/dataclass_outputs.py similarity index 100% rename from src/schema2validataclass/schema/dataclass_outputs.py rename to src/schema2validataclass/output/dataclass_outputs.py diff --git a/src/schema2validataclass/schema/outputs.py b/src/schema2validataclass/output/outputs.py similarity index 86% rename from src/schema2validataclass/schema/outputs.py rename to src/schema2validataclass/output/outputs.py index 3051b8a..958d98c 100644 --- a/src/schema2validataclass/schema/outputs.py +++ b/src/schema2validataclass/output/outputs.py @@ -21,6 +21,10 @@ DATACLASS_OUTPUT_CLASSES, DataclassObjectOutput, ) +from .pydantic_outputs import ( # noqa: F401 + PYDANTIC_OUTPUT_CLASSES, + PydanticObjectOutput, +) from .validataclass_outputs import ( # noqa: F401 VALIDATACLASS_OUTPUT_CLASSES, ValidataclassObjectOutput, diff --git a/src/schema2validataclass/output/pydantic_outputs.py b/src/schema2validataclass/output/pydantic_outputs.py new file mode 100644 index 0000000..a69242c --- /dev/null +++ b/src/schema2validataclass/output/pydantic_outputs.py @@ -0,0 +1,197 @@ +""" +Copyright 2025 binary butterfly GmbH +Use of this source code is governed by an MIT-style license that can be found in the LICENSE.txt. +""" + +from abc import ABC +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from schema2validataclass.common.helper import to_snake_case + +from .base_outputs import ( + BooleanBaseOutput, + EnumBaseOutput, + FloatBaseOutput, + IntegerBaseOutput, + ListBaseOutput, + NestedObjectBaseOutput, + ObjectBaseOutput, + RegexBaseOutput, + StringBaseOutput, +) + + +class PydanticRenderMixin(ABC): + """Mixin providing Pydantic V2-specific rendering for BaseOutput subclasses.""" + + key: str + required: bool + default: Any + + # Provided by BaseOutput subclasses + get_type: Callable[[], str] + + def render(self) -> str: + type_output = self.get_type() + if self.required: + return f'{self.key}: {type_output}' + type_output = f'{type_output} | None' + if self.default is None: + return f'{self.key}: {type_output} = None' + if isinstance(self.default, str): + return f"{self.key}: {type_output} = '{self.default}'" + return f'{self.key}: {type_output} = {self.default}' + + def get_imports(self) -> list[str]: + return [] + + +@dataclass(kw_only=True, init=False) +class PydanticBooleanOutput(PydanticRenderMixin, BooleanBaseOutput): + pass + + +@dataclass(kw_only=True, init=False) +class PydanticIntegerOutput(PydanticRenderMixin, IntegerBaseOutput): + def render(self) -> str: + constraints = self._build_constraints() + if constraints: + return self._render_annotated('int', constraints) + return super().render() + + def _build_constraints(self) -> dict[str, Any]: + constraints: dict[str, Any] = {} + if self.minimum is not None: + constraints['ge'] = self.minimum + if self.maximum is not None: + constraints['le'] = self.maximum + return constraints + + def _render_annotated(self, base_type: str, constraints: dict[str, Any]) -> str: + params = ', '.join(f'{k}={v}' for k, v in constraints.items()) + type_output = f'Annotated[{base_type}, Field({params})]' + if not self.required: + type_output = f'{type_output} | None' + return f'{self.key}: {type_output} = None' + return f'{self.key}: {type_output}' + + def get_imports(self) -> list[str]: + if self._build_constraints(): + return ['typing.Annotated', 'pydantic.Field'] + return [] + + +@dataclass(kw_only=True, init=False) +class PydanticFloatOutput(PydanticRenderMixin, FloatBaseOutput): + def render(self) -> str: + constraints = self._build_constraints() + if constraints: + return self._render_annotated('float', constraints) + return super().render() + + def _build_constraints(self) -> dict[str, Any]: + constraints: dict[str, Any] = {} + if self.minimum is not None: + constraints['ge'] = self.minimum + if self.maximum is not None: + constraints['le'] = self.maximum + return constraints + + def _render_annotated(self, base_type: str, constraints: dict[str, Any]) -> str: + params = ', '.join(f'{k}={v}' for k, v in constraints.items()) + type_output = f'Annotated[{base_type}, Field({params})]' + if not self.required: + type_output = f'{type_output} | None' + return f'{self.key}: {type_output} = None' + return f'{self.key}: {type_output}' + + def get_imports(self) -> list[str]: + if self._build_constraints(): + return ['typing.Annotated', 'pydantic.Field'] + return [] + + +@dataclass(kw_only=True, init=False) +class PydanticStringOutput(PydanticRenderMixin, StringBaseOutput): + def render(self) -> str: + constraints = self._build_constraints() + if constraints: + return self._render_annotated(constraints) + return super().render() + + def _build_constraints(self) -> dict[str, Any]: + constraints: dict[str, Any] = {} + if self.minLength is not None: + constraints['min_length'] = self.minLength + if self.maxLength is not None: + constraints['max_length'] = self.maxLength + return constraints + + def _render_annotated(self, constraints: dict[str, Any]) -> str: + params = ', '.join(f'{k}={v}' for k, v in constraints.items()) + type_output = f'Annotated[str, Field({params})]' + if not self.required: + type_output = f'{type_output} | None' + return f'{self.key}: {type_output} = None' + return f'{self.key}: {type_output}' + + def get_imports(self) -> list[str]: + if self._build_constraints(): + return ['typing.Annotated', 'pydantic.Field'] + return [] + + +@dataclass(kw_only=True, init=False) +class PydanticEnumOutput(PydanticRenderMixin, EnumBaseOutput): + def get_imports(self) -> list[str]: + return [f'.{to_snake_case(self.name)}.{self.name}'] + + +@dataclass(kw_only=True, init=False) +class PydanticRegexOutput(PydanticRenderMixin, RegexBaseOutput): + def render(self) -> str: + type_output = f"Annotated[str, Field(pattern=r'{self.pattern}')]" + if not self.required: + type_output = f'{type_output} | None' + return f'{self.key}: {type_output} = None' + return f'{self.key}: {type_output}' + + def get_imports(self) -> list[str]: + return ['typing.Annotated', 'pydantic.Field'] + + +@dataclass(kw_only=True, init=False) +class PydanticListOutput(PydanticRenderMixin, ListBaseOutput): + def get_imports(self) -> list[str]: + return self.output.get_imports() + + +@dataclass(kw_only=True, init=False) +class PydanticNestedObjectOutput(PydanticRenderMixin, NestedObjectBaseOutput): + def get_imports(self) -> list[str]: + return [f'.{to_snake_case(self.name)}.{self.name}'] + + +@dataclass(kw_only=True, init=False) +class PydanticObjectOutput(ObjectBaseOutput): + def get_imports(self) -> list[str]: + raw_imports: list[str] = ['pydantic.BaseModel'] + if self.get_field_mapping(): + raw_imports += ['pydantic.model_validator', 'typing.Any'] + for output in self.outputs: + raw_imports += output.get_imports() + return self._format_imports(raw_imports) + + +PYDANTIC_OUTPUT_CLASSES = { + 'boolean': PydanticBooleanOutput, + 'integer': PydanticIntegerOutput, + 'float': PydanticFloatOutput, + 'string': PydanticStringOutput, + 'enum': PydanticEnumOutput, + 'regex': PydanticRegexOutput, + 'list': PydanticListOutput, + 'nested_object': PydanticNestedObjectOutput, +} diff --git a/src/schema2validataclass/schema/validataclass_outputs.py b/src/schema2validataclass/output/validataclass_outputs.py similarity index 100% rename from src/schema2validataclass/schema/validataclass_outputs.py rename to src/schema2validataclass/output/validataclass_outputs.py diff --git a/src/schema2validataclass/schema/models.py b/src/schema2validataclass/schema/models.py index f9bd9e6..e2b3ce0 100644 --- a/src/schema2validataclass/schema/models.py +++ b/src/schema2validataclass/schema/models.py @@ -200,10 +200,14 @@ def __init__(self, schema: dict, uri: URI): self.contained_object = Object(schema, uri=uri) self.definitions = [] - raw_definitions: dict[str, Any] = schema.get('$defs', {}) or schema.get('definitions', {}) + raw_definitions: dict[str, Any] = schema.get('definitions', {}) for key, child_schema in raw_definitions.items(): self.definitions.append(parse_schema(child_schema, uri=URI.from_uri(uri, f'/definitions/{key}'))) + raw_defs: dict[str, Any] = schema.get('$defs', {}) + for key, child_schema in raw_defs.items(): + self.definitions.append(parse_schema(child_schema, uri=URI.from_uri(uri, f'/$defs/{key}'))) + @property def properties(self) -> list[BaseField]: return self.contained_object.properties if self.contained_object else [] diff --git a/src/schema2validataclass/templates/pydantic.jinja2 b/src/schema2validataclass/templates/pydantic.jinja2 new file mode 100644 index 0000000..64b3ed3 --- /dev/null +++ b/src/schema2validataclass/templates/pydantic.jinja2 @@ -0,0 +1,37 @@ +{{ config.header }} + +{%- for import_line in model.get_imports() %} +{{ import_line }} +{%- endfor %} + +class {{ model.name }}(BaseModel): +{%- if model.description %} + """ + {{ model.description }} + """ +{%- endif %} +{%- for output in model.outputs %} + {{ output.render() }} +{%- else %} + ... +{%- endfor %} +{%- set field_mapping = model.get_field_mapping() %} +{%- if field_mapping %} + + @model_validator(mode='before') + @classmethod + def _rename_properties(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + field_mapping = { +{%- for from_key, to_key in field_mapping.items() %} + '{{ from_key }}': '{{ to_key }}', +{%- endfor %} + } + + for from_key, to_key in field_mapping.items(): + if from_key in data: + data[to_key] = data.pop(from_key) + + return data +{%- endif %} diff --git a/src/schema2validataclass/templates/validataclass.jinja2 b/src/schema2validataclass/templates/validataclass.jinja2 index bbaf92d..71627a8 100644 --- a/src/schema2validataclass/templates/validataclass.jinja2 +++ b/src/schema2validataclass/templates/validataclass.jinja2 @@ -19,3 +19,20 @@ class {{ model.name }}{% if config.set_validataclass_mixin %}(ValidataclassMixin {%- else %} ... {%- endfor %} +{%- set field_mapping = model.get_field_mapping() %} +{%- if field_mapping %} + + @staticmethod + def __pre_validate__(input_data: dict) -> dict: + field_mapping = { +{%- for from_key, to_key in field_mapping.items() %} + '{{ from_key }}': '{{ to_key }}', +{%- endfor %} + } + + for from_key, to_key in field_mapping.items(): + if from_key in input_data: + input_data[to_key] = input_data.pop(from_key) + + return input_data +{%- endif %} diff --git a/tests/integration/dataclass/main_without_title_test.py b/tests/integration/dataclass/main_without_title_test.py index 4ea80d3..7a08368 100644 --- a/tests/integration/dataclass/main_without_title_test.py +++ b/tests/integration/dataclass/main_without_title_test.py @@ -28,5 +28,5 @@ def test_fields_present(tmp_path: Path): assert 'test_string: str | None = None' in content assert 'test_integer: int | None = None' in content - assert 'test_number: int | None = None' in content + assert 'test_number: float | None = None' in content assert 'test_boolean: bool | None = None' in content diff --git a/tests/integration/dataclass/renamed_properties_test.py b/tests/integration/dataclass/renamed_properties_test.py new file mode 100644 index 0000000..6fb74f7 --- /dev/null +++ b/tests/integration/dataclass/renamed_properties_test.py @@ -0,0 +1,55 @@ +""" +Copyright 2026 binary butterfly GmbH +Use of this source code is governed by an MIT-style license that can be found in the LICENSE.txt. +""" + +from pathlib import Path + +from schema2validataclass import App +from schema2validataclass.common.uri import URI +from schema2validataclass.config import Config, OutputFormat +from tests.integration.dataclass.helpers import INPUT_DIR, generated_files + +SCHEMA_PATH = INPUT_DIR / 'renamed_properties.json' + + +def run_generate(schema_path: Path, output_path: Path, **config_kwargs): + config = Config(output_format=OutputFormat.DATACLASS, **config_kwargs) + app = App(config=config) + app.generate(URI(file_path=schema_path), output_path) + + +def test_generates_expected_files(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + assert generated_files(tmp_path) == {'__init__.py', 'renamed_properties_schema_input.py'} + + +def test_from_field_renamed_to_from_(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'renamed_properties_schema_input.py').read_text() + + assert 'from_: str' in content + assert 'from: str' not in content + + +def test_normal_fields_unchanged(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'renamed_properties_schema_input.py').read_text() + + assert 'to: str' in content + assert 'normal_field: int' in content + + +def test_no_pre_validate_in_dataclass(tmp_path: Path): + """Dataclass output has no validation mechanism, so no __pre_validate__ is generated.""" + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'renamed_properties_schema_input.py').read_text() + + assert '__pre_validate__' not in content + + +def test_no_rename_with_empty_renamed_properties(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path, renamed_properties=[]) + content = (tmp_path / 'renamed_properties_schema_input.py').read_text() + + assert 'from: str' in content diff --git a/tests/integration/dataclass/simple_all_optional_test.py b/tests/integration/dataclass/simple_all_optional_test.py index f3273fa..3260888 100644 --- a/tests/integration/dataclass/simple_all_optional_test.py +++ b/tests/integration/dataclass/simple_all_optional_test.py @@ -37,7 +37,7 @@ def test_optional_fields_use_none(tmp_path: Path): assert 'test_string: str | None = None' in content assert 'test_integer: int | None = None' in content - assert 'test_number: int | None = None' in content + assert 'test_number: float | None = None' in content assert 'test_boolean: bool | None = None' in content diff --git a/tests/integration/dataclass/simple_all_required_test.py b/tests/integration/dataclass/simple_all_required_test.py index 7610e42..bef576d 100644 --- a/tests/integration/dataclass/simple_all_required_test.py +++ b/tests/integration/dataclass/simple_all_required_test.py @@ -23,5 +23,5 @@ def test_main_class_fields(tmp_path: Path): assert 'class SimpleSchemaInput:' in content assert 'test_string: str | None = None' in content assert 'test_integer: int | None = None' in content - assert 'test_number: int | None = None' in content + assert 'test_number: float | None = None' in content assert 'test_boolean: bool | None = None' in content diff --git a/tests/integration/dataclass/simple_enum_test.py b/tests/integration/dataclass/simple_enum_test.py index 12329fb..47f4f66 100644 --- a/tests/integration/dataclass/simple_enum_test.py +++ b/tests/integration/dataclass/simple_enum_test.py @@ -28,8 +28,8 @@ def test_enum_output(tmp_path: Path): content = (tmp_path / 'test_enum.py').read_text() assert 'class TestEnum(Enum):' in content - assert 'FOO = "foo"' in content - assert 'BAR = "bar"' in content + assert "FOO = 'foo'" in content + assert "BAR = 'bar'" in content def test_enum_import_without_validator(tmp_path: Path): diff --git a/tests/integration/dataclass/simple_required_test.py b/tests/integration/dataclass/simple_required_test.py index 3864cc0..2ec16cb 100644 --- a/tests/integration/dataclass/simple_required_test.py +++ b/tests/integration/dataclass/simple_required_test.py @@ -44,5 +44,5 @@ def test_enum_file_generated(tmp_path: Path): content = (tmp_path / 'test_enum.py').read_text() assert 'class TestEnum(Enum):' in content - assert 'FOO = "foo"' in content - assert 'BAR = "bar"' in content + assert "FOO = 'foo'" in content + assert "BAR = 'bar'" in content diff --git a/tests/integration/pydantic/chained_schemas_test.py b/tests/integration/pydantic/chained_schemas_test.py new file mode 100644 index 0000000..3b57873 --- /dev/null +++ b/tests/integration/pydantic/chained_schemas_test.py @@ -0,0 +1,45 @@ +""" +Copyright 2026 binary butterfly GmbH +Use of this source code is governed by an MIT-style license that can be found in the LICENSE.txt. +""" + +from pathlib import Path + +from tests.integration.pydantic.helpers import INPUT_DIR, generated_files, run_generate + +SCHEMA_PATH = INPUT_DIR / 'chained_schemas' / 'main_schema.json' + + +def test_generates_expected_files(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + assert generated_files(tmp_path) == { + '__init__.py', + 'simple_schema_input.py', + 'second_object_input.py', + 'third_object_input.py', + } + + +def test_main_class_references_second(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'simple_schema_input.py').read_text() + + assert 'class SimpleSchemaInput(BaseModel):' in content + assert 'SecondObject: SecondObjectInput | None = None' in content + + +def test_second_class_references_third(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'second_object_input.py').read_text() + + assert 'class SecondObjectInput(BaseModel):' in content + assert 'ThirdObject: ThirdObjectInput | None = None' in content + assert 'second_string: str | None = None' in content + + +def test_third_class(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'third_object_input.py').read_text() + + assert 'class ThirdObjectInput(BaseModel):' in content + assert 'third_string: str | None = None' in content diff --git a/tests/integration/pydantic/helpers.py b/tests/integration/pydantic/helpers.py new file mode 100644 index 0000000..dcf3516 --- /dev/null +++ b/tests/integration/pydantic/helpers.py @@ -0,0 +1,22 @@ +""" +Copyright 2026 binary butterfly GmbH +Use of this source code is governed by an MIT-style license that can be found in the LICENSE.txt. +""" + +from pathlib import Path + +from schema2validataclass import App +from schema2validataclass.common.uri import URI +from schema2validataclass.config import Config, OutputFormat + +INPUT_DIR = Path(__file__).resolve().parent.parent.parent / 'test_schema' / 'input' + + +def generated_files(output_path: Path) -> set[str]: + return {f.name for f in output_path.iterdir() if f.is_file()} + + +def run_generate(schema_path: Path, output_path: Path): + config = Config(output_format=OutputFormat.PYDANTIC) + app = App(config=config) + app.generate(URI(file_path=schema_path), output_path) diff --git a/tests/integration/pydantic/import_loops_in_lists_test.py b/tests/integration/pydantic/import_loops_in_lists_test.py new file mode 100644 index 0000000..558aac8 --- /dev/null +++ b/tests/integration/pydantic/import_loops_in_lists_test.py @@ -0,0 +1,38 @@ +""" +Copyright 2026 binary butterfly GmbH +Use of this source code is governed by an MIT-style license that can be found in the LICENSE.txt. +""" + +from pathlib import Path + +from tests.integration.pydantic.helpers import INPUT_DIR, generated_files, run_generate + +SCHEMA_PATH = INPUT_DIR / 'import_loops_in_lists' / 'main_schema.json' + + +def test_generates_expected_files(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + assert generated_files(tmp_path) == { + '__init__.py', + 'simple_schema_input.py', + 'second_object_input.py', + 'third_object_input.py', + } + + +def test_looping_list_reference_removed_from_third(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'third_object_input.py').read_text() + + assert 'class ThirdObjectInput(BaseModel):' in content + assert 'third_string' in content + assert 'SecondObject' not in content + + +def test_no_circular_import(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + second_content = (tmp_path / 'second_object_input.py').read_text() + third_content = (tmp_path / 'third_object_input.py').read_text() + + assert 'third_object_input' in second_content + assert 'second_object_input' not in third_content diff --git a/tests/integration/pydantic/import_loops_test.py b/tests/integration/pydantic/import_loops_test.py new file mode 100644 index 0000000..96039f6 --- /dev/null +++ b/tests/integration/pydantic/import_loops_test.py @@ -0,0 +1,38 @@ +""" +Copyright 2026 binary butterfly GmbH +Use of this source code is governed by an MIT-style license that can be found in the LICENSE.txt. +""" + +from pathlib import Path + +from tests.integration.pydantic.helpers import INPUT_DIR, generated_files, run_generate + +SCHEMA_PATH = INPUT_DIR / 'import_loops' / 'main_schema.json' + + +def test_generates_expected_files(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + assert generated_files(tmp_path) == { + '__init__.py', + 'simple_schema_input.py', + 'second_object_input.py', + 'third_object_input.py', + } + + +def test_looping_reference_removed_from_third(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'third_object_input.py').read_text() + + assert 'class ThirdObjectInput(BaseModel):' in content + assert 'third_string' in content + assert 'SecondObjectInput' not in content + + +def test_no_circular_import(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + second_content = (tmp_path / 'second_object_input.py').read_text() + third_content = (tmp_path / 'third_object_input.py').read_text() + + assert 'third_object_input' in second_content + assert 'second_object_input' not in third_content diff --git a/tests/integration/pydantic/list_reference_test.py b/tests/integration/pydantic/list_reference_test.py new file mode 100644 index 0000000..0d993ac --- /dev/null +++ b/tests/integration/pydantic/list_reference_test.py @@ -0,0 +1,39 @@ +""" +Copyright 2026 binary butterfly GmbH +Use of this source code is governed by an MIT-style license that can be found in the LICENSE.txt. +""" + +from pathlib import Path + +from tests.integration.pydantic.helpers import INPUT_DIR, generated_files, run_generate + +SCHEMA_PATH = INPUT_DIR / 'list_reference' / 'main_schema.json' + + +def test_generates_expected_files(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + assert generated_files(tmp_path) == {'__init__.py', 'simple_schema_input.py', 'second_object_input.py'} + + +def test_list_field(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'simple_schema_input.py').read_text() + + assert 'class SimpleSchemaInput(BaseModel):' in content + assert 'second_objects: list[SecondObjectInput] | None = None' in content + assert 'ListValidator' not in content + + +def test_referenced_object(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'second_object_input.py').read_text() + + assert 'class SecondObjectInput(BaseModel):' in content + assert 'second_string: str | None = None' in content + + +def test_nested_object_import(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'simple_schema_input.py').read_text() + + assert 'from .second_object_input import SecondObjectInput' in content diff --git a/tests/integration/pydantic/main_without_title_test.py b/tests/integration/pydantic/main_without_title_test.py new file mode 100644 index 0000000..50a13f2 --- /dev/null +++ b/tests/integration/pydantic/main_without_title_test.py @@ -0,0 +1,32 @@ +""" +Copyright 2026 binary butterfly GmbH +Use of this source code is governed by an MIT-style license that can be found in the LICENSE.txt. +""" + +from pathlib import Path + +from tests.integration.pydantic.helpers import INPUT_DIR, generated_files, run_generate + +SCHEMA_PATH = INPUT_DIR / 'main_without_title.json' + + +def test_generates_expected_files(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + assert generated_files(tmp_path) == {'__init__.py', 'main_without_title_input.py'} + + +def test_class_name_derived_from_filename(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'main_without_title_input.py').read_text() + + assert 'class MainWithoutTitleInput(BaseModel):' in content + + +def test_fields_present(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'main_without_title_input.py').read_text() + + assert 'test_string: str | None = None' in content + assert 'test_integer: int | None = None' in content + assert 'test_number: float | None = None' in content + assert 'test_boolean: bool | None = None' in content diff --git a/tests/integration/pydantic/object_in_object_with_title_test.py b/tests/integration/pydantic/object_in_object_with_title_test.py new file mode 100644 index 0000000..46c0f52 --- /dev/null +++ b/tests/integration/pydantic/object_in_object_with_title_test.py @@ -0,0 +1,38 @@ +""" +Copyright 2026 binary butterfly GmbH +Use of this source code is governed by an MIT-style license that can be found in the LICENSE.txt. +""" + +from pathlib import Path + +from tests.integration.pydantic.helpers import INPUT_DIR, generated_files, run_generate + +SCHEMA_PATH = INPUT_DIR / 'object_in_object_with_title.json' + + +def test_generates_expected_files(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + assert generated_files(tmp_path) == {'__init__.py', 'simple_schema_input.py', 'test_object_input.py'} + + +def test_main_class_references_nested_object(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'simple_schema_input.py').read_text() + + assert 'class SimpleSchemaInput(BaseModel):' in content + assert 'test_object: TestObjectInput | None = None' in content + + +def test_nested_object_class(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'test_object_input.py').read_text() + + assert 'class TestObjectInput(BaseModel):' in content + assert 'test_property: str | None = None' in content + + +def test_nested_object_import(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'simple_schema_input.py').read_text() + + assert 'from .test_object_input import TestObjectInput' in content diff --git a/tests/integration/pydantic/object_naming_test.py b/tests/integration/pydantic/object_naming_test.py new file mode 100644 index 0000000..3b67b9c --- /dev/null +++ b/tests/integration/pydantic/object_naming_test.py @@ -0,0 +1,31 @@ +""" +Copyright 2026 binary butterfly GmbH +Use of this source code is governed by an MIT-style license that can be found in the LICENSE.txt. +""" + +from pathlib import Path + +from tests.integration.pydantic.helpers import INPUT_DIR, generated_files, run_generate + +SCHEMA_PATH = INPUT_DIR / 'object_naming' / 'main_schema.json' + + +def test_generates_expected_files(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + assert generated_files(tmp_path) == {'__init__.py', 'simple_schema_input.py', 'second_object_input.py'} + + +def test_main_class_references_named_object(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'simple_schema_input.py').read_text() + + assert 'class SimpleSchemaInput(BaseModel):' in content + assert 'SecondObject: SecondObjectInput | None = None' in content + + +def test_referenced_object(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'second_object_input.py').read_text() + + assert 'class SecondObjectInput(BaseModel):' in content + assert 'second_string: str | None = None' in content diff --git a/tests/integration/pydantic/patched_reference_test.py b/tests/integration/pydantic/patched_reference_test.py new file mode 100644 index 0000000..d2ca961 --- /dev/null +++ b/tests/integration/pydantic/patched_reference_test.py @@ -0,0 +1,32 @@ +""" +Copyright 2026 binary butterfly GmbH +Use of this source code is governed by an MIT-style license that can be found in the LICENSE.txt. +""" + +from pathlib import Path + +from tests.integration.pydantic.helpers import INPUT_DIR, generated_files, run_generate + +SCHEMA_PATH = INPUT_DIR / 'patched_reference' / 'main_schema.json' + + +def test_generates_expected_files(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + assert generated_files(tmp_path) == {'__init__.py', 'simple_schema_input.py'} + + +def test_patched_integer_with_constraint(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'simple_schema_input.py').read_text() + + assert 'class SimpleSchemaInput(BaseModel):' in content + assert 'Annotated[int, Field(ge=0)]' in content + assert 'from pydantic import' in content + assert 'Field' in content + assert 'Annotated' in content + + +def test_no_extra_object_files(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + py_files = {f.name for f in tmp_path.glob('*.py')} - {'__init__.py'} + assert py_files == {'simple_schema_input.py'} diff --git a/tests/integration/pydantic/renamed_properties_test.py b/tests/integration/pydantic/renamed_properties_test.py new file mode 100644 index 0000000..4706860 --- /dev/null +++ b/tests/integration/pydantic/renamed_properties_test.py @@ -0,0 +1,67 @@ +""" +Copyright 2026 binary butterfly GmbH +Use of this source code is governed by an MIT-style license that can be found in the LICENSE.txt. +""" + +from pathlib import Path + +from schema2validataclass import App +from schema2validataclass.common.uri import URI +from schema2validataclass.config import Config, OutputFormat +from tests.integration.pydantic.helpers import INPUT_DIR, generated_files + +SCHEMA_PATH = INPUT_DIR / 'renamed_properties.json' + + +def run_generate(schema_path: Path, output_path: Path, **config_kwargs): + config = Config(output_format=OutputFormat.PYDANTIC, **config_kwargs) + app = App(config=config) + app.generate(URI(file_path=schema_path), output_path) + + +def test_generates_expected_files(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + assert generated_files(tmp_path) == {'__init__.py', 'renamed_properties_schema_input.py'} + + +def test_from_field_renamed_to_from_(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'renamed_properties_schema_input.py').read_text() + + assert 'from_: str' in content + assert 'from: str' not in content + + +def test_normal_fields_unchanged(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'renamed_properties_schema_input.py').read_text() + + assert 'to: str' in content + assert 'normal_field: int' in content + + +def test_model_validator_generated(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'renamed_properties_schema_input.py').read_text() + + assert "@model_validator(mode='before')" in content + assert '_rename_properties' in content + assert "'from': 'from_'" in content + + +def test_model_validator_imports(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'renamed_properties_schema_input.py').read_text() + + assert 'from pydantic import' in content + assert 'model_validator' in content + assert 'from typing import Any' in content + + +def test_no_model_validator_without_renamed_properties(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path, renamed_properties=[]) + content = (tmp_path / 'renamed_properties_schema_input.py').read_text() + + assert 'model_validator' not in content + assert '_rename_properties' not in content + assert 'from: str' in content diff --git a/tests/integration/pydantic/simple_all_optional_test.py b/tests/integration/pydantic/simple_all_optional_test.py new file mode 100644 index 0000000..3ff2890 --- /dev/null +++ b/tests/integration/pydantic/simple_all_optional_test.py @@ -0,0 +1,43 @@ +""" +Copyright 2026 binary butterfly GmbH +Use of this source code is governed by an MIT-style license that can be found in the LICENSE.txt. +""" + +from pathlib import Path + +from tests.integration.pydantic.helpers import INPUT_DIR, generated_files, run_generate + +SCHEMA_PATH = INPUT_DIR / 'simple_all_optional.json' + + +def test_generates_expected_files(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + assert generated_files(tmp_path) == {'__init__.py', 'simple_schema_input.py'} + + +def test_inherits_base_model(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'simple_schema_input.py').read_text() + + assert 'class SimpleSchemaInput(BaseModel):' in content + assert 'from pydantic import BaseModel' in content + + +def test_optional_fields_use_none(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'simple_schema_input.py').read_text() + + assert 'test_string: str | None = None' in content + assert 'test_integer: int | None = None' in content + assert 'test_number: float | None = None' in content + assert 'test_boolean: bool | None = None' in content + + +def test_no_validataclass_artifacts(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'simple_schema_input.py').read_text() + + assert 'Validator' not in content + assert 'UnsetValue' not in content + assert 'validataclass' not in content + assert '@dataclass' not in content diff --git a/tests/integration/pydantic/simple_all_required_test.py b/tests/integration/pydantic/simple_all_required_test.py new file mode 100644 index 0000000..4979dc9 --- /dev/null +++ b/tests/integration/pydantic/simple_all_required_test.py @@ -0,0 +1,24 @@ +""" +Copyright 2026 binary butterfly GmbH +Use of this source code is governed by an MIT-style license that can be found in the LICENSE.txt. +""" + +from pathlib import Path + +from tests.integration.pydantic.helpers import INPUT_DIR, generated_files, run_generate + +SCHEMA_PATH = INPUT_DIR / 'simple_all_required.json' + + +def test_generates_expected_files(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + assert generated_files(tmp_path) == {'__init__.py', 'simple_schema_input.py'} + + +def test_main_class_fields(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'simple_schema_input.py').read_text() + + assert 'class SimpleSchemaInput(BaseModel):' in content + assert 'test_string: str | None = None' in content + assert 'test_integer: int | None = None' in content diff --git a/tests/integration/pydantic/simple_default_string_test.py b/tests/integration/pydantic/simple_default_string_test.py new file mode 100644 index 0000000..998d93d --- /dev/null +++ b/tests/integration/pydantic/simple_default_string_test.py @@ -0,0 +1,23 @@ +""" +Copyright 2026 binary butterfly GmbH +Use of this source code is governed by an MIT-style license that can be found in the LICENSE.txt. +""" + +from pathlib import Path + +from tests.integration.pydantic.helpers import INPUT_DIR, generated_files, run_generate + +SCHEMA_PATH = INPUT_DIR / 'simple_default_string.json' + + +def test_generates_expected_files(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + assert generated_files(tmp_path) == {'__init__.py', 'simple_schema_input.py', 'test_enum.py'} + + +def test_all_fields_optional(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'simple_schema_input.py').read_text() + + assert 'test_required_string: str | None = None' in content + assert 'test_enum: TestEnum | None = None' in content diff --git a/tests/integration/pydantic/simple_enum_test.py b/tests/integration/pydantic/simple_enum_test.py new file mode 100644 index 0000000..0298d6a --- /dev/null +++ b/tests/integration/pydantic/simple_enum_test.py @@ -0,0 +1,31 @@ +""" +Copyright 2026 binary butterfly GmbH +Use of this source code is governed by an MIT-style license that can be found in the LICENSE.txt. +""" + +from pathlib import Path + +from tests.integration.pydantic.helpers import INPUT_DIR, generated_files, run_generate + +SCHEMA_PATH = INPUT_DIR / 'simple_enum.json' + + +def test_generates_expected_files(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + assert generated_files(tmp_path) == {'__init__.py', 'simple_schema_input.py', 'test_enum.py'} + + +def test_required_field(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'simple_schema_input.py').read_text() + + assert 'test_required_string: str' in content + assert 'test_required_string: str |' not in content + + +def test_enum_import_without_validator(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'simple_schema_input.py').read_text() + + assert 'from .test_enum import TestEnum' in content + assert 'EnumValidator' not in content diff --git a/tests/integration/pydantic/simple_required_test.py b/tests/integration/pydantic/simple_required_test.py new file mode 100644 index 0000000..dcf0231 --- /dev/null +++ b/tests/integration/pydantic/simple_required_test.py @@ -0,0 +1,47 @@ +""" +Copyright 2026 binary butterfly GmbH +Use of this source code is governed by an MIT-style license that can be found in the LICENSE.txt. +""" + +from pathlib import Path + +from tests.integration.pydantic.helpers import INPUT_DIR, generated_files, run_generate + +SCHEMA_PATH = INPUT_DIR / 'simple_required.json' + + +def test_generates_expected_files(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + assert generated_files(tmp_path) == {'__init__.py', 'simple_schema_input.py', 'test_enum.py'} + + +def test_required_field_is_plain_type(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'simple_schema_input.py').read_text() + + assert 'test_required_string: str' in content + assert 'test_required_string: str |' not in content + + +def test_optional_fields_use_none(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'simple_schema_input.py').read_text() + + assert 'test_string: str | None = None' in content + assert 'test_integer: int | None = None' in content + + +def test_enum_field_optional(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'simple_schema_input.py').read_text() + + assert 'test_enum: TestEnum | None = None' in content + + +def test_enum_file_generated(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'test_enum.py').read_text() + + assert 'class TestEnum(Enum):' in content + assert "FOO = 'foo'" in content + assert "BAR = 'bar'" in content diff --git a/tests/integration/pydantic/simple_title_description_test.py b/tests/integration/pydantic/simple_title_description_test.py new file mode 100644 index 0000000..38bdf4e --- /dev/null +++ b/tests/integration/pydantic/simple_title_description_test.py @@ -0,0 +1,23 @@ +""" +Copyright 2026 binary butterfly GmbH +Use of this source code is governed by an MIT-style license that can be found in the LICENSE.txt. +""" + +from pathlib import Path + +from tests.integration.pydantic.helpers import INPUT_DIR, generated_files, run_generate + +SCHEMA_PATH = INPUT_DIR / 'simple_title_description.json' + + +def test_generates_expected_files(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + assert generated_files(tmp_path) == {'__init__.py', 'simple_schema_input.py'} + + +def test_fields_present(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'simple_schema_input.py').read_text() + + assert 'class SimpleSchemaInput(BaseModel):' in content + assert 'test_string: str | None = None' in content diff --git a/tests/integration/validataclass/main_without_title_test.py b/tests/integration/validataclass/main_without_title_test.py index e2842a3..2253b31 100644 --- a/tests/integration/validataclass/main_without_title_test.py +++ b/tests/integration/validataclass/main_without_title_test.py @@ -28,5 +28,5 @@ def test_fields_present(tmp_path: Path): assert 'test_string: str | UnsetValueType = StringValidator(), Default(UnsetValue)' in content assert 'test_integer: int | UnsetValueType = IntegerValidator(), Default(UnsetValue)' in content - assert 'test_number: int | UnsetValueType = FloatValidator(), Default(UnsetValue)' in content + assert 'test_number: float | UnsetValueType = FloatValidator(), Default(UnsetValue)' in content assert 'test_boolean: bool | UnsetValueType = BooleanValidator(), Default(UnsetValue)' in content diff --git a/tests/integration/validataclass/renamed_properties_test.py b/tests/integration/validataclass/renamed_properties_test.py new file mode 100644 index 0000000..163f5af --- /dev/null +++ b/tests/integration/validataclass/renamed_properties_test.py @@ -0,0 +1,76 @@ +""" +Copyright 2026 binary butterfly GmbH +Use of this source code is governed by an MIT-style license that can be found in the LICENSE.txt. +""" + +from pathlib import Path + +from schema2validataclass import App +from schema2validataclass.common.uri import URI +from schema2validataclass.config import Config +from tests.integration.validataclass.helpers import INPUT_DIR, generated_files + +SCHEMA_PATH = INPUT_DIR / 'renamed_properties.json' + + +def run_generate(schema_path: Path, output_path: Path, **config_kwargs): + config = Config(**config_kwargs) + app = App(config=config) + app.generate(URI(file_path=schema_path), output_path) + + +def test_generates_expected_files(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + assert generated_files(tmp_path) == {'__init__.py', 'renamed_properties_schema_input.py'} + + +def test_from_field_renamed_to_from_(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'renamed_properties_schema_input.py').read_text() + + assert 'from_: str = StringValidator()' in content + assert 'from: str' not in content + + +def test_normal_fields_unchanged(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'renamed_properties_schema_input.py').read_text() + + assert 'to: str = StringValidator()' in content + assert 'normal_field: int' in content + + +def test_pre_validate_generated(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'renamed_properties_schema_input.py').read_text() + + assert '@staticmethod' in content + assert 'def __pre_validate__(input_data: dict) -> dict:' in content + assert "'from': 'from_'" in content + + +def test_pre_validate_mapping_logic(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path) + content = (tmp_path / 'renamed_properties_schema_input.py').read_text() + + assert 'for from_key, to_key in field_mapping.items():' in content + assert 'if from_key in input_data:' in content + assert 'input_data[to_key] = input_data.pop(from_key)' in content + assert 'return input_data' in content + + +def test_no_pre_validate_without_renamed_properties(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path, renamed_properties=[]) + content = (tmp_path / 'renamed_properties_schema_input.py').read_text() + + assert '__pre_validate__' not in content + assert 'from: str = StringValidator()' in content + + +def test_custom_renamed_properties(tmp_path: Path): + run_generate(SCHEMA_PATH, tmp_path, renamed_properties=['to']) + content = (tmp_path / 'renamed_properties_schema_input.py').read_text() + + assert 'to_: str = StringValidator()' in content + assert 'from: str = StringValidator()' in content + assert "'to': 'to_'" in content diff --git a/tests/integration/validataclass/simple_all_optional_test.py b/tests/integration/validataclass/simple_all_optional_test.py index bdf27f0..5552627 100644 --- a/tests/integration/validataclass/simple_all_optional_test.py +++ b/tests/integration/validataclass/simple_all_optional_test.py @@ -22,7 +22,7 @@ def test_main_class_has_all_optional_fields(tmp_path: Path): assert 'class SimpleSchemaInput(ValidataclassMixin):' in content assert 'test_string: str | UnsetValueType = StringValidator(), Default(UnsetValue)' in content assert 'test_integer: int | UnsetValueType = IntegerValidator(), Default(UnsetValue)' in content - assert 'test_number: int | UnsetValueType = FloatValidator(), Default(UnsetValue)' in content + assert 'test_number: float | UnsetValueType = FloatValidator(), Default(UnsetValue)' in content assert 'test_boolean: bool | UnsetValueType = BooleanValidator(), Default(UnsetValue)' in content diff --git a/tests/integration/validataclass/simple_all_required_test.py b/tests/integration/validataclass/simple_all_required_test.py index e8053bf..5f0d8ed 100644 --- a/tests/integration/validataclass/simple_all_required_test.py +++ b/tests/integration/validataclass/simple_all_required_test.py @@ -22,5 +22,5 @@ def test_main_class_fields(tmp_path: Path): assert 'class SimpleSchemaInput(ValidataclassMixin):' in content assert 'test_string: str | UnsetValueType = StringValidator(), Default(UnsetValue)' in content assert 'test_integer: int | UnsetValueType = IntegerValidator(), Default(UnsetValue)' in content - assert 'test_number: int | UnsetValueType = FloatValidator(), Default(UnsetValue)' in content + assert 'test_number: float | UnsetValueType = FloatValidator(), Default(UnsetValue)' in content assert 'test_boolean: bool | UnsetValueType = BooleanValidator(), Default(UnsetValue)' in content diff --git a/tests/integration/validataclass/simple_enum_test.py b/tests/integration/validataclass/simple_enum_test.py index 8486bd6..b73a180 100644 --- a/tests/integration/validataclass/simple_enum_test.py +++ b/tests/integration/validataclass/simple_enum_test.py @@ -27,8 +27,8 @@ def test_enum_output(tmp_path: Path): content = (tmp_path / 'test_enum.py').read_text() assert 'class TestEnum(Enum):' in content - assert 'FOO = "foo"' in content - assert 'BAR = "bar"' in content + assert "FOO = 'foo'" in content + assert "BAR = 'bar'" in content def test_enum_imports(tmp_path: Path): diff --git a/tests/integration/validataclass/simple_required_test.py b/tests/integration/validataclass/simple_required_test.py index 98c228b..efc5e0b 100644 --- a/tests/integration/validataclass/simple_required_test.py +++ b/tests/integration/validataclass/simple_required_test.py @@ -42,8 +42,8 @@ def test_enum_file_generated(tmp_path: Path): content = (tmp_path / 'test_enum.py').read_text() assert 'class TestEnum(Enum):' in content - assert 'FOO = "foo"' in content - assert 'BAR = "bar"' in content + assert "FOO = 'foo'" in content + assert "BAR = 'bar'" in content def test_required_field_imports(tmp_path: Path): diff --git a/tests/test_schema/input/renamed_properties.json b/tests/test_schema/input/renamed_properties.json new file mode 100644 index 0000000..8402501 --- /dev/null +++ b/tests/test_schema/input/renamed_properties.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "title": "Renamed Properties Schema", + "required": ["from", "to"], + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "string" + }, + "normal_field": { + "type": "integer" + } + } +}