-
Notifications
You must be signed in to change notification settings - Fork 11
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #696 from cordada/feature/add-pydantic-rut-field
extras: Add Pydantic type for `Rut`
- Loading branch information
Showing
3 changed files
with
246 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. | ||
""" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |