Skip to content

Commit a973b79

Browse files
authored
✨ add ulib type (#73)
Add `Latitude`, `Longitude` and `Coordinate` (#76) * feat: add latitude, longitude and coordinate * refactor: apply feedbacks * refactor: apply feedbacks * refactor: delete __init__ functions * fix: coordinate parsing * docs: update coordinate documentation * refactor: use latitude, longitude in schema * 🚧 Some improvements for `Coordinate` type PR (#2) * refactor: delete __init__ functions * 🚧 Some improvements for `Coordinate` type PR * Get tests passing * ✨ Test serialization json schema * ⬆ Upgrade deps in `pyproject.toml` and `requirements/pyproject.txt --------- Co-authored-by: JeanArhancet <[email protected]> Co-authored-by: David Montague <[email protected]> * fix: test and requirements * docs: fix supported format --------- Co-authored-by: Serge Matveenko <[email protected]> Co-authored-by: David Montague <[email protected]> ✨ add ulib type refactor: delete init function
1 parent 0c42c50 commit a973b79

File tree

5 files changed

+160
-0
lines changed

5 files changed

+160
-0
lines changed

pydantic_extra_types/ulid.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""
2+
The `pydantic_extra_types.ULID` module provides the [`ULID`] data type.
3+
4+
This class depends on the [python-ulid] package, which is a validate by the [ULID-spec](https://github.com/ulid/spec#implementations-in-other-languages).
5+
"""
6+
from __future__ import annotations
7+
8+
from dataclasses import dataclass
9+
from typing import Any, Union
10+
11+
from pydantic import GetCoreSchemaHandler
12+
from pydantic._internal import _repr
13+
from pydantic_core import PydanticCustomError, core_schema
14+
15+
try:
16+
from ulid import ULID as _ULID
17+
except ModuleNotFoundError: # pragma: no cover
18+
raise RuntimeError(
19+
'The `ulid` module requires "python-ulid" to be installed. You can install it with "pip install python-ulid".'
20+
)
21+
22+
UlidType = Union[str, bytes, int]
23+
24+
25+
@dataclass
26+
class ULID(_repr.Representation):
27+
"""
28+
A wrapper around [python-ulid](https://pypi.org/project/python-ulid/) package, which
29+
is a validate by the [ULID-spec](https://github.com/ulid/spec#implementations-in-other-languages).
30+
"""
31+
32+
ulid: _ULID
33+
34+
@classmethod
35+
def __get_pydantic_core_schema__(cls, source: type[Any], handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
36+
return core_schema.no_info_wrap_validator_function(
37+
cls._validate_ulid,
38+
core_schema.union_schema(
39+
[
40+
core_schema.is_instance_schema(_ULID),
41+
core_schema.int_schema(),
42+
core_schema.bytes_schema(),
43+
core_schema.str_schema(),
44+
]
45+
),
46+
)
47+
48+
@classmethod
49+
def _validate_ulid(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> Any:
50+
ulid: _ULID
51+
try:
52+
if isinstance(value, int):
53+
ulid = _ULID.from_int(value)
54+
elif isinstance(value, str):
55+
ulid = _ULID.from_str(value)
56+
elif isinstance(value, _ULID):
57+
ulid = value
58+
else:
59+
ulid = _ULID.from_bytes(value)
60+
except ValueError:
61+
raise PydanticCustomError('ulid_format', 'Unrecognized format')
62+
return handler(ulid)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ dynamic = ['version']
4747
all = [
4848
'phonenumbers>=8,<9',
4949
'pycountry>=22,<23',
50+
'python-ulid>=1,<2',
5051
]
5152

5253
[project.urls]

requirements/pyproject.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ pydantic==2.0.3
1414
# via pydantic-extra-types (pyproject.toml)
1515
pydantic-core==2.3.0
1616
# via pydantic
17+
python-ulid==1.1.0
18+
# via pydantic-extra-types (pyproject.toml)
1719
typing-extensions==4.6.3
1820
# via
1921
# pydantic

tests/test_json_schema.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
)
1313
from pydantic_extra_types.mac_address import MacAddress
1414
from pydantic_extra_types.payment import PaymentCardNumber
15+
from pydantic_extra_types.ulid import ULID
1516

1617

1718
@pytest.mark.parametrize(
@@ -170,6 +171,20 @@
170171
'type': 'object',
171172
},
172173
),
174+
(
175+
ULID,
176+
{
177+
'properties': {
178+
'x': {
179+
'anyOf': [{'type': 'integer'}, {'format': 'binary', 'type': 'string'}, {'type': 'string'}],
180+
'title': 'X',
181+
}
182+
},
183+
'required': ['x'],
184+
'title': 'Model',
185+
'type': 'object',
186+
},
187+
),
173188
],
174189
)
175190
def test_json_schema(cls, expected):

tests/test_ulid.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
from datetime import datetime, timezone
2+
from typing import Any
3+
4+
import pytest
5+
from pydantic import BaseModel, ValidationError
6+
7+
from pydantic_extra_types.ulid import ULID
8+
9+
try:
10+
from ulid import ULID as _ULID
11+
except ModuleNotFoundError: # pragma: no cover
12+
raise RuntimeError(
13+
'The `ulid` module requires "python-ulid" to be installed. You can install it with "pip install python-ulid".'
14+
)
15+
16+
17+
class Something(BaseModel):
18+
ulid: ULID
19+
20+
21+
@pytest.mark.parametrize(
22+
'ulid, result, valid',
23+
[
24+
# Valid ULID for str format
25+
('01BTGNYV6HRNK8K8VKZASZCFPE', '01BTGNYV6HRNK8K8VKZASZCFPE', True),
26+
('01BTGNYV6HRNK8K8VKZASZCFPF', '01BTGNYV6HRNK8K8VKZASZCFPF', True),
27+
# Invalid ULID for str format
28+
('01BTGNYV6HRNK8K8VKZASZCFP', None, False), # Invalid ULID (short length)
29+
('01BTGNYV6HRNK8K8VKZASZCFPEA', None, False), # Invalid ULID (long length)
30+
# Valid ULID for _ULID format
31+
(_ULID.from_str('01BTGNYV6HRNK8K8VKZASZCFPE'), '01BTGNYV6HRNK8K8VKZASZCFPE', True),
32+
(_ULID.from_str('01BTGNYV6HRNK8K8VKZASZCFPF'), '01BTGNYV6HRNK8K8VKZASZCFPF', True),
33+
# Invalid _ULID for bytes format
34+
(b'\x01\xBA\x1E\xB2\x8A\x9F\xFAy\x10\xD5\xA5k\xC8', None, False), # Invalid ULID (short length)
35+
(b'\x01\xBA\x1E\xB2\x8A\x9F\xFAy\x10\xD5\xA5k\xC8\xB6\x00', None, False), # Invalid ULID (long length)
36+
# Valid ULID for int format
37+
(109667145845879622871206540411193812282, '2JG4FVY7N8XS4GFVHPXGJZ8S9T', True),
38+
(109667145845879622871206540411193812283, '2JG4FVY7N8XS4GFVHPXGJZ8S9V', True),
39+
(109667145845879622871206540411193812284, '2JG4FVY7N8XS4GFVHPXGJZ8S9W', True),
40+
],
41+
)
42+
def test_format_for_ulid(ulid: Any, result: Any, valid: bool):
43+
if valid:
44+
assert str(Something(ulid=ulid).ulid) == result
45+
else:
46+
with pytest.raises(ValidationError, match='format'):
47+
Something(ulid=ulid)
48+
49+
50+
def test_property_for_ulid():
51+
ulid = Something(ulid='01BTGNYV6HRNK8K8VKZASZCFPE').ulid
52+
assert ulid.hex == '015ea15f6cd1c56689a373fab3f63ece'
53+
assert ulid == '01BTGNYV6HRNK8K8VKZASZCFPE'
54+
assert ulid.datetime == datetime(2017, 9, 20, 22, 18, 59, 153000, tzinfo=timezone.utc)
55+
assert ulid.timestamp == 1505945939.153
56+
57+
58+
def test_json_schema():
59+
assert Something.model_json_schema(mode='validation') == {
60+
'properties': {
61+
'ulid': {
62+
'anyOf': [{'type': 'integer'}, {'format': 'binary', 'type': 'string'}, {'type': 'string'}],
63+
'title': 'Ulid',
64+
}
65+
},
66+
'required': ['ulid'],
67+
'title': 'Something',
68+
'type': 'object',
69+
}
70+
assert Something.model_json_schema(mode='serialization') == {
71+
'properties': {
72+
'ulid': {
73+
'anyOf': [{'type': 'integer'}, {'format': 'binary', 'type': 'string'}, {'type': 'string'}],
74+
'title': 'Ulid',
75+
}
76+
},
77+
'required': ['ulid'],
78+
'title': 'Something',
79+
'type': 'object',
80+
}

0 commit comments

Comments
 (0)