Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)