From ba6917528a2bd03c345aba99ef184766eb231793 Mon Sep 17 00:00:00 2001 From: Jose Tomas Robles Hahn Date: Tue, 24 Sep 2024 13:11:36 -0300 Subject: [PATCH] feat(extras): Add Pydantic type for `Rut` - Add Pydantic type `cl_sii.extras.pydantic_types.Rut` that can be used in place of `cl_sii.rut.Rut` in Pydantic models and Pydantic data classes. - Add extra `pydantic` to Python package. Ref: https://app.shortcut.com/cordada/story/9729 [sc-9729] --- pyproject.toml | 1 + src/cl_sii/extras/pydantic_types.py | 110 +++++++++++++++++++ src/tests/test_extras_pydantic_types.py | 135 ++++++++++++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 src/cl_sii/extras/pydantic_types.py create mode 100644 src/tests/test_extras_pydantic_types.py diff --git a/pyproject.toml b/pyproject.toml index c6b22efc..66c5dad6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ dynamic = ["version"] django = ["Django>=2.2.24"] django-filter = ["django-filter>=24.2"] djangorestframework = ["djangorestframework>=3.10.3,<3.16"] +pydantic = ["pydantic>=2.0"] [project.urls] Homepage = "https://github.com/fyntex/lib-cl-sii-python" diff --git a/src/cl_sii/extras/pydantic_types.py b/src/cl_sii/extras/pydantic_types.py new file mode 100644 index 00000000..a55a2b03 --- /dev/null +++ b/src/cl_sii/extras/pydantic_types.py @@ -0,0 +1,110 @@ +""" +cl_sii "extras" / Pydantic types. +""" + +from __future__ import annotations + +import sys +from typing import Any + + +if sys.version_info[:2] >= (3, 9): + from typing import Annotated +else: + from typing_extensions import Annotated + +try: + import pydantic + import pydantic.json_schema +except ImportError as exc: # pragma: no cover + raise ImportError("Package 'pydantic' is required to use this module.") from exc + +try: + import pydantic_core +except ImportError as exc: # pragma: no cover + raise ImportError("Package 'pydantic-core' is required to use this module.") from exc + +import cl_sii.rut +import cl_sii.rut.constants + + +class _RutPydanticAnnotation: + """ + `Annotated` wrapper that can be used as the annotation for `cl_sii.rut.Rut` + fields on `pydantic.BaseModels`, `@pydantic.dataclasses`, etc. + + .. seealso:: + - Handling third-party types: + https://docs.pydantic.dev/2.9/concepts/types/#handling-third-party-types + (https://github.com/pydantic/pydantic/blob/v2.9.2/docs/concepts/types.md#handling-third-party-types) + - Customizing the core schema and JSON schema: + https://docs.pydantic.dev/2.9/architecture/#customizing-the-core-schema-and-json-schema + (https://github.com/pydantic/pydantic/blob/v2.9.2/docs/architecture.md#customizing-the-core-schema-and-json-schema) + + Examples: + + >>> from typing import Annotated + >>> import pydantic + >>> import cl_sii.rut + + >>> Rut = Annotated[cl_sii.rut.Rut, _RutPydanticAnnotation] + + >>> class ExampleModel(pydantic.BaseModel): + ... rut: Rut + >>> + >>> example_model_instance = ExampleModel.model_validate({'rut': '78773510-K'}) + + >>> import pydantic.dataclasses + >>> + >>> @pydantic.dataclasses.dataclass + ... class ExampleDataclass: + ... rut: Rut + >>> + >>> example_dataclass_instance = ExampleDataclass(rut='78773510-K') + + >>> example_type_adapter = pydantic.TypeAdapter(Rut) + >>> + >>> example_type_adapter.validate_python('78773510-K') + Rut('78773510-K') + >>> example_type_adapter.validate_json('"78773510-K"') + Rut('78773510-K') + >>> example_type_adapter.dump_python(cl_sii.rut.Rut('78773510-K')) + '78773510-K' + >>> example_type_adapter.dump_json(cl_sii.rut.Rut('78773510-K')) + b'"78773510-K"' + """ + + @classmethod + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler + ) -> pydantic_core.core_schema.CoreSchema: + def validate_from_str(value: str) -> cl_sii.rut.Rut: + return cl_sii.rut.Rut(value, validate_dv=False) + + from_str_schema = pydantic_core.core_schema.chain_schema( + [ + pydantic_core.core_schema.str_schema( + pattern=cl_sii.rut.constants.RUT_CANONICAL_STRICT_REGEX.pattern + ), + pydantic_core.core_schema.no_info_plain_validator_function(validate_from_str), + ] + ) + + return pydantic_core.core_schema.json_or_python_schema( + json_schema=from_str_schema, + python_schema=pydantic_core.core_schema.union_schema( + [ + pydantic_core.core_schema.is_instance_schema(cl_sii.rut.Rut), + from_str_schema, + ] + ), + serialization=pydantic_core.core_schema.plain_serializer_function_ser_schema( + lambda instance: instance.canonical + ), + ) + + +Rut = Annotated[cl_sii.rut.Rut, _RutPydanticAnnotation] +""" +Convenience type alias for Pydantic fields that represent Chilean RUTs. +""" diff --git a/src/tests/test_extras_pydantic_types.py b/src/tests/test_extras_pydantic_types.py new file mode 100644 index 00000000..e66221d8 --- /dev/null +++ b/src/tests/test_extras_pydantic_types.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import json +import unittest +from typing import ClassVar + +import pydantic + +from cl_sii.extras.pydantic_types import Rut as PydanticRut +from cl_sii.rut import Rut + + +class PydanticRutTest(unittest.TestCase): + """ + Tests for :class:`PydanticRut`. + """ + + ThirdPartyType: ClassVar[type[Rut]] + PydanticThirdPartyType: ClassVar[type[PydanticRut]] + pydantic_type_adapter: ClassVar[pydantic.TypeAdapter] + valid_instance_1: ClassVar[Rut] + valid_instance_2: ClassVar[Rut] + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + + cls.ThirdPartyType = Rut + cls.PydanticThirdPartyType = PydanticRut + cls.pydantic_type_adapter = pydantic.TypeAdapter(cls.PydanticThirdPartyType) + + cls.valid_instance_1 = cls.ThirdPartyType('78773510-K') + assert isinstance(cls.valid_instance_1, cls.ThirdPartyType) + + cls.valid_instance_2 = cls.ThirdPartyType('77004430-8') + assert isinstance(cls.valid_instance_2, cls.ThirdPartyType) + + def test_serialize_to_python(self) -> None: + # -----Arrange----- + + instance = self.valid_instance_1 + expected_serialized_value = '78773510-K' + + # -----Act----- + + actual_serialized_value = self.pydantic_type_adapter.dump_python(instance) + + # -----Assert----- + + self.assertEqual(expected_serialized_value, actual_serialized_value) + + def test_serialize_to_json(self) -> None: + # -----Arrange----- + + instance = self.valid_instance_1 + + expected_serialized_value = b'"78773510-K"' + self.assertEqual( + expected_serialized_value, json.dumps(json.loads(expected_serialized_value)).encode() + ) + + # -----Act----- + + actual_serialized_value = self.pydantic_type_adapter.dump_json(instance) + + # -----Assert----- + + self.assertEqual(expected_serialized_value, actual_serialized_value) + + def test_deserialize_from_instance(self) -> None: + # -----Arrange----- + + obj = self.valid_instance_2 + expected_deserialized_value = self.valid_instance_2 + + # -----Act----- + + actual_deserialized_value = self.pydantic_type_adapter.validate_python(obj) + + # -----Assert----- + + self.assertEqual(expected_deserialized_value, actual_deserialized_value) + + def test_deserialize_from_python(self) -> None: + # -----Arrange----- + + obj = '78773510-K' + expected_deserialized_value = self.valid_instance_1 + + # -----Act----- + + actual_deserialized_value = self.pydantic_type_adapter.validate_python(obj) + + # -----Assert----- + + self.assertEqual(expected_deserialized_value, actual_deserialized_value) + + def test_deserialize_from_json(self) -> None: + # -----Arrange----- + + data = '"78773510-K"' + self.assertEqual(data, json.dumps(json.loads(data))) + + expected_deserialized_value = self.valid_instance_1 + + # -----Act----- + + actual_deserialized_value = self.pydantic_type_adapter.validate_json(data) + + # -----Assert----- + + self.assertEqual(expected_deserialized_value, actual_deserialized_value) + + def test_deserialize_invalid(self) -> None: + test_items = [ + 78773510, + -78773510, + '78773510-k', + '78.773.510-K', + '78773510-X', + '-78773510-K', + True, + None, + ] + + for test_item in test_items: + obj = test_item + data = json.dumps(test_item) + + with self.subTest(item=test_item): + with self.assertRaises(pydantic.ValidationError): + self.pydantic_type_adapter.validate_python(obj) + + with self.assertRaises(pydantic.ValidationError): + self.pydantic_type_adapter.validate_json(data)