Skip to content

Commit 8c5a07c

Browse files
authored
Merge branch 'main' into main
2 parents a573fe6 + 5ed9422 commit 8c5a07c

29 files changed

+1946
-650
lines changed

.github/dependabot.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ updates:
99
commit-message:
1010
prefix:
1111
# Python
12-
- package-ecosystem: "pip"
12+
- package-ecosystem: "uv"
1313
directory: "/"
1414
schedule:
1515
interval: "monthly"

.github/workflows/ci.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
steps:
2020
- uses: actions/checkout@v4
2121

22-
- uses: astral-sh/setup-uv@v5
22+
- uses: astral-sh/setup-uv@v6
2323
with:
2424
enable-cache: true
2525

@@ -47,7 +47,7 @@ jobs:
4747
steps:
4848
- uses: actions/checkout@v4
4949

50-
- uses: astral-sh/setup-uv@v5
50+
- uses: astral-sh/setup-uv@v6
5151
with:
5252
enable-cache: true
5353

@@ -74,7 +74,7 @@ jobs:
7474
steps:
7575
- uses: actions/checkout@v4
7676

77-
- uses: astral-sh/setup-uv@v5
77+
- uses: astral-sh/setup-uv@v6
7878
with:
7979
enable-cache: true
8080

@@ -111,7 +111,7 @@ jobs:
111111
steps:
112112
- uses: actions/checkout@v4
113113

114-
- uses: astral-sh/setup-uv@v5
114+
- uses: astral-sh/setup-uv@v6
115115
with:
116116
enable-cache: true
117117

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ sources = pydantic_extra_types tests
77

88
.PHONY: install ## Install the package, dependencies, and pre-commit for local development
99
install: .uv
10-
uv sync --frozen --group all --all-extras
10+
uv sync --frozen --all-groups --all-extras
1111
uv pip install pre-commit
12-
pre-commit install --install-hooks
12+
uv run pre-commit install --install-hooks
1313

1414
.PHONY: rebuild-lockfiles ## Rebuild lockfiles from scratch, updating all dependencies
1515
rebuild-lockfiles: .uv

pydantic_extra_types/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '2.10.2'
1+
__version__ = '2.10.5'

