diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-07-13 11:08:10 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-07-13 11:08:10 +0000 |
commit | 587ae094bfaaecf5984c2c46494ccad56460a684 (patch) | |
tree | 779461ee7c578ab628a5fdbcff3892422eed36e9 | |
parent | Releasing progress-linux version 2.8.2-1. (diff) | |
download | pydantic-extra-types-587ae094bfaaecf5984c2c46494ccad56460a684.tar.xz pydantic-extra-types-587ae094bfaaecf5984c2c46494ccad56460a684.zip |
Merging upstream version 2.9.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
-rw-r--r-- | HISTORY.md | 15 | ||||
-rw-r--r-- | pydantic_extra_types/__init__.py | 2 | ||||
-rw-r--r-- | pydantic_extra_types/color.py | 13 | ||||
-rw-r--r-- | pydantic_extra_types/semantic_version.py | 55 | ||||
-rw-r--r-- | pydantic_extra_types/timezone_name.py | 189 | ||||
-rw-r--r-- | pyproject.toml | 6 | ||||
-rw-r--r-- | requirements/linting.in | 1 | ||||
-rw-r--r-- | requirements/linting.txt | 8 | ||||
-rw-r--r-- | requirements/pyproject.txt | 2 | ||||
-rw-r--r-- | requirements/testing.txt | 4 | ||||
-rw-r--r-- | tests/test_json_schema.py | 29 | ||||
-rw-r--r-- | tests/test_semantic_version.py | 23 | ||||
-rw-r--r-- | tests/test_timezone_names.py | 209 |
13 files changed, 542 insertions, 14 deletions
@@ -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 diff --git a/pydantic_extra_types/__init__.py b/pydantic_extra_types/__init__.py index 964a32a..387cfac 100644 --- a/pydantic_extra_types/__init__.py +++ b/pydantic_extra_types/__init__.py @@ -1 +1 @@ -__version__ = '2.8.2' +__version__ = '2.9.0' diff --git a/pydantic_extra_types/color.py b/pydantic_extra_types/color.py index 8e28ff4..26aebba 100644 --- a/pydantic_extra_types/color.py +++ b/pydantic_extra_types/color.py @@ -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) diff --git a/pydantic_extra_types/semantic_version.py b/pydantic_extra_types/semantic_version.py new file mode 100644 index 0000000..f0945fd --- /dev/null +++ b/pydantic_extra_types/semantic_version.py @@ -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()) diff --git a/pydantic_extra_types/timezone_name.py b/pydantic_extra_types/timezone_name.py new file mode 100644 index 0000000..93b2213 --- /dev/null +++ b/pydantic_extra_types/timezone_name.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 26f15d0..824db8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"', diff --git a/requirements/linting.in b/requirements/linting.in index 06a5fce..fa0f927 100644 --- a/requirements/linting.in +++ b/requirements/linting.in @@ -2,3 +2,4 @@ pre-commit mypy annotated-types ruff +types-pytz diff --git a/requirements/linting.txt b/requirements/linting.txt index 1fdcce5..a117fc1 100644 --- a/requirements/linting.txt +++ b/requirements/linting.txt @@ -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 diff --git a/requirements/pyproject.txt b/requirements/pyproject.txt index 1f30461..aab552b 100644 --- a/requirements/pyproject.txt +++ b/requirements/pyproject.txt @@ -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 diff --git a/requirements/testing.txt b/requirements/testing.txt index 72d9dfb..5a8a7f9 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -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 diff --git a/tests/test_json_schema.py b/tests/test_json_schema.py index 43ad932..c39c88f 100644 --- a/tests/test_json_schema.py +++ b/tests/test_json_schema.py @@ -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): diff --git a/tests/test_semantic_version.py b/tests/test_semantic_version.py new file mode 100644 index 0000000..dc79bef --- /dev/null +++ b/tests/test_semantic_version.py @@ -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') diff --git a/tests/test_timezone_names.py b/tests/test_timezone_names.py new file mode 100644 index 0000000..d980092 --- /dev/null +++ b/tests/test_timezone_names.py @@ -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' |