Skip to content

Commit c55eea7

Browse files
07pepa07pepayezz123
authored
Add timezone name validation (#193)
* add timezone name validation * Update mypy command in Makefile * Update timezone_name to use type hints consistently and improve code readability * add More tests --------- Co-authored-by: 07pepa <pepe@wont_share.com> Co-authored-by: Yasser Tahiri <yasserth19@gmail.com>
1 parent 1cddee1 commit c55eea7

File tree

6 files changed

+424
-2
lines changed

6 files changed

+424
-2
lines changed

pydantic_extra_types/timezone_name.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
"""Time zone name validation and serialization module."""
2+
3+
from __future__ import annotations
4+
5+
import importlib
6+
import sys
7+
import warnings
8+
from typing import Any, Callable, List, Set, Type, cast
9+
10+
from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler
11+
from pydantic_core import PydanticCustomError, core_schema
12+
13+
14+
def _is_available(name: str) -> bool:
15+
"""Check if a module is available for import."""
16+
try:
17+
importlib.import_module(name)
18+
return True
19+
except ModuleNotFoundError: # pragma: no cover
20+
return False
21+
22+
23+
def _tz_provider_from_zone_info() -> Set[str]: # pragma: no cover
24+
"""Get timezones from the zoneinfo module."""
25+
from zoneinfo import available_timezones
26+
27+
return set(available_timezones())
28+
29+
30+
def _tz_provider_from_pytz() -> Set[str]: # pragma: no cover
31+
"""Get timezones from the pytz module."""
32+
from pytz import all_timezones
33+
34+
return set(all_timezones)
35+
36+
37+
def _warn_about_pytz_usage() -> None:
38+
"""Warn about using pytz with Python 3.9 or later."""
39+
warnings.warn( # pragma: no cover
40+
'Projects using Python 3.9 or later should be using the support now included as part of the standard library. '
41+
'Please consider switching to the standard library (zoneinfo) module.'
42+
)
43+
44+
45+
def get_timezones() -> Set[str]:
46+
"""Determine the timezone provider and return available timezones."""
47+
if _is_available('zoneinfo') and _is_available('tzdata'): # pragma: no cover
48+
return _tz_provider_from_zone_info()
49+
elif _is_available('pytz'): # pragma: no cover
50+
if sys.version_info[:2] > (3, 8):
51+
_warn_about_pytz_usage()
52+
return _tz_provider_from_pytz()
53+
else: # pragma: no cover
54+
if sys.version_info[:2] == (3, 8):
55+
raise ImportError('No pytz module found. Please install it with "pip install pytz"')
56+
raise ImportError('No timezone provider found. Please install tzdata with "pip install tzdata"')
57+
58+
59+
class TimeZoneNameSettings(type):
60+
def __new__(cls, name: str, bases: tuple[type, ...], dct: dict[str, Any], **kwargs: Any) -> Type[TimeZoneName]:
61+
dct['strict'] = kwargs.pop('strict', True)
62+
return cast(Type[TimeZoneName], super().__new__(cls, name, bases, dct))
63+
64+
def __init__(cls, name: str, bases: tuple[type, ...], dct: dict[str, Any], **kwargs: Any) -> None:
65+
super().__init__(name, bases, dct)
66+
cls.strict = kwargs.get('strict', True)
67+
68+
69+
def timezone_name_settings(**kwargs: Any) -> Callable[[Type[TimeZoneName]], Type[TimeZoneName]]:
70+
def wrapper(cls: Type[TimeZoneName]) -> Type[TimeZoneName]:
71+
cls.strict = kwargs.get('strict', True)
72+
return cls
73+
74+
return wrapper
75+
76+
77+
@timezone_name_settings(strict=True)
78+
class TimeZoneName(str):
79+
"""
80+
TimeZoneName is a custom string subclass for validating and serializing timezone names.
81+
82+
The TimeZoneName class uses the IANA Time Zone Database for validation.
83+
It supports both strict and non-strict modes for timezone name validation.
84+
85+
86+
## Examples:
87+
88+
Some examples of using the TimeZoneName class:
89+
90+
### Normal usage:
91+
92+
```python
93+
from pydantic_extra_types.timezone_name import TimeZoneName
94+
from pydantic import BaseModel
95+
class Location(BaseModel):
96+
city: str
97+
timezone: TimeZoneName
98+
99+
loc = Location(city="New York", timezone="America/New_York")
100+
print(loc.timezone)
101+
102+
>> America/New_York
103+
104+
```
105+
106+
### Non-strict mode:
107+
108+
```python
109+
110+
from pydantic_extra_types.timezone_name import TimeZoneName, timezone_name_settings
111+
112+
@timezone_name_settings(strict=False)
113+
class TZNonStrict(TimeZoneName):
114+
pass
115+
116+
tz = TZNonStrict("america/new_york")
117+
118+
print(tz)
119+
120+
>> america/new_york
121+
122+
```
123+
"""
124+
125+
__slots__: List[str] = []
126+
allowed_values: Set[str] = set(get_timezones())
127+
allowed_values_list: List[str] = sorted(allowed_values)
128+
allowed_values_upper_to_correct: dict[str, str] = {val.upper(): val for val in allowed_values}
129+
strict: bool
130+
131+
@classmethod
132+
def _validate(cls, __input_value: str, _: core_schema.ValidationInfo) -> TimeZoneName:
133+
"""
134+
Validate a time zone name from the provided str value.
135+
136+
Args:
137+
__input_value: The str value to be validated.
138+
_: The Pydantic ValidationInfo.
139+
140+
Returns:
141+
The validated time zone name.
142+
143+
Raises:
144+
PydanticCustomError: If the timezone name is not valid.
145+
"""
146+
if __input_value not in cls.allowed_values: # be fast for the most common case
147+
if not cls.strict:
148+
upper_value = __input_value.strip().upper()
149+
if upper_value in cls.allowed_values_upper_to_correct:
150+
return cls(cls.allowed_values_upper_to_correct[upper_value])
151+
raise PydanticCustomError('TimeZoneName', 'Invalid timezone name.')
152+
return cls(__input_value)
153+
154+
@classmethod
155+
def __get_pydantic_core_schema__(
156+
cls, _: Type[Any], __: GetCoreSchemaHandler
157+
) -> core_schema.AfterValidatorFunctionSchema:
158+
"""
159+
Return a Pydantic CoreSchema with the timezone name validation.
160+
161+
Args:
162+
_: The source type.
163+
__: The handler to get the CoreSchema.
164+
165+
Returns:
166+
A Pydantic CoreSchema with the timezone name validation.
167+
"""
168+
return core_schema.with_info_after_validator_function(
169+
cls._validate,
170+
core_schema.str_schema(min_length=1),
171+
)
172+
173+
@classmethod
174+
def __get_pydantic_json_schema__(
175+
cls, schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
176+
) -> dict[str, Any]:
177+
"""
178+
Return a Pydantic JSON Schema with the timezone name validation.
179+
180+
Args:
181+
schema: The Pydantic CoreSchema.
182+
handler: The handler to get the JSON Schema.
183+
184+
Returns:
185+
A Pydantic JSON Schema with the timezone name validation.
186+
"""
187+
json_schema = handler(schema)
188+
json_schema.update({'enum': cls.allowed_values_list})
189+
return json_schema

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ all = [
5050
'semver>=3.0.2',
5151
'python-ulid>=1,<2; python_version<"3.9"',
5252
'python-ulid>=1,<3; python_version>="3.9"',
53-
'pendulum>=3.0.0,<4.0.0'
53+
'pendulum>=3.0.0,<4.0.0',
54+
'pytz>=2024.1',
55+
'tzdata>=2024.1',
5456
]
5557
phonenumbers = ['phonenumbers>=8,<9']
5658
pycountry = ['pycountry>=23']

requirements/linting.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ pre-commit
22
mypy
33
annotated-types
44
ruff
5+
types-pytz

requirements/linting.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#
2-
# This file is autogenerated by pip-compile with Python 3.11
2+
# This file is autogenerated by pip-compile with Python 3.12
33
# by the following command:
44
#
55
# pip-compile --no-emit-index-url --output-file=requirements/linting.txt requirements/linting.in
@@ -28,6 +28,8 @@ pyyaml==6.0.1
2828
# via pre-commit
2929
ruff==0.5.0
3030
# via -r requirements/linting.in
31+
types-pytz==2024.1.0.20240417
32+
# via -r requirements/linting.in
3133
typing-extensions==4.10.0
3234
# via mypy
3335
virtualenv==20.25.1

tests/test_json_schema.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from pydantic_extra_types.pendulum_dt import DateTime
2020
from pydantic_extra_types.script_code import ISO_15924
2121
from pydantic_extra_types.semantic_version import SemanticVersion
22+
from pydantic_extra_types.timezone_name import TimeZoneName
2223
from pydantic_extra_types.ulid import ULID
2324

2425
languages = [lang.alpha_3 for lang in pycountry.languages]
@@ -36,6 +37,8 @@
3637

3738
scripts = [script.alpha_4 for script in pycountry.scripts]
3839

40+
timezone_names = TimeZoneName.allowed_values_list
41+
3942
everyday_currencies.sort()
4043

4144

@@ -335,6 +338,22 @@
335338
'type': 'object',
336339
},
337340
),
341+
(
342+
TimeZoneName,
343+
{
344+
'properties': {
345+
'x': {
346+
'title': 'X',
347+
'type': 'string',
348+
'enum': timezone_names,
349+
'minLength': 1,
350+
}
351+
},
352+
'required': ['x'],
353+
'title': 'Model',
354+
'type': 'object',
355+
},
356+
),
338357
],
339358
)
340359
def test_json_schema(cls, expected):

0 commit comments

Comments
 (0)