1
0
Fork 0

Merging upstream version 2.9.0.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-02-05 14:05:59 +01:00
parent f1720b9d27
commit e6be59280f
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
13 changed files with 542 additions and 14 deletions

View file

@ -2,6 +2,21 @@
## Latest Changes
## 2.9.0
### Types
* Add Semantic version type. PR [#195](https://github.com/pydantic/pydantic-extra-types/pull/195) by [@nikstuckenbrock](https://github.com/nikstuckenbrock)
* Add timezone name validation. PR [#193](https://github.com/pydantic/pydantic-extra-types/pull/193) by [@07pepa](https://github.com/07pepa)
### Refactor
* Replace try-except block by if-else statement. PR [#192](https://github.com/pydantic/pydantic-extra-types/pull/192) by [@maxsos](https://github.com/maxsos)
### Dependencies
* ⬆ Bump the python-packages group with 4 updates. PR [#194](https://github.com/pydantic/pydantic-extra-types/pull/194) by @dependabot
## 2.8.2
* 🐛 Preserve timezone information when validating Pendulum DateTimes. [#189](https://github.com/pydantic/pydantic-extra-types/pull/189) by [@chrisguidry

View file

@ -1 +1 @@
__version__ = '2.8.2'
__version__ = '2.9.0'

View file

@ -124,13 +124,14 @@ class Color(_repr.Representation):
if self._rgba.alpha is not None:
return self.as_hex()
rgb = cast(Tuple[int, int, int], self.as_rgb_tuple())
try:
if rgb in COLORS_BY_VALUE:
return COLORS_BY_VALUE[rgb]
except KeyError as e:
else:
if fallback:
return self.as_hex()
else:
raise ValueError('no named color found, use fallback=True, as_hex() or as_rgb()') from e
raise ValueError('no named color found, use fallback=True, as_hex() or as_rgb()')
def as_hex(self, format: Literal['short', 'long'] = 'short') -> str:
"""Returns the hexadecimal representation of the color.
@ -292,12 +293,10 @@ def parse_str(value: str) -> RGBA:
Raises:
ValueError: If the input string cannot be parsed to an RGBA tuple.
"""
value_lower = value.lower()
try:
if value_lower in COLORS_BY_NAME:
r, g, b = COLORS_BY_NAME[value_lower]
except KeyError:
pass
else:
return ints_to_rgba(r, g, b, None)
m = re.fullmatch(r_hex_short, value_lower)

View file

@ -0,0 +1,55 @@
"""
SemanticVersion definition that is based on the Semantiv Versioning Specification [semver](https://semver.org/).
"""
from typing import Any, Callable
from pydantic import GetJsonSchemaHandler
from pydantic.json_schema import JsonSchemaValue
from pydantic_core import core_schema
try:
import semver
except ModuleNotFoundError as e: # pragma: no cover
raise RuntimeError(
'The `semantic_version` module requires "semver" to be installed. You can install it with "pip install semver".'
) from e
class SemanticVersion:
"""
Semantic version based on the official [semver thread](https://python-semver.readthedocs.io/en/latest/advanced/combine-pydantic-and-semver.html).
"""
@classmethod
def __get_pydantic_core_schema__(
cls,
_source_type: Any,
_handler: Callable[[Any], core_schema.CoreSchema],
) -> core_schema.CoreSchema:
def validate_from_str(value: str) -> semver.Version:
return semver.Version.parse(value)
from_str_schema = core_schema.chain_schema(
[
core_schema.str_schema(),
core_schema.no_info_plain_validator_function(validate_from_str),
]
)
return core_schema.json_or_python_schema(
json_schema=from_str_schema,
python_schema=core_schema.union_schema(
[
core_schema.is_instance_schema(semver.Version),
from_str_schema,
]
),
serialization=core_schema.to_string_ser_schema(),
)
@classmethod
def __get_pydantic_json_schema__(
cls, _core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
) -> JsonSchemaValue:
return handler(core_schema.str_schema())

View file

@ -0,0 +1,189 @@
"""Time zone name validation and serialization module."""
from __future__ import annotations
import importlib
import sys
import warnings
from typing import Any, Callable, List, Set, Type, cast
from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler
from pydantic_core import PydanticCustomError, core_schema
def _is_available(name: str) -> bool:
"""Check if a module is available for import."""
try:
importlib.import_module(name)
return True
except ModuleNotFoundError: # pragma: no cover
return False
def _tz_provider_from_zone_info() -> Set[str]: # pragma: no cover
"""Get timezones from the zoneinfo module."""
from zoneinfo import available_timezones
return set(available_timezones())
def _tz_provider_from_pytz() -> Set[str]: # pragma: no cover
"""Get timezones from the pytz module."""
from pytz import all_timezones
return set(all_timezones)
def _warn_about_pytz_usage() -> None:
"""Warn about using pytz with Python 3.9 or later."""
warnings.warn( # pragma: no cover
'Projects using Python 3.9 or later should be using the support now included as part of the standard library. '
'Please consider switching to the standard library (zoneinfo) module.'
)
def get_timezones() -> Set[str]:
"""Determine the timezone provider and return available timezones."""
if _is_available('zoneinfo') and _is_available('tzdata'): # pragma: no cover
return _tz_provider_from_zone_info()
elif _is_available('pytz'): # pragma: no cover
if sys.version_info[:2] > (3, 8):
_warn_about_pytz_usage()
return _tz_provider_from_pytz()
else: # pragma: no cover
if sys.version_info[:2] == (3, 8):
raise ImportError('No pytz module found. Please install it with "pip install pytz"')
raise ImportError('No timezone provider found. Please install tzdata with "pip install tzdata"')
class TimeZoneNameSettings(type):
def __new__(cls, name: str, bases: tuple[type, ...], dct: dict[str, Any], **kwargs: Any) -> Type[TimeZoneName]:
dct['strict'] = kwargs.pop('strict', True)
return cast(Type[TimeZoneName], super().__new__(cls, name, bases, dct))
def __init__(cls, name: str, bases: tuple[type, ...], dct: dict[str, Any], **kwargs: Any) -> None:
super().__init__(name, bases, dct)
cls.strict = kwargs.get('strict', True)
def timezone_name_settings(**kwargs: Any) -> Callable[[Type[TimeZoneName]], Type[TimeZoneName]]:
def wrapper(cls: Type[TimeZoneName]) -> Type[TimeZoneName]:
cls.strict = kwargs.get('strict', True)
return cls
return wrapper
@timezone_name_settings(strict=True)
class TimeZoneName(str):
"""
TimeZoneName is a custom string subclass for validating and serializing timezone names.
The TimeZoneName class uses the IANA Time Zone Database for validation.
It supports both strict and non-strict modes for timezone name validation.
## Examples:
Some examples of using the TimeZoneName class:
### Normal usage:
```python
from pydantic_extra_types.timezone_name import TimeZoneName
from pydantic import BaseModel
class Location(BaseModel):
city: str
timezone: TimeZoneName
loc = Location(city="New York", timezone="America/New_York")
print(loc.timezone)
>> America/New_York
```
### Non-strict mode:
```python
from pydantic_extra_types.timezone_name import TimeZoneName, timezone_name_settings
@timezone_name_settings(strict=False)
class TZNonStrict(TimeZoneName):
pass
tz = TZNonStrict("america/new_york")
print(tz)
>> america/new_york
```
"""
__slots__: List[str] = []
allowed_values: Set[str] = set(get_timezones())
allowed_values_list: List[str] = sorted(allowed_values)
allowed_values_upper_to_correct: dict[str, str] = {val.upper(): val for val in allowed_values}
strict: bool
@classmethod
def _validate(cls, __input_value: str, _: core_schema.ValidationInfo) -> TimeZoneName:
"""
Validate a time zone name from the provided str value.
Args:
__input_value: The str value to be validated.
_: The Pydantic ValidationInfo.
Returns:
The validated time zone name.
Raises:
PydanticCustomError: If the timezone name is not valid.
"""
if __input_value not in cls.allowed_values: # be fast for the most common case
if not cls.strict:
upper_value = __input_value.strip().upper()
if upper_value in cls.allowed_values_upper_to_correct:
return cls(cls.allowed_values_upper_to_correct[upper_value])
raise PydanticCustomError('TimeZoneName', 'Invalid timezone name.')
return cls(__input_value)
@classmethod
def __get_pydantic_core_schema__(
cls, _: Type[Any], __: GetCoreSchemaHandler
) -> core_schema.AfterValidatorFunctionSchema:
"""
Return a Pydantic CoreSchema with the timezone name validation.
Args:
_: The source type.
__: The handler to get the CoreSchema.
Returns:
A Pydantic CoreSchema with the timezone name validation.
"""
return core_schema.with_info_after_validator_function(
cls._validate,
core_schema.str_schema(min_length=1),
)
@classmethod
def __get_pydantic_json_schema__(
cls, schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
) -> dict[str, Any]:
"""
Return a Pydantic JSON Schema with the timezone name validation.
Args:
schema: The Pydantic CoreSchema.
handler: The handler to get the JSON Schema.
Returns:
A Pydantic JSON Schema with the timezone name validation.
"""
json_schema = handler(schema)
json_schema.update({'enum': cls.allowed_values_list})
return json_schema

View file

@ -47,12 +47,16 @@ dynamic = ['version']
all = [
'phonenumbers>=8,<9',
'pycountry>=23',
'semver>=3.0.2',
'python-ulid>=1,<2; python_version<"3.9"',
'python-ulid>=1,<3; python_version>="3.9"',
'pendulum>=3.0.0,<4.0.0'
'pendulum>=3.0.0,<4.0.0',
'pytz>=2024.1',
'tzdata>=2024.1',
]
phonenumbers = ['phonenumbers>=8,<9']
pycountry = ['pycountry>=23']
semver = ['semver>=3.0.2']
python_ulid = [
'python-ulid>=1,<2; python_version<"3.9"',
'python-ulid>=1,<3; python_version>="3.9"',

View file

@ -2,3 +2,4 @@ pre-commit
mypy
annotated-types
ruff
types-pytz

View file

@ -1,5 +1,5 @@
#
# This file is autogenerated by pip-compile with Python 3.11
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --no-emit-index-url --output-file=requirements/linting.txt requirements/linting.in
@ -14,7 +14,7 @@ filelock==3.13.1
# via virtualenv
identify==2.5.35
# via pre-commit
mypy==1.10.0
mypy==1.10.1
# via -r requirements/linting.in
mypy-extensions==1.0.0
# via mypy
@ -26,7 +26,9 @@ pre-commit==3.7.1
# via -r requirements/linting.in
pyyaml==6.0.1
# via pre-commit
ruff==0.4.7
ruff==0.5.0
# via -r requirements/linting.in
types-pytz==2024.1.0.20240417
# via -r requirements/linting.in
typing-extensions==4.10.0
# via mypy

View file

@ -22,6 +22,8 @@ python-dateutil==2.8.2
# time-machine
python-ulid==1.1.0
# via pydantic-extra-types (pyproject.toml)
semver==3.0.2
# via pydantic-extra-types (pyproject.toml)
six==1.16.0
# via python-dateutil
time-machine==2.13.0

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.5.3
coverage[toml]==7.5.4
# via
# -r requirements/testing.in
# codecov
@ -31,7 +31,7 @@ pluggy==1.5.0
# via pytest
pygments==2.17.2
# via rich
pytest==8.2.1
pytest==8.2.2
# via
# -r requirements/testing.in
# pytest-cov

View file

@ -18,6 +18,8 @@ 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.semantic_version import SemanticVersion
from pydantic_extra_types.timezone_name import TimeZoneName
from pydantic_extra_types.ulid import ULID
languages = [lang.alpha_3 for lang in pycountry.languages]
@ -35,6 +37,8 @@ everyday_currencies = [
scripts = [script.alpha_4 for script in pycountry.scripts]
timezone_names = TimeZoneName.allowed_values_list
everyday_currencies.sort()
@ -325,6 +329,31 @@ everyday_currencies.sort()
'type': 'object',
},
),
(
SemanticVersion,
{
'properties': {'x': {'title': 'X', 'type': 'string'}},
'required': ['x'],
'title': 'Model',
'type': 'object',
},
),
(
TimeZoneName,
{
'properties': {
'x': {
'title': 'X',
'type': 'string',
'enum': timezone_names,
'minLength': 1,
}
},
'required': ['x'],
'title': 'Model',
'type': 'object',
},
),
],
)
def test_json_schema(cls, expected):

View file

@ -0,0 +1,23 @@
import pytest
from pydantic import BaseModel, ValidationError
from pydantic_extra_types.semantic_version import SemanticVersion
@pytest.fixture(scope='module', name='SemanticVersionObject')
def application_object_fixture():
class Application(BaseModel):
version: SemanticVersion
return Application
def test_valid_semantic_version(SemanticVersionObject):
application = SemanticVersionObject(version='1.0.0')
assert application.version
assert application.model_dump() == {'version': '1.0.0'}
def test_invalid_semantic_version(SemanticVersionObject):
with pytest.raises(ValidationError):
SemanticVersionObject(version='Peter Maffay')

View file

@ -0,0 +1,209 @@
import re
import pytest
import pytz
from pydantic import BaseModel, ValidationError
from pydantic_core import PydanticCustomError
from pydantic_extra_types.timezone_name import TimeZoneName, TimeZoneNameSettings, timezone_name_settings
has_zone_info = True
try:
from zoneinfo import available_timezones
except ImportError:
has_zone_info = False
pytz_zones_bad = [(zone.lower(), zone) for zone in pytz.all_timezones]
pytz_zones_bad.extend([(f' {zone}', zone) for zone in pytz.all_timezones_set])
class TZNameCheck(BaseModel):
timezone_name: TimeZoneName
@timezone_name_settings(strict=False)
class TZNonStrict(TimeZoneName):
pass
class NonStrictTzName(BaseModel):
timezone_name: TZNonStrict
@pytest.mark.parametrize('zone', pytz.all_timezones)
def test_all_timezones_non_strict_pytz(zone):
assert TZNameCheck(timezone_name=zone).timezone_name == zone
assert NonStrictTzName(timezone_name=zone).timezone_name == zone
@pytest.mark.parametrize('zone', pytz_zones_bad)
def test_all_timezones_pytz_lower(zone):
assert NonStrictTzName(timezone_name=zone[0]).timezone_name == zone[1]
def test_fail_non_existing_timezone():
with pytest.raises(
ValidationError,
match=re.escape(
'1 validation error for TZNameCheck\n'
'timezone_name\n '
'Invalid timezone name. '
"[type=TimeZoneName, input_value='mars', input_type=str]"
),
):
TZNameCheck(timezone_name='mars')
with pytest.raises(
ValidationError,
match=re.escape(
'1 validation error for NonStrictTzName\n'
'timezone_name\n '
'Invalid timezone name. '
"[type=TimeZoneName, input_value='mars', input_type=str]"
),
):
NonStrictTzName(timezone_name='mars')
if has_zone_info:
zones = list(available_timezones())
zones.sort()
zones_bad = [(zone.lower(), zone) for zone in zones]
@pytest.mark.parametrize('zone', zones)
def test_all_timezones_zone_info(zone):
assert TZNameCheck(timezone_name=zone).timezone_name == zone
assert NonStrictTzName(timezone_name=zone).timezone_name == zone
@pytest.mark.parametrize('zone', zones_bad)
def test_all_timezones_zone_info_NonStrict(zone):
assert NonStrictTzName(timezone_name=zone[0]).timezone_name == zone[1]
def test_timezone_name_settings_metaclass():
class TestStrictTZ(TimeZoneName, strict=True, metaclass=TimeZoneNameSettings):
pass
class TestNonStrictTZ(TimeZoneName, strict=False, metaclass=TimeZoneNameSettings):
pass
assert TestStrictTZ.strict is True
assert TestNonStrictTZ.strict is False
# Test default value
class TestDefaultStrictTZ(TimeZoneName, metaclass=TimeZoneNameSettings):
pass
assert TestDefaultStrictTZ.strict is True
def test_timezone_name_validation():
valid_tz = 'America/New_York'
invalid_tz = 'Invalid/Timezone'
assert TimeZoneName._validate(valid_tz, None) == valid_tz
with pytest.raises(PydanticCustomError):
TimeZoneName._validate(invalid_tz, None)
assert TZNonStrict._validate(valid_tz.lower(), None) == valid_tz
assert TZNonStrict._validate(f' {valid_tz} ', None) == valid_tz
with pytest.raises(PydanticCustomError):
TZNonStrict._validate(invalid_tz, None)
def test_timezone_name_pydantic_core_schema():
schema = TimeZoneName.__get_pydantic_core_schema__(TimeZoneName, None)
assert isinstance(schema, dict)
assert schema['type'] == 'function-after'
assert 'function' in schema
assert 'schema' in schema
assert schema['schema']['type'] == 'str'
assert schema['schema']['min_length'] == 1
def test_timezone_name_pydantic_json_schema():
core_schema = TimeZoneName.__get_pydantic_core_schema__(TimeZoneName, None)
class MockJsonSchemaHandler:
def __call__(self, schema):
return {'type': 'string'}
handler = MockJsonSchemaHandler()
json_schema = TimeZoneName.__get_pydantic_json_schema__(core_schema, handler)
assert 'enum' in json_schema
assert isinstance(json_schema['enum'], list)
assert len(json_schema['enum']) > 0
def test_timezone_name_repr():
tz = TimeZoneName('America/New_York')
assert repr(tz) == "'America/New_York'"
assert str(tz) == 'America/New_York'
def test_timezone_name_allowed_values():
assert isinstance(TimeZoneName.allowed_values, set)
assert len(TimeZoneName.allowed_values) > 0
assert all(isinstance(tz, str) for tz in TimeZoneName.allowed_values)
assert isinstance(TimeZoneName.allowed_values_list, list)
assert len(TimeZoneName.allowed_values_list) > 0
assert all(isinstance(tz, str) for tz in TimeZoneName.allowed_values_list)
assert isinstance(TimeZoneName.allowed_values_upper_to_correct, dict)
assert len(TimeZoneName.allowed_values_upper_to_correct) > 0
assert all(
isinstance(k, str) and isinstance(v, str) for k, v in TimeZoneName.allowed_values_upper_to_correct.items()
)
def test_timezone_name_inheritance():
class CustomTZ(TimeZoneName, metaclass=TimeZoneNameSettings):
pass
assert issubclass(CustomTZ, TimeZoneName)
assert issubclass(CustomTZ, str)
assert isinstance(CustomTZ('America/New_York'), (CustomTZ, TimeZoneName, str))
def test_timezone_name_string_operations():
tz = TimeZoneName('America/New_York')
assert tz.upper() == 'AMERICA/NEW_YORK'
assert tz.lower() == 'america/new_york'
assert tz.strip() == 'America/New_York'
assert f'{tz} Time' == 'America/New_York Time'
assert tz.startswith('America')
assert tz.endswith('York')
def test_timezone_name_comparison():
tz1 = TimeZoneName('America/New_York')
tz2 = TimeZoneName('Europe/London')
tz3 = TimeZoneName('America/New_York')
assert tz1 == tz3
assert tz1 != tz2
assert tz1 < tz2 # Alphabetical comparison
assert tz2 > tz1
assert tz1 <= tz3
assert tz1 >= tz3
def test_timezone_name_hash():
tz1 = TimeZoneName('America/New_York')
tz2 = TimeZoneName('America/New_York')
tz3 = TimeZoneName('Europe/London')
assert hash(tz1) == hash(tz2)
assert hash(tz1) != hash(tz3)
tz_set = {tz1, tz2, tz3}
assert len(tz_set) == 2
def test_timezone_name_slots():
tz = TimeZoneName('America/New_York')
with pytest.raises(AttributeError):
tz.new_attribute = 'test'