Skip to content

Commit 945ee65

Browse files
committed
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]
1 parent d3c0fd1 commit 945ee65

File tree

3 files changed

+246
-0
lines changed

3 files changed

+246
-0
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ dynamic = ["version"]
5252
django = ["Django>=2.2.24"]
5353
django-filter = ["django-filter>=24.2"]
5454
djangorestframework = ["djangorestframework>=3.10.3,<3.16"]
55+
pydantic = ["pydantic>=2.0"]
5556

5657
[project.urls]
5758
Homepage = "https://github.com/fyntex/lib-cl-sii-python"
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""
2+
cl_sii "extras" / Pydantic types.
3+
"""
4+
5+
from __future__ import annotations
6+
7+
import sys
8+
from typing import Any
9+
10+
11+
if sys.version_info[:2] >= (3, 9):
12+
from typing import Annotated
13+
else:
14+
from typing_extensions import Annotated
15+
16+
try:
17+
import pydantic
18+
import pydantic.json_schema
19+
except ImportError as exc: # pragma: no cover
20+
raise ImportError("Package 'pydantic' is required to use this module.") from exc
21+
22+
try:
23+
import pydantic_core
24+
except ImportError as exc: # pragma: no cover
25+
raise ImportError("Package 'pydantic-core' is required to use this module.") from exc
26+
27+
import cl_sii.rut
28+
import cl_sii.rut.constants
29+
30+
31+
class _RutPydanticAnnotation:
32+
"""
33+
`Annotated` wrapper that can be used as the annotation for `cl_sii.rut.Rut`
34+
fields on `pydantic.BaseModels`, `@pydantic.dataclasses`, etc.
35+
36+
.. seealso::
37+
- Handling third-party types:
38+
https://docs.pydantic.dev/2.9/concepts/types/#handling-third-party-types
39+
(https://github.com/pydantic/pydantic/blob/v2.9.2/docs/concepts/types.md#handling-third-party-types)
40+
- Customizing the core schema and JSON schema:
41+
https://docs.pydantic.dev/2.9/architecture/#customizing-the-core-schema-and-json-schema
42+
(https://github.com/pydantic/pydantic/blob/v2.9.2/docs/architecture.md#customizing-the-core-schema-and-json-schema)
43+
44+
Examples:
45+
46+
>>> from typing import Annotated
47+
>>> import pydantic
48+
>>> import cl_sii.rut
49+
50+
>>> Rut = Annotated[cl_sii.rut.Rut, _RutPydanticAnnotation]
51+
52+
>>> class ExampleModel(pydantic.BaseModel):
53+
... rut: Rut
54+
>>>
55+
>>> example_model_instance = ExampleModel.model_validate({'rut': '78773510-K'})
56+
57+
>>> import pydantic.dataclasses
58+
>>>
59+
>>> @pydantic.dataclasses.dataclass
60+
... class ExampleDataclass:
61+
... rut: Rut
62+
>>>
63+
>>> example_dataclass_instance = ExampleDataclass(rut='78773510-K')
64+
65+
>>> example_type_adapter = pydantic.TypeAdapter(Rut)
66+
>>>
67+
>>> example_type_adapter.validate_python('78773510-K')
68+
Rut('78773510-K')
69+
>>> example_type_adapter.validate_json('"78773510-K"')
70+
Rut('78773510-K')
71+
>>> example_type_adapter.dump_python(cl_sii.rut.Rut('78773510-K'))
72+
'78773510-K'
73+
>>> example_type_adapter.dump_json(cl_sii.rut.Rut('78773510-K'))
74+
b'"78773510-K"'
75+
"""
76+
77+
@classmethod
78+
def __get_pydantic_core_schema__(
79+
cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
80+
) -> pydantic_core.core_schema.CoreSchema:
81+
def validate_from_str(value: str) -> cl_sii.rut.Rut:
82+
return cl_sii.rut.Rut(value, validate_dv=False)
83+
84+
from_str_schema = pydantic_core.core_schema.chain_schema(
85+
[
86+
pydantic_core.core_schema.str_schema(
87+
pattern=cl_sii.rut.constants.RUT_CANONICAL_STRICT_REGEX.pattern
88+
),
89+
pydantic_core.core_schema.no_info_plain_validator_function(validate_from_str),
90+
]
91+
)
92+
93+
return pydantic_core.core_schema.json_or_python_schema(
94+
json_schema=from_str_schema,
95+
python_schema=pydantic_core.core_schema.union_schema(
96+
[
97+
pydantic_core.core_schema.is_instance_schema(cl_sii.rut.Rut),
98+
from_str_schema,
99+
]
100+
),
101+
serialization=pydantic_core.core_schema.plain_serializer_function_ser_schema(
102+
lambda instance: instance.canonical
103+
),
104+
)
105+
106+
107+
Rut = Annotated[cl_sii.rut.Rut, _RutPydanticAnnotation]
108+
"""
109+
Convenience type alias for Pydantic fields that represent Chilean RUTs.
110+
"""
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import unittest
5+
from typing import ClassVar
6+
7+
import pydantic
8+
9+
from cl_sii.extras.pydantic_types import Rut as PydanticRut
10+
from cl_sii.rut import Rut
11+
12+
13+
class PydanticRutTest(unittest.TestCase):
14+
"""
15+
Tests for :class:`PydanticRut`.
16+
"""
17+
18+
ThirdPartyType: ClassVar[type[Rut]]
19+
PydanticThirdPartyType: ClassVar[type[PydanticRut]]
20+
pydantic_type_adapter: ClassVar[pydantic.TypeAdapter]
21+
valid_instance_1: ClassVar[Rut]
22+
valid_instance_2: ClassVar[Rut]
23+
24+
@classmethod
25+
def setUpClass(cls) -> None:
26+
super().setUpClass()
27+
28+
cls.ThirdPartyType = Rut
29+
cls.PydanticThirdPartyType = PydanticRut
30+
cls.pydantic_type_adapter = pydantic.TypeAdapter(cls.PydanticThirdPartyType)
31+
32+
cls.valid_instance_1 = cls.ThirdPartyType('78773510-K')
33+
assert isinstance(cls.valid_instance_1, cls.ThirdPartyType)
34+
35+
cls.valid_instance_2 = cls.ThirdPartyType('77004430-8')
36+
assert isinstance(cls.valid_instance_2, cls.ThirdPartyType)
37+
38+
def test_serialize_to_python(self) -> None:
39+
# -----Arrange-----
40+
41+
instance = self.valid_instance_1
42+
expected_serialized_value = '78773510-K'
43+
44+
# -----Act-----
45+
46+
actual_serialized_value = self.pydantic_type_adapter.dump_python(instance)
47+
48+
# -----Assert-----
49+
50+
self.assertEqual(expected_serialized_value, actual_serialized_value)
51+
52+
def test_serialize_to_json(self) -> None:
53+
# -----Arrange-----
54+
55+
instance = self.valid_instance_1
56+
57+
expected_serialized_value = b'"78773510-K"'
58+
self.assertEqual(
59+
expected_serialized_value, json.dumps(json.loads(expected_serialized_value)).encode()
60+
)
61+
62+
# -----Act-----
63+
64+
actual_serialized_value = self.pydantic_type_adapter.dump_json(instance)
65+
66+
# -----Assert-----
67+
68+
self.assertEqual(expected_serialized_value, actual_serialized_value)
69+
70+
def test_deserialize_from_instance(self) -> None:
71+
# -----Arrange-----
72+
73+
obj = self.valid_instance_2
74+
expected_deserialized_value = self.valid_instance_2
75+
76+
# -----Act-----
77+
78+
actual_deserialized_value = self.pydantic_type_adapter.validate_python(obj)
79+
80+
# -----Assert-----
81+
82+
self.assertEqual(expected_deserialized_value, actual_deserialized_value)
83+
84+
def test_deserialize_from_python(self) -> None:
85+
# -----Arrange-----
86+
87+
obj = '78773510-K'
88+
expected_deserialized_value = self.valid_instance_1
89+
90+
# -----Act-----
91+
92+
actual_deserialized_value = self.pydantic_type_adapter.validate_python(obj)
93+
94+
# -----Assert-----
95+
96+
self.assertEqual(expected_deserialized_value, actual_deserialized_value)
97+
98+
def test_deserialize_from_json(self) -> None:
99+
# -----Arrange-----
100+
101+
data = '"78773510-K"'
102+
self.assertEqual(data, json.dumps(json.loads(data)))
103+
104+
expected_deserialized_value = self.valid_instance_1
105+
106+
# -----Act-----
107+
108+
actual_deserialized_value = self.pydantic_type_adapter.validate_json(data)
109+
110+
# -----Assert-----
111+
112+
self.assertEqual(expected_deserialized_value, actual_deserialized_value)
113+
114+
def test_deserialize_invalid(self) -> None:
115+
test_items = [
116+
78773510,
117+
-78773510,
118+
'78773510-k',
119+
'78.773.510-K',
120+
'78773510-X',
121+
'-78773510-K',
122+
True,
123+
None,
124+
]
125+
126+
for test_item in test_items:
127+
obj = test_item
128+
data = json.dumps(test_item)
129+
130+
with self.subTest(item=test_item):
131+
with self.assertRaises(pydantic.ValidationError):
132+
self.pydantic_type_adapter.validate_python(obj)
133+
134+
with self.assertRaises(pydantic.ValidationError):
135+
self.pydantic_type_adapter.validate_json(data)

0 commit comments

Comments
 (0)