Skip to content

Commit

Permalink
feat(extras): Add Pydantic type for Rut
Browse files Browse the repository at this point in the history
- 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]
  • Loading branch information
jtrobles-cdd committed Sep 24, 2024
1 parent 31d4cdf commit ba69175
Show file tree
Hide file tree
Showing 3 changed files with 246 additions and 0 deletions.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
110 changes: 110 additions & 0 deletions src/cl_sii/extras/pydantic_types.py
Original file line number Diff line number Diff line change
@@ -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.
"""
135 changes: 135 additions & 0 deletions src/tests/test_extras_pydantic_types.py
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit ba69175

Please sign in to comment.