pydantic_extra_types/color.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,7 @@ def parse_color_value(value: int | str, max_val: int = 255) -> float:
354354
"""
355355
try:
356356
color = float(value)
357-
except ValueError as e:
357+
except (ValueError, TypeError) as e:
358358
raise PydanticCustomError(
359359
'color_error',
360360
'value is not a valid color: color values must be a valid number',

pydantic_extra_types/coordinate.py

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,25 @@
66
from __future__ import annotations
77

88
from dataclasses import dataclass
9-
from typing import Any, ClassVar, Tuple
9+
from decimal import Decimal
10+
from typing import Any, ClassVar, Tuple, Union
1011

1112
from pydantic import GetCoreSchemaHandler
1213
from pydantic._internal import _repr
1314
from pydantic_core import ArgsKwargs, PydanticCustomError, core_schema
1415

16+
LatitudeType = Union[float, Decimal]
17+
LongitudeType = Union[float, Decimal]
18+
CoordinateType = Tuple[LatitudeType, LongitudeType]
19+
1520

1621
class Latitude(float):
1722
"""Latitude value should be between -90 and 90, inclusive.
1823
24+
Supports both float and Decimal types.
25+
1926
```py
27+
from decimal import Decimal
2028
from pydantic import BaseModel
2129
from pydantic_extra_types.coordinate import Latitude
2230
@@ -25,9 +33,10 @@ class Location(BaseModel):
2533
latitude: Latitude
2634
2735
28-
location = Location(latitude=41.40338)
29-
print(location)
30-
# > latitude=41.40338
36+
# Using float
37+
location1 = Location(latitude=41.40338)
38+
# Using Decimal
39+
location2 = Location(latitude=Decimal('41.40338'))
3140
```
3241
"""
3342

@@ -36,13 +45,21 @@ class Location(BaseModel):
3645

3746
@classmethod
3847
def __get_pydantic_core_schema__(cls, source: type[Any], handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
39-
return core_schema.float_schema(ge=cls.min, le=cls.max)
48+
return core_schema.union_schema(
49+
[
50+
core_schema.float_schema(ge=cls.min, le=cls.max),
51+
core_schema.decimal_schema(ge=Decimal(cls.min), le=Decimal(cls.max)),
52+
]
53+
)
4054

4155

4256
class Longitude(float):
4357
"""Longitude value should be between -180 and 180, inclusive.
4458
59+
Supports both float and Decimal types.
60+
4561
```py
62+
from decimal import Decimal
4663
from pydantic import BaseModel
4764
4865
from pydantic_extra_types.coordinate import Longitude
@@ -52,9 +69,10 @@ class Location(BaseModel):
5269
longitude: Longitude
5370
5471
55-
location = Location(longitude=2.17403)
56-
print(location)
57-
# > longitude=2.17403
72+
# Using float
73+
location1 = Location(longitude=2.17403)
74+
# Using Decimal
75+
location2 = Location(longitude=Decimal('2.17403'))
5876
```
5977
"""
6078

@@ -63,7 +81,12 @@ class Location(BaseModel):
6381

6482
@classmethod
6583
def __get_pydantic_core_schema__(cls, source: type[Any], handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
66-
return core_schema.float_schema(ge=cls.min, le=cls.max)
84+
return core_schema.union_schema(
85+
[
86+
core_schema.float_schema(ge=cls.min, le=cls.max),
87+
core_schema.decimal_schema(ge=Decimal(cls.min), le=Decimal(cls.max)),
88+
]
89+
)
6790

6891

6992
@dataclass
@@ -73,10 +96,11 @@ class Coordinate(_repr.Representation):
7396
You can use the `Coordinate` data type for storing coordinates. Coordinates can be
7497
defined using one of the following formats:
7598
76-
1. Tuple: `(Latitude, Longitude)`. For example: `(41.40338, 2.17403)`.
99+
1. Tuple: `(Latitude, Longitude)`. For example: `(41.40338, 2.17403)` or `(Decimal('41.40338'), Decimal('2.17403'))`.
77100
2. `Coordinate` instance: `Coordinate(latitude=Latitude, longitude=Longitude)`.
78101
79102
```py
103+
from decimal import Decimal
80104
from pydantic import BaseModel
81105
82106
from pydantic_extra_types.coordinate import Coordinate
@@ -86,7 +110,12 @@ class Location(BaseModel):
86110
coordinate: Coordinate
87111
88112
89-
location = Location(coordinate=(41.40338, 2.17403))
113+
# Using float values
114+
location1 = Location(coordinate=(41.40338, 2.17403))
115+
# > coordinate=Coordinate(latitude=41.40338, longitude=2.17403)
116+
117+
# Using Decimal values
118+
location2 = Location(coordinate=(Decimal('41.40338'), Decimal('2.17403')))
90119
# > coordinate=Coordinate(latitude=41.40338, longitude=2.17403)
91120
```
92121
"""
@@ -102,7 +131,7 @@ def __get_pydantic_core_schema__(cls, source: type[Any], handler: GetCoreSchemaH
102131
core_schema.no_info_wrap_validator_function(cls._parse_str, core_schema.str_schema()),
103132
core_schema.no_info_wrap_validator_function(
104133
cls._parse_tuple,
105-
handler.generate_schema(Tuple[float, float]),
134+
handler.generate_schema(CoordinateType),
106135
),
107136
handler(source),
108137
]

pydantic_extra_types/domain.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
class DomainStr(str):
1515
"""A string subclass with custom validation for domain string format."""
1616

17+
_domain_re_pattern = (
18+
r'(?=^.{1,253}$)' r'(^((?!-)[a-zA-Z0-9-]{1,63}(?<!-)\.)+' r'([a-zA-Z]{2,63}|xn--[a-zA-Z0-9]{2,59})$)'
19+
)
20+
1721
@classmethod
1822
def validate(cls, __input_value: Any, _: Any) -> str:
1923
"""Validate a domain name from the provided value.
@@ -37,8 +41,7 @@ def _validate(cls, v: Any) -> DomainStr:
3741
if len(v) < 1 or len(v) > 253:
3842
raise PydanticCustomError('domain_length', 'Domain must be between 1 and 253 characters')
3943

40-
pattern = r'^([a-z0-9-]+(\.[a-z0-9-]+)+)$'
41-
if not re.match(pattern, v):
44+
if not re.match(cls._domain_re_pattern, v):
4245
raise PydanticCustomError('domain_format', 'Invalid domain format')
4346

4447
return cls(v)

pydantic_extra_types/isbn.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from __future__ import annotations
77

8+
import itertools as it
89
from typing import Any
910

1011
from pydantic import GetCoreSchemaHandler
@@ -21,11 +22,8 @@ def isbn10_digit_calc(isbn: str) -> str:
2122
The calculated last digit of the ISBN-10 value.
2223
"""
2324
total = sum(int(digit) * (10 - idx) for idx, digit in enumerate(isbn[:9]))
24-
25-
for check_digit in range(1, 11):
26-
if (total + check_digit) % 11 == 0:
27-
valid_check_digit = 'X' if check_digit == 10 else str(check_digit)
28-
25+
diff = (11 - total) % 11
26+
valid_check_digit = 'X' if diff == 10 else str(diff)
2927
return valid_check_digit
3028

