1
0
Fork 0

Merging upstream version 2.8.0.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-02-05 14:04:19 +01:00
parent da56cb4f32
commit 32cf2f5756
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
18 changed files with 341 additions and 77 deletions

View file

@ -2,6 +2,27 @@
## Latest Changes
## 2.8.0
### Refactor
* ♻️ refactor some functions & minor changes. [#180](https://github.com/pydantic/pydantic-extra-types/pull/180) by [@yezz123](https://github.com/yezz123)
### Internal
* Allow requiring extra dependencies. [#178](https://github.com/pydantic/pydantic-extra-types/pull/178) by [@yezz123](https://github.com/yezz123)
### Types
* Add ISO 15924 and tests. [#174](https://github.com/pydantic/pydantic-extra-types/pull/174) by [@07pepa](https://github.com/07pepa)
* add native datetime to pendulum_dt.py. [#176](https://github.com/pydantic/pydantic-extra-types/pull/176) by [@07pepa](https://github.com/07pepa)
* add hash and eq to phone_numbers. [#177](https://github.com/pydantic/pydantic-extra-types/pull/177) by [@07pepa](https://github.com/07pepa)
### Dependencies
* ⬆ Bump the python-packages group with 5 updates. PR [#179](https://github.com/pydantic/pydantic-extra-types/pull/179) by @dependabot
* ⬆ Bump the python-packages group with 4 updates. PR [#171](https://github.com/pydantic/pydantic-extra-types/pull/171) by @dependabot
## 2.7.0
* 🔥 Remove latest-changes workflow. PR [#165](https://github.com/pydantic/pydantic-extra-types/pull/165) by [yezz123](https://github.com/yezz123)

View file

@ -1 +1 @@
__version__ = '2.7.0'
__version__ = '2.8.0'

View file

@ -121,17 +121,16 @@ class Color(_repr.Representation):
Raises:
ValueError: When no named color is found and fallback is `False`.
"""
if self._rgba.alpha is None:
rgb = cast(Tuple[int, int, int], self.as_rgb_tuple())
try:
return COLORS_BY_VALUE[rgb]
except KeyError as e:
if fallback:
return self.as_hex()
else:
raise ValueError('no named color found, use fallback=True, as_hex() or as_rgb()') from e
else:
if self._rgba.alpha is not None:
return self.as_hex()
rgb = cast(Tuple[int, int, int], self.as_rgb_tuple())
try:
return COLORS_BY_VALUE[rgb]
except KeyError as e:
if fallback:
return self.as_hex()
else:
raise ValueError('no named color found, use fallback=True, as_hex() or as_rgb()') from e
def as_hex(self, format: Literal['short', 'long'] = 'short') -> str:
"""Returns the hexadecimal representation of the color.
@ -149,7 +148,7 @@ class Color(_repr.Representation):
as_hex = ''.join(f'{v:02x}' for v in values)
if format == 'short' and all(c in repeat_colors for c in values):
as_hex = ''.join(as_hex[c] for c in range(0, len(as_hex), 2))
return '#' + as_hex
return f'#{as_hex}'
def as_rgb(self) -> str:
"""
@ -179,16 +178,10 @@ class Color(_repr.Representation):
If alpha is included, it is in the range 0 to 1.
"""
r, g, b = (float_to_255(c) for c in self._rgba[:3])
if alpha is None:
if self._rgba.alpha is None:
return r, g, b
else:
return r, g, b, self._alpha_float()
elif alpha:
return r, g, b, self._alpha_float()
else:
# alpha is False
if alpha is None and self._rgba.alpha is None or alpha is not None and not alpha:
return r, g, b
else:
return r, g, b, self._alpha_float()
def as_hsl(self) -> str:
"""
@ -225,11 +218,7 @@ class Color(_repr.Representation):
return h, s, l
else:
return h, s, l, self._alpha_float()
if alpha:
return h, s, l, self._alpha_float()
else:
# alpha is False
return h, s, l
return (h, s, l, self._alpha_float()) if alpha else (h, s, l)
def _alpha_float(self) -> float:
return 1 if self._rgba.alpha is None else self._rgba.alpha
@ -315,20 +304,14 @@ def parse_str(value: str) -> RGBA:
if m:
*rgb, a = m.groups()
r, g, b = (int(v * 2, 16) for v in rgb)
if a:
alpha: float | None = int(a * 2, 16) / 255
else:
alpha = None
alpha = int(a * 2, 16) / 255 if a else None
return ints_to_rgba(r, g, b, alpha)
m = re.fullmatch(r_hex_long, value_lower)
if m:
*rgb, a = m.groups()
r, g, b = (int(v, 16) for v in rgb)
if a:
alpha = int(a, 16) / 255
else:
alpha = None
alpha = int(a, 16) / 255 if a else None
return ints_to_rgba(r, g, b, alpha)
m = re.fullmatch(r_rgb, value_lower) or re.fullmatch(r_rgb_v4_style, value_lower)
@ -390,11 +373,11 @@ def parse_color_value(value: int | str, max_val: int = 255) -> float:
"""
try:
color = float(value)
except ValueError:
except ValueError as e:
raise PydanticCustomError(
'color_error',
'value is not a valid color: color values must be a valid number',
)
) from e
if 0 <= color <= max_val:
return color / max_val
else:
@ -425,11 +408,11 @@ def parse_float_alpha(value: None | str | float | int) -> float | None:
alpha = float(value[:-1]) / 100
else:
alpha = float(value)
except ValueError:
except ValueError as e:
raise PydanticCustomError(
'color_error',
'value is not a valid color: alpha values must be a valid float',
)
) from e
if math.isclose(alpha, 1):
return None
@ -465,7 +448,7 @@ def parse_hsl(h: str, h_units: str, sat: str, light: str, alpha: float | None =
h_value = h_value % rads / rads
else:
# turns
h_value = h_value % 1
h_value %= 1
r, g, b = hls_to_rgb(h_value, l_value, s_value)
return RGBA(r, g, b, parse_float_alpha(alpha))

View file

@ -123,18 +123,16 @@ class Coordinate(_repr.Representation):
return value
try:
value = tuple(float(x) for x in value.split(','))
except ValueError:
except ValueError as e:
raise PydanticCustomError(
'coordinate_error',
'value is not a valid coordinate: string is not recognized as a valid coordinate',
)
) from e
return ArgsKwargs(args=value)
@classmethod
def _parse_tuple(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> Any:
if not isinstance(value, tuple):
return value
return ArgsKwargs(args=handler(value))
return ArgsKwargs(args=handler(value)) if isinstance(value, tuple) else value
def __str__(self) -> str:
return f'{self.latitude},{self.longitude}'

View file

@ -13,10 +13,10 @@ from pydantic_core import PydanticCustomError, core_schema
try:
import pycountry
except ModuleNotFoundError: # pragma: no cover
except ModuleNotFoundError as e: # pragma: no cover
raise RuntimeError(
'The `country` module requires "pycountry" to be installed. You can install it with "pip install pycountry".'
)
) from e
@dataclass

View file

@ -11,11 +11,11 @@ from pydantic_core import PydanticCustomError, core_schema
try:
import pycountry
except ModuleNotFoundError: # pragma: no cover
except ModuleNotFoundError as e: # pragma: no cover
raise RuntimeError(
'The `currency_code` module requires "pycountry" to be installed. You can install it with "pip install '
'pycountry".'
)
) from e
# List of codes that should not be usually used within regular transactions
_CODES_FOR_BONDS_METAL_TESTING = {

View file

@ -13,11 +13,11 @@ from pydantic_core import PydanticCustomError, core_schema
try:
import pycountry
except ModuleNotFoundError: # pragma: no cover
except ModuleNotFoundError as e: # pragma: no cover
raise RuntimeError(
'The `language_code` module requires "pycountry" to be installed.'
' You can install it with "pip install pycountry".'
)
) from e
@dataclass

View file

@ -3,15 +3,18 @@ Native Pendulum DateTime object implementation. This is a copy of the Pendulum D
CoreSchema implementation. This allows Pydantic to validate the DateTime object.
"""
import pendulum
try:
from pendulum import Date as _Date
from pendulum import DateTime as _DateTime
from pendulum import Duration as _Duration
from pendulum import parse
except ModuleNotFoundError: # pragma: no cover
except ModuleNotFoundError as e: # pragma: no cover
raise RuntimeError(
'The `pendulum_dt` module requires "pendulum" to be installed. You can install it with "pip install pendulum".'
)
) from e
from datetime import date, datetime, timedelta
from typing import Any, List, Type
from pydantic import GetCoreSchemaHandler
@ -68,6 +71,9 @@ class DateTime(_DateTime):
if isinstance(value, _DateTime):
return handler(value)
if isinstance(value, datetime):
return handler(DateTime.instance(value))
# otherwise, parse it.
try:
data = parse(value)
@ -126,6 +132,9 @@ class Date(_Date):
if isinstance(value, _Date):
return handler(value)
if isinstance(value, date):
return handler(pendulum.instance(value))
# otherwise, parse it.
try:
data = parse(value)
@ -184,6 +193,9 @@ class Duration(_Duration):
if isinstance(value, _Duration):
return handler(value)
if isinstance(value, timedelta):
return handler(_Duration(seconds=value.total_seconds()))
# otherwise, parse it.
try:
data = parse(value)

View file

@ -14,10 +14,10 @@ from pydantic_core import PydanticCustomError, core_schema
try:
import phonenumbers
except ModuleNotFoundError: # pragma: no cover
except ModuleNotFoundError as e: # pragma: no cover
raise RuntimeError(
'`PhoneNumber` requires "phonenumbers" to be installed. You can install it with "pip install phonenumbers"'
)
) from e
GeneratorCallableStr = Generator[Callable[..., str], None, None]
@ -67,3 +67,9 @@ class PhoneNumber(str):
raise PydanticCustomError('value_error', 'value is not a valid phone number')
return phonenumbers.format_number(parsed_number, getattr(phonenumbers.PhoneNumberFormat, cls.phone_format))
def __eq__(self, other: Any) -> bool:
return super().__eq__(other)
def __hash__(self) -> int:
return super().__hash__()

View file

@ -0,0 +1,100 @@
"""
script definitions that are based on the [ISO 15924](https://en.wikipedia.org/wiki/ISO_15924)
"""
from __future__ import annotations
from typing import Any
from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler
from pydantic_core import PydanticCustomError, core_schema
try:
import pycountry
except ModuleNotFoundError as e: # pragma: no cover
raise RuntimeError(
'The `script_code` module requires "pycountry" to be installed.'
' You can install it with "pip install pycountry".'
) from e
class ISO_15924(str):
"""ISO_15924 parses script in the [ISO 15924](https://en.wikipedia.org/wiki/ISO_15924)
format.
```py
from pydantic import BaseModel
from pydantic_extra_types.language_code import ISO_15924
class Script(BaseModel):
alpha_4: ISO_15924
script = Script(alpha_4='Java')
print(lang)
# > script='Java'
```
"""
allowed_values_list = [script.alpha_4 for script in pycountry.scripts]
allowed_values = set(allowed_values_list)
@classmethod
def _validate(cls, __input_value: str, _: core_schema.ValidationInfo) -> ISO_15924:
"""
Validate a ISO 15924 language code from the provided str value.
Args:
__input_value: The str value to be validated.
_: The Pydantic ValidationInfo.
Returns:
The validated ISO 15924 script code.
Raises:
PydanticCustomError: If the ISO 15924 script code is not valid.
"""
if __input_value not in cls.allowed_values:
raise PydanticCustomError(
'ISO_15924', 'Invalid ISO 15924 script code. See https://en.wikipedia.org/wiki/ISO_15924'
)
return cls(__input_value)
@classmethod
def __get_pydantic_core_schema__(
cls, _: type[Any], __: GetCoreSchemaHandler
) -> core_schema.AfterValidatorFunctionSchema:
"""
Return a Pydantic CoreSchema with the ISO 639-3 language code validation.
Args:
_: The source type.
__: The handler to get the CoreSchema.
Returns:
A Pydantic CoreSchema with the ISO 639-3 language code validation.
"""
return core_schema.with_info_after_validator_function(
cls._validate,
core_schema.str_schema(min_length=4, max_length=4),
)
@classmethod
def __get_pydantic_json_schema__(
cls, schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
) -> dict[str, Any]:
"""
Return a Pydantic JSON Schema with the ISO 639-3 language code validation.
Args:
schema: The Pydantic CoreSchema.
handler: The handler to get the JSON Schema.
Returns:
A Pydantic JSON Schema with the ISO 639-3 language code validation.
"""
json_schema = handler(schema)
json_schema.update({'enum': cls.allowed_values_list})
return json_schema

View file

@ -15,10 +15,10 @@ from pydantic_core import PydanticCustomError, core_schema
try:
from ulid import ULID as _ULID
except ModuleNotFoundError: # pragma: no cover
except ModuleNotFoundError as e: # pragma: no cover
raise RuntimeError(
'The `ulid` module requires "python-ulid" to be installed. You can install it with "pip install python-ulid".'
)
) from e
UlidType = Union[str, bytes, int]
@ -58,6 +58,6 @@ class ULID(_repr.Representation):
ulid = value
else:
ulid = _ULID.from_bytes(value)
except ValueError:
raise PydanticCustomError('ulid_format', 'Unrecognized format')
except ValueError as e:
raise PydanticCustomError('ulid_format', 'Unrecognized format') from e
return handler(ulid)

View file

@ -51,6 +51,13 @@ all = [
'python-ulid>=1,<3; python_version>="3.9"',
'pendulum>=3.0.0,<4.0.0'
]
phonenumbers = ['phonenumbers>=8,<9']
pycountry = ['pycountry>=23']
python_ulid = [
'python-ulid>=1,<2; python_version<"3.9"',
'python-ulid>=1,<3; python_version>="3.9"',
]
pendulum = ['pendulum>=3.0.0,<4.0.0']
[project.urls]
Homepage = 'https://github.com/pydantic/pydantic-extra-types'

View file

@ -4,7 +4,7 @@
#
# pip-compile --no-emit-index-url --output-file=requirements/linting.txt requirements/linting.in
#
annotated-types==0.6.0
annotated-types==0.7.0
# via -r requirements/linting.in
cfgv==3.4.0
# via pre-commit
@ -14,7 +14,7 @@ filelock==3.13.1
# via virtualenv
identify==2.5.35
# via pre-commit
mypy==1.9.0
mypy==1.10.0
# via -r requirements/linting.in
mypy-extensions==1.0.0
# via mypy
@ -22,11 +22,11 @@ nodeenv==1.8.0
# via pre-commit
platformdirs==4.2.0
# via virtualenv
pre-commit==3.7.0
pre-commit==3.7.1
# via -r requirements/linting.in
pyyaml==6.0.1
# via pre-commit
ruff==0.3.5
ruff==0.4.7
# via -r requirements/linting.in
typing-extensions==4.10.0
# via mypy

View file

@ -10,7 +10,7 @@ charset-normalizer==3.3.2
# via requests
codecov==2.1.13
# via -r requirements/testing.in
coverage[toml]==7.4.4
coverage[toml]==7.5.3
# via
# -r requirements/testing.in
# codecov
@ -27,11 +27,11 @@ mdurl==0.1.2
# via markdown-it-py
packaging==23.2
# via pytest
pluggy==1.4.0
pluggy==1.5.0
# via pytest
pygments==2.17.2
# via rich
pytest==8.1.1
pytest==8.2.1
# via
# -r requirements/testing.in
# pytest-cov

View file

@ -17,6 +17,7 @@ from pydantic_extra_types.language_code import ISO639_3, ISO639_5, LanguageAlpha
from pydantic_extra_types.mac_address import MacAddress
from pydantic_extra_types.payment import PaymentCardNumber
from pydantic_extra_types.pendulum_dt import DateTime
from pydantic_extra_types.script_code import ISO_15924
from pydantic_extra_types.ulid import ULID
languages = [lang.alpha_3 for lang in pycountry.languages]
@ -32,6 +33,8 @@ everyday_currencies = [
if currency.alpha_3 not in pydantic_extra_types.currency_code._CODES_FOR_BONDS_METAL_TESTING
]
scripts = [script.alpha_4 for script in pycountry.scripts]
everyday_currencies.sort()
@ -305,6 +308,23 @@ everyday_currencies.sort()
'type': 'object',
},
),
(
ISO_15924,
{
'properties': {
'x': {
'title': 'X',
'type': 'string',
'enum': scripts,
'maxLength': 4,
'minLength': 4,
}
},
'required': ['x'],
'title': 'Model',
'type': 'object',
},
),
],
)
def test_json_schema(cls, expected):

View file

@ -1,9 +1,14 @@
from datetime import date, datetime, timedelta
from datetime import timezone as tz
import pendulum
import pytest
from pydantic import BaseModel, ValidationError
from pydantic_extra_types.pendulum_dt import Date, DateTime, Duration
UTC = tz.utc
class DtModel(BaseModel):
dt: DateTime
@ -17,32 +22,77 @@ class DurationModel(BaseModel):
delta_t: Duration
def test_pendulum_dt_existing_instance():
@pytest.mark.parametrize(
'instance',
[
pendulum.now(),
datetime.now(),
datetime.now(UTC),
],
)
def test_existing_instance(instance):
"""
Verifies that constructing a model with an existing pendulum dt doesn't throw.
"""
now = pendulum.now()
model = DtModel(dt=now)
assert model.dt == now
model = DtModel(dt=instance)
if isinstance(instance, datetime):
assert model.dt == pendulum.instance(instance)
if instance.tzinfo is None and isinstance(instance, datetime):
instance = model.dt.replace(tzinfo=UTC) # pendulum defaults to UTC
dt = model.dt
else:
assert model.dt == instance
dt = model.dt
assert dt.day == instance.day
assert dt.month == instance.month
assert dt.year == instance.year
assert dt.hour == instance.hour
assert dt.minute == instance.minute
assert dt.second == instance.second
assert dt.microsecond == instance.microsecond
if dt.tzinfo != instance.tzinfo:
assert dt.tzinfo.utcoffset(dt) == instance.tzinfo.utcoffset(instance)
def test_pendulum_date_existing_instance():
@pytest.mark.parametrize(
'instance',
[
pendulum.today(),
date.today(),
],
)
def test_pendulum_date_existing_instance(instance):
"""
Verifies that constructing a model with an existing pendulum date doesn't throw.
"""
today = pendulum.today().date()
model = DateModel(d=today)
assert model.d == today
model = DateModel(d=instance)
if isinstance(instance, datetime):
assert model.d == pendulum.instance(instance).date()
else:
assert model.d == instance
d = model.d
assert d.day == instance.day
assert d.month == instance.month
assert d.year == instance.year
def test_pendulum_duration_existing_instance():
@pytest.mark.parametrize(
'instance',
[
pendulum.duration(days=42, hours=13, minutes=37),
pendulum.duration(days=-42, hours=13, minutes=37),
timedelta(days=42, hours=13, minutes=37),
timedelta(days=-42, hours=13, minutes=37),
],
)
def test_duration_timedelta__existing_instance(instance):
"""
Verifies that constructing a model with an existing pendulum duration doesn't throw.
"""
delta_t = pendulum.duration(days=42, hours=13, minutes=37)
model = DurationModel(delta_t=delta_t)
model = DurationModel(delta_t=instance)
assert model.delta_t.total_seconds() == delta_t.total_seconds()
assert model.delta_t.total_seconds() == instance.total_seconds()
@pytest.mark.parametrize(

View file

@ -52,6 +52,20 @@ def test_parsed_but_not_a_valid_number() -> None:
Something(phone_number='+1 555-1212')
def test_hashes() -> None:
assert hash(PhoneNumber('555-1212')) == hash(PhoneNumber('555-1212'))
assert hash(PhoneNumber('555-1212')) == hash('555-1212')
assert hash(PhoneNumber('555-1212')) != hash('555-1213')
assert hash(PhoneNumber('555-1212')) != hash(PhoneNumber('555-1213'))
def test_eq() -> None:
assert PhoneNumber('555-1212') == PhoneNumber('555-1212')
assert PhoneNumber('555-1212') == '555-1212'
assert PhoneNumber('555-1212') != '555-1213'
assert PhoneNumber('555-1212') != PhoneNumber('555-1213')
def test_json_schema() -> None:
assert Something.model_json_schema() == {
'title': 'Something',

53
tests/test_scripts.py Normal file
View file

@ -0,0 +1,53 @@
import re
import pycountry
import pytest
from pydantic import BaseModel, ValidationError
from pydantic_extra_types.script_code import ISO_15924
class ScriptCheck(BaseModel):
script: ISO_15924
@pytest.mark.parametrize('script', map(lambda lang: lang.alpha_4, pycountry.scripts))
def test_ISO_15924_code_ok(script: str):
model = ScriptCheck(script=script)
assert model.script == script
assert str(model.script) == script
assert model.model_dump() == {'script': script} # test serialization
def test_ISO_15924_code_fail_not_enought_letters():
with pytest.raises(
ValidationError,
match=re.escape(
'1 validation error for ScriptCheck\nscript\n '
"String should have at least 4 characters [type=string_too_short, input_value='X', input_type=str]\n"
),
):
ScriptCheck(script='X')
def test_ISO_15924_code_fail_too_much_letters():
with pytest.raises(
ValidationError,
match=re.escape(
'1 validation error for ScriptCheck\nscript\n '
"String should have at most 4 characters [type=string_too_long, input_value='Klingon', input_type=str]"
),
):
ScriptCheck(script='Klingon')
def test_ISO_15924_code_fail_not_existing():
with pytest.raises(
ValidationError,
match=re.escape(
'1 validation error for ScriptCheck\nscript\n '
'Invalid ISO 15924 script code. See https://en.wikipedia.org/wiki/ISO_15924 '
"[type=ISO_15924, input_value='Klin', input_type=str]"
),
):
ScriptCheck(script='Klin')