summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-07-13 11:08:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-07-13 11:08:10 +0000
commit587ae094bfaaecf5984c2c46494ccad56460a684 (patch)
tree779461ee7c578ab628a5fdbcff3892422eed36e9
parentReleasing progress-linux version 2.8.2-1. (diff)
downloadpydantic-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.md15
-rw-r--r--pydantic_extra_types/__init__.py2
-rw-r--r--pydantic_extra_types/color.py13
-rw-r--r--pydantic_extra_types/semantic_version.py55
-rw-r--r--pydantic_extra_types/timezone_name.py189
-rw-r--r--pyproject.toml6
-rw-r--r--requirements/linting.in1
-rw-r--r--requirements/linting.txt8
-rw-r--r--requirements/pyproject.txt2
-rw-r--r--requirements/testing.txt4
-rw-r--r--tests/test_json_schema.py29
-rw-r--r--tests/test_semantic_version.py23
-rw-r--r--tests/test_timezone_names.py209
13 files changed, 542 insertions, 14 deletions
diff --git a/HISTORY.md b/HISTORY.md
index b781e31..381beca 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -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'