3129

@@ -38,9 +36,9 @@ def isbn13_digit_calc(isbn: str) -> str:
3836
Returns:
3937
The calculated last digit of the ISBN-13 value.
4038
"""
41-
total = sum(int(digit) * (1 if idx % 2 == 0 else 3) for idx, digit in enumerate(isbn[:12]))
39+
total = sum(int(digit) * factor for digit, factor in zip(isbn[:12], it.cycle((1, 3))))
4240

43-
check_digit = (10 - (total % 10)) % 10
41+
check_digit = (10 - total) % 10
4442

4543
return str(check_digit)
4644

pydantic_extra_types/mac_address.py

Lines changed: 27 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -63,58 +63,36 @@ def _validate(cls, __input_value: str, _: Any) -> str:
6363
@staticmethod
6464
def validate_mac_address(value: bytes) -> str:
6565
"""Validate a MAC Address from the provided byte value."""
66-
if len(value) < 14:
66+
string = value.decode()
67+
if len(string) < 14:
6768
raise PydanticCustomError(
6869
'mac_address_len',
6970
'Length for a {mac_address} MAC address must be {required_length}',
70-
{'mac_address': value.decode(), 'required_length': 14},
71+
{'mac_address': string, 'required_length': 14},
7172
)
72-
73-
if value[2] in [ord(':'), ord('-')]:
74-
if (len(value) + 1) % 3 != 0:
75-
raise PydanticCustomError(
76-
'mac_address_format', 'Must have the format xx:xx:xx:xx:xx:xx or xx-xx-xx-xx-xx-xx'
77-
)
78-
n = (len(value) + 1) // 3
79-
if n not in (6, 8, 20):
80-
raise PydanticCustomError(
81-
'mac_address_format',
82-
'Length for a {mac_address} MAC address must be {required_length}',
83-
{'mac_address': value.decode(), 'required_length': (6, 8, 20)},
84-
)
85-
mac_address = bytearray(n)
86-
x = 0
87-
for i in range(n):
88-
try:
89-
byte_value = int(value[x : x + 2], 16)
90-
mac_address[i] = byte_value
91-
x += 3
92-
except ValueError as e:
93-
raise PydanticCustomError('mac_address_format', 'Unrecognized format') from e
94-
95-
elif value[4] == ord('.'):
96-
if (len(value) + 1) % 5 != 0:
97-
raise PydanticCustomError('mac_address_format', 'Must have the format xx.xx.xx.xx.xx.xx')
98-
n = 2 * (len(value) + 1) // 5
99-
if n not in (6, 8, 20):
100-
raise PydanticCustomError(
101-
'mac_address_format',
102-
'Length for a {mac_address} MAC address must be {required_length}',
103-
{'mac_address': value.decode(), 'required_length': (6, 8, 20)},
104-
)
105-
mac_address = bytearray(n)
106-
x = 0
107-
for i in range(0, n, 2):
108-
try:
109-
byte_value = int(value[x : x + 2], 16)
110-
mac_address[i] = byte_value
111-
byte_value = int(value[x + 2 : x + 4], 16)
112-
mac_address[i + 1] = byte_value
113-
x += 5
114-
except ValueError as e:
115-
raise PydanticCustomError('mac_address_format', 'Unrecognized format') from e
116-
73+
for sep, partbytes in ((':', 2), ('-', 2), ('.', 4)):
74+
if sep in string:
75+
parts = string.split(sep)
76+
if any(len(part) != partbytes for part in parts):
77+
raise PydanticCustomError(
78+
'mac_address_format',
79+
f'Must have the format xx{sep}xx{sep}xx{sep}xx{sep}xx{sep}xx',
80+
)
81+
if len(parts) * partbytes // 2 not in (6, 8, 20):
82+
raise PydanticCustomError(
83+
'mac_address_format',
84+
'Length for a {mac_address} MAC address must be {required_length}',
85+
{'mac_address': string, 'required_length': (6, 8, 20)},
86+
)
87+
mac_address = []
88+
for part in parts:
89+
for idx in range(0, partbytes, 2):
90+
try:
91+
byte_value = int(part[idx : idx + 2], 16)
92+
except ValueError as exc:
93+
raise PydanticCustomError('mac_address_format', 'Unrecognized format') from exc
94+
else:
95+
mac_address.append(byte_value)
96+
return ':'.join(f'{b:02x}' for b in mac_address)
11797
else:
11898
raise PydanticCustomError('mac_address_format', 'Unrecognized format')
119-
120-
return ':'.join(f'{b:02x}' for b in mac_address)

0 commit comments

Comments
 (0)