1
0
Fork 0

Merging upstream version 2.10.0.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-02-05 14:06:30 +01:00
parent 9304378357
commit 912971d44b
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
17 changed files with 843 additions and 66 deletions

View file

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

View file

@ -0,0 +1,61 @@
"""
The `domain_str` module provides the `DomainStr` data type.
This class depends on the `pydantic` package and implements custom validation for domain string format.
"""
from __future__ import annotations
import re
from typing import Any, Mapping
from pydantic import GetCoreSchemaHandler
from pydantic_core import PydanticCustomError, core_schema
class DomainStr(str):
"""
A string subclass with custom validation for domain string format.
"""
@classmethod
def validate(cls, __input_value: Any, _: Any) -> str:
"""
Validate a domain name from the provided value.
Args:
__input_value: The value to be validated.
_: The source type to be converted.
Returns:
str: The parsed domain name.
"""
return cls._validate(__input_value)
@classmethod
def _validate(cls, v: Any) -> DomainStr:
if not isinstance(v, str):
raise PydanticCustomError('domain_type', 'Value must be a string')
v = v.strip().lower()
if len(v) < 1 or len(v) > 253:
raise PydanticCustomError('domain_length', 'Domain must be between 1 and 253 characters')
pattern = r'^([a-z0-9-]+(\.[a-z0-9-]+)+)$'
if not re.match(pattern, v):
raise PydanticCustomError('domain_format', 'Invalid domain format')
return cls(v)
@classmethod
def __get_pydantic_core_schema__(cls, source_type: Any, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
return core_schema.with_info_before_validator_function(
cls.validate,
core_schema.str_schema(),
)
@classmethod
def __get_pydantic_json_schema__(
cls, schema: core_schema.CoreSchema, handler: GetCoreSchemaHandler
) -> Mapping[str, Any]:
return handler(schema)

View file

@ -7,20 +7,22 @@ This class depends on the [phonenumbers] package, which is a Python port of Goog
from __future__ import annotations
from typing import Any, Callable, ClassVar, Generator
from dataclasses import dataclass
from functools import partial
from typing import Any, ClassVar, Optional, Sequence
from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler
from pydantic_core import PydanticCustomError, core_schema
try:
import phonenumbers
from phonenumbers import PhoneNumber as BasePhoneNumber
from phonenumbers.phonenumberutil import NumberParseException
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]
class PhoneNumber(str):
"""
@ -28,19 +30,13 @@ class PhoneNumber(str):
is a Python port of Google's [libphonenumber](https://github.com/google/libphonenumber/).
"""
supported_regions: list[str] = sorted(phonenumbers.SUPPORTED_REGIONS)
"""The supported regions."""
supported_formats: list[str] = sorted([f for f in phonenumbers.PhoneNumberFormat.__dict__.keys() if f.isupper()])
"""The supported phone number formats."""
supported_regions: list[str] = []
"""The supported regions. If empty, all regions are supported."""
default_region_code: ClassVar[str | None] = None
"""The default region code to use when parsing phone numbers without an international prefix."""
phone_format: str = 'RFC3966'
"""The format of the phone number."""
min_length: int = 7
"""The minimum length of the phone number."""
max_length: int = 64
"""The maximum length of the phone number."""
@classmethod
def __get_pydantic_json_schema__(
@ -54,7 +50,7 @@ class PhoneNumber(str):
def __get_pydantic_core_schema__(cls, source: type[Any], handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
return core_schema.with_info_after_validator_function(
cls._validate,
core_schema.str_schema(min_length=cls.min_length, max_length=cls.max_length),
core_schema.str_schema(),
)
@classmethod
@ -66,6 +62,12 @@ class PhoneNumber(str):
if not phonenumbers.is_valid_number(parsed_number):
raise PydanticCustomError('value_error', 'value is not a valid phone number')
if cls.supported_regions and not any(
phonenumbers.is_valid_number_for_region(parsed_number, region_code=region)
for region in cls.supported_regions
):
raise PydanticCustomError('value_error', 'value is not from a supported region')
return phonenumbers.format_number(parsed_number, getattr(phonenumbers.PhoneNumberFormat, cls.phone_format))
def __eq__(self, other: Any) -> bool:
@ -73,3 +75,108 @@ class PhoneNumber(str):
def __hash__(self) -> int:
return super().__hash__()
@dataclass(frozen=True)
class PhoneNumberValidator:
"""
A pydantic before validator for phone numbers using the [phonenumbers](https://pypi.org/project/phonenumbers/) package,
a Python port of Google's [libphonenumber](https://github.com/google/libphonenumber/).
Intended to be used to create custom pydantic data types using the `typing.Annotated` type construct.
Args:
default_region (str | None): The default region code to use when parsing phone numbers without an international prefix.
If `None` (default), the region must be supplied in the phone number as an international prefix.
number_format (str): The format of the phone number to return. See `phonenumbers.PhoneNumberFormat` for valid values.
supported_regions (list[str]): The supported regions. If empty, all regions are supported (default).
Returns:
str: The formatted phone number.
Example:
MyNumberType = Annotated[
Union[str, phonenumbers.PhoneNumber],
PhoneNumberValidator()
]
USNumberType = Annotated[
Union[str, phonenumbers.PhoneNumber],
PhoneNumberValidator(supported_regions=['US'], default_region='US')
]
class SomeModel(BaseModel):
phone_number: MyNumberType
us_number: USNumberType
"""
default_region: Optional[str] = None
number_format: str = 'RFC3966'
supported_regions: Optional[Sequence[str]] = None
def __post_init__(self) -> None:
if self.default_region and self.default_region not in phonenumbers.SUPPORTED_REGIONS:
raise ValueError(f'Invalid default region code: {self.default_region}')
if self.number_format not in (
number_format
for number_format in dir(phonenumbers.PhoneNumberFormat)
if not number_format.startswith('_') and number_format.isupper()
):
raise ValueError(f'Invalid number format: {self.number_format}')
if self.supported_regions:
for supported_region in self.supported_regions:
if supported_region not in phonenumbers.SUPPORTED_REGIONS:
raise ValueError(f'Invalid supported region code: {supported_region}')
@staticmethod
def _parse(
region: str | None,
number_format: str,
supported_regions: Optional[Sequence[str]],
phone_number: Any,
) -> str:
if not phone_number:
raise PydanticCustomError('value_error', 'value is not a valid phone number')
if not isinstance(phone_number, (str, BasePhoneNumber)):
raise PydanticCustomError('value_error', 'value is not a valid phone number')
parsed_number = None
if isinstance(phone_number, BasePhoneNumber):
parsed_number = phone_number
else:
try:
parsed_number = phonenumbers.parse(phone_number, region=region)
except NumberParseException as exc:
raise PydanticCustomError('value_error', 'value is not a valid phone number') from exc
if not phonenumbers.is_valid_number(parsed_number):
raise PydanticCustomError('value_error', 'value is not a valid phone number')
if supported_regions and not any(
phonenumbers.is_valid_number_for_region(parsed_number, region_code=region) for region in supported_regions
):
raise PydanticCustomError('value_error', 'value is not from a supported region')
return phonenumbers.format_number(parsed_number, getattr(phonenumbers.PhoneNumberFormat, number_format))
def __get_pydantic_core_schema__(self, source: type[Any], handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
return core_schema.no_info_before_validator_function(
partial(
self._parse,
self.default_region,
self.number_format,
self.supported_regions,
),
core_schema.str_schema(),
)
def __get_pydantic_json_schema__(
self, schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
) -> dict[str, Any]:
json_schema = handler(schema)
json_schema.update({'format': 'phone'})
return json_schema
def __hash__(self) -> int:
return super().__hash__()

View file

@ -0,0 +1,70 @@
"""
The `pydantic_extra_types.s3` module provides the
[`S3Path`][pydantic_extra_types.s3.S3Path] data type.
A simpleAWS S3 URLs parser.
It also provides the `Bucket`, `Key` component.
"""
from __future__ import annotations
import re
from typing import Any, ClassVar, Type
from pydantic import GetCoreSchemaHandler
from pydantic_core import core_schema
class S3Path(str):
"""
An object representing a valid S3 path.
This type also allows you to access the `bucket` and `key` component of the S3 path.
It also contains the `last_key` which represents the last part of the path (tipically a file).
```python
from pydantic import BaseModel
from pydantic_extra_types.s3 import S3Path
class TestModel(BaseModel):
path: S3Path
p = 's3://my-data-bucket/2023/08/29/sales-report.csv'
model = TestModel(path=p)
model
#> TestModel(path=S3Path('s3://my-data-bucket/2023/08/29/sales-report.csv'))
model.path.bucket
#> 'my-data-bucket'
```
"""
patt: ClassVar[str] = r'^s3://([^/]+)/(.*?([^/]+)/?)$'
def __init__(self, value: str) -> None:
self.value = value
groups: tuple[str, str, str] = re.match(self.patt, self.value).groups() # type: ignore
self.bucket: str = groups[0]
self.key: str = groups[1]
self.last_key: str = groups[2]
def __str__(self) -> str: # pragma: no cover
return self.value
def __repr__(self) -> str: # pragma: no cover
return f'{self.__class__.__name__}({self.value!r})'
@classmethod
def _validate(cls, __input_value: str, _: core_schema.ValidationInfo) -> S3Path:
return cls(__input_value)
@classmethod
def __get_pydantic_core_schema__(cls, source: Type[Any], handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
_, _ = source, handler
return core_schema.with_info_after_validator_function(
cls._validate,
core_schema.str_schema(pattern=cls.patt),
field_name=cls.__class__.__name__,
)

View file

@ -0,0 +1,84 @@
"""
The _VersionPydanticAnnotation class provides functionality to parse and validate Semantic Versioning (SemVer) strings.
This class depends on the [semver](https://python-semver.readthedocs.io/en/latest/index.html) package.
"""
import sys
from typing import Any, Callable
if sys.version_info < (3, 9): # pragma: no cover
from typing_extensions import Annotated # pragma: no cover
else:
from typing import Annotated # pragma: no cover
import warnings
from pydantic import GetJsonSchemaHandler
from pydantic.json_schema import JsonSchemaValue
from pydantic_core import core_schema
from semver import Version
warnings.warn(
'Use from pydantic_extra_types.semver import SemanticVersion instead. Will be removed in 3.0.0.', DeprecationWarning
)
class _VersionPydanticAnnotation(Version):
"""
Represents a Semantic Versioning (SemVer).
Wraps the `version` type from `semver`.
Example:
```python
from pydantic import BaseModel
from pydantic_extra_types.semver import _VersionPydanticAnnotation
class appVersion(BaseModel):
version: _VersionPydanticAnnotation
app_version = appVersion(version="1.2.3")
print(app_version.version)
# > 1.2.3
```
"""
@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) -> Version:
return 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(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())
ManifestVersion = Annotated[Version, _VersionPydanticAnnotation]

View file

@ -9,8 +9,8 @@ path = 'pydantic_extra_types/__init__.py'
name = 'pydantic-extra-types'
description = 'Extra Pydantic types.'
authors = [
{name = 'Samuel Colvin', email = 's@muelcolvin.com'},
{name = 'Yasser Tahiri', email = 'hello@yezz.me'},
{ name = 'Samuel Colvin', email = 's@muelcolvin.com' },
{ name = 'Yasser Tahiri', email = 'hello@yezz.me' },
]
license = 'MIT'
readme = 'README.md'
@ -38,9 +38,7 @@ classifiers = [
'Topic :: Internet',
]
requires-python = '>=3.8'
dependencies = [
'pydantic>=2.5.2',
]
dependencies = ['pydantic>=2.5.2','typing-extensions']
dynamic = ['version']
[project.optional-dependencies]
@ -49,9 +47,10 @@ all = [
'pycountry>=23',
'semver>=3.0.2',
'python-ulid>=1,<2; python_version<"3.9"',
'python-ulid>=1,<3; python_version>="3.9"',
'python-ulid>=1,<4; python_version>="3.9"',
'pendulum>=3.0.0,<4.0.0',
'pytz>=2024.1',
'semver~=3.0.2',
'tzdata>=2024.1',
]
phonenumbers = ['phonenumbers>=8,<9']
@ -78,7 +77,7 @@ target-version = 'py38'
[tool.ruff.lint]
extend-select = ['Q', 'RUF100', 'C90', 'UP', 'I']
flake8-quotes = {inline-quotes = 'single', multiline-quotes = 'double'}
flake8-quotes = { inline-quotes = 'single', multiline-quotes = 'double' }
isort = { known-first-party = ['pydantic_extra_types', 'tests'] }
mccabe = { max-complexity = 14 }
pydocstyle = { convention = 'google' }
@ -124,7 +123,8 @@ filterwarnings = [
# This ignore will be removed when pycountry will drop py36 & support py311
'ignore:::pkg_resources',
# This ignore will be removed when pendulum fixes https://github.com/sdispater/pendulum/issues/834
'ignore:datetime.datetime.utcfromtimestamp.*:DeprecationWarning'
'ignore:datetime.datetime.utcfromtimestamp.*:DeprecationWarning',
' ignore:Use from pydantic_extra_types.semver import SemanticVersion instead. Will be removed in 3.0.0.:DeprecationWarning'
]
# configuring https://github.com/pydantic/hooky

View file

@ -14,7 +14,7 @@ filelock==3.13.1
# via virtualenv
identify==2.5.35
# via pre-commit
mypy==1.10.1
mypy==1.11.1
# 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.1
pre-commit==3.8.0
# via -r requirements/linting.in
pyyaml==6.0.1
# via pre-commit
ruff==0.5.0
ruff==0.5.5
# via -r requirements/linting.in
types-pytz==2024.1.0.20240417
# via -r requirements/linting.in

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 --extra=all --no-emit-index-url --output-file=requirements/pyproject.txt pyproject.toml
@ -12,9 +12,9 @@ phonenumbers==8.13.31
# via pydantic-extra-types (pyproject.toml)
pycountry==23.12.11
# via pydantic-extra-types (pyproject.toml)
pydantic==2.6.3
pydantic==2.9.1
# via pydantic-extra-types (pyproject.toml)
pydantic-core==2.16.3
pydantic-core==2.23.3
# via pydantic
python-dateutil==2.8.2
# via
@ -32,5 +32,8 @@ typing-extensions==4.10.0
# via
# pydantic
# pydantic-core
# pydantic-extra-types (pyproject.toml)
tzdata==2024.1
# via pendulum
# via
# pendulum
# pydantic-extra-types (pyproject.toml)

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.4
coverage[toml]==7.6.0
# 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.2
pytest==8.3.2
# via
# -r requirements/testing.in
# pytest-cov

View file

@ -189,7 +189,7 @@ def test_json_schema():
'type': 'object',
}
},
'properties': {'value': {'allOf': [{'$ref': '#/$defs/Coordinate'}], 'title': 'Value'}},
'properties': {'value': {'$ref': '#/$defs/Coordinate', 'title': 'Value'}},
'required': ['value'],
'title': 'Model',
'type': 'object',

76
tests/test_domain.py Normal file
View file

@ -0,0 +1,76 @@
from typing import Any
import pytest
from pydantic import BaseModel, ValidationError
from pydantic_extra_types.domain import DomainStr
class MyModel(BaseModel):
domain: DomainStr
valid_domains = [
'example.com',
'sub.example.com',
'sub-domain.example-site.co.uk',
'a.com',
'x.com',
'1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.com', # Multiple subdomains
]
invalid_domains = [
'', # Empty string
'example', # Missing TLD
'.com', # Missing domain name
'example.', # Trailing dot
'exam ple.com', # Space in domain
'exa_mple.com', # Underscore in domain
'example.com.', # Trailing dot
]
very_long_domains = [
'a' * 249 + '.com', # Just under the limit
'a' * 250 + '.com', # At the limit
'a' * 251 + '.com', # Just over the limit
'sub1.sub2.sub3.sub4.sub5.sub6.sub7.sub8.sub9.sub10.sub11.sub12.sub13.sub14.sub15.sub16.sub17.sub18.sub19.sub20.sub21.sub22.sub23.sub24.sub25.sub26.sub27.sub28.sub29.sub30.sub31.sub32.sub33.extremely-long-domain-name-example-to-test-the-253-character-limit.com',
]
invalid_domain_types = [1, 2, 1.1, 2.1, False, [], {}, None]
@pytest.mark.parametrize('domain', valid_domains)
def test_valid_domains(domain: str):
try:
MyModel.model_validate({'domain': domain})
assert len(domain) < 254 and len(domain) > 0
except ValidationError:
assert len(domain) > 254 or len(domain) == 0
@pytest.mark.parametrize('domain', invalid_domains)
def test_invalid_domains(domain: str):
try:
MyModel.model_validate({'domain': domain})
raise Exception(
f"This test case has only samples that should raise a ValidationError. This domain '{domain}' did not raise such an exception."
)
except ValidationError:
# An error is expected on this test
pass
@pytest.mark.parametrize('domain', very_long_domains)
def test_very_long_domains(domain: str):
try:
MyModel.model_validate({'domain': domain})
assert len(domain) < 254 and len(domain) > 0
except ValidationError:
# An error is expected on this test
pass
@pytest.mark.parametrize('domain', invalid_domain_types)
def test_invalid_domain_types(domain: Any):
with pytest.raises(ValidationError, match='Value must be a string'):
MyModel(domain=domain)

View file

@ -1,24 +1,31 @@
from typing import Union
import pycountry
import pytest
from pydantic import BaseModel
try:
from typing import Annotated
except ImportError:
# Python 3.8
from typing_extensions import Annotated
import pydantic_extra_types
from pydantic_extra_types.color import Color
from pydantic_extra_types.coordinate import Coordinate, Latitude, Longitude
from pydantic_extra_types.country import (
CountryAlpha2,
CountryAlpha3,
CountryNumericCode,
CountryShortName,
)
from pydantic_extra_types.country import CountryAlpha2, CountryAlpha3, CountryNumericCode, CountryShortName
from pydantic_extra_types.currency_code import ISO4217, Currency
from pydantic_extra_types.domain import DomainStr
from pydantic_extra_types.isbn import ISBN
from pydantic_extra_types.language_code import ISO639_3, ISO639_5, LanguageAlpha2, LanguageName
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.phone_numbers import PhoneNumber, PhoneNumberValidator
from pydantic_extra_types.s3 import S3Path
from pydantic_extra_types.script_code import ISO_15924
from pydantic_extra_types.semantic_version import SemanticVersion
from pydantic_extra_types.semver import _VersionPydanticAnnotation
from pydantic_extra_types.timezone_name import TimeZoneName
from pydantic_extra_types.ulid import ULID
@ -41,6 +48,16 @@ timezone_names = TimeZoneName.allowed_values_list
everyday_currencies.sort()
AnyNumberRFC3966 = Annotated[Union[str, PhoneNumber], PhoneNumberValidator()]
USNumberE164 = Annotated[
Union[str, PhoneNumber],
PhoneNumberValidator(
supported_regions=['US'],
default_region='US',
number_format='E164',
),
]
@pytest.mark.parametrize(
'cls,expected',
@ -354,6 +371,93 @@ everyday_currencies.sort()
'type': 'object',
},
),
(
_VersionPydanticAnnotation,
{
'properties': {'x': {'title': 'X', 'type': 'string'}},
'required': ['x'],
'title': 'Model',
'type': 'object',
},
),
(
PhoneNumber,
{
'title': 'Model',
'type': 'object',
'properties': {
'x': {
'title': 'X',
'type': 'string',
'format': 'phone',
}
},
'required': ['x'],
},
),
(
AnyNumberRFC3966,
{
'title': 'Model',
'type': 'object',
'properties': {
'x': {
'title': 'X',
'type': 'string',
'format': 'phone',
}
},
'required': ['x'],
},
),
(
USNumberE164,
{
'title': 'Model',
'type': 'object',
'properties': {
'x': {
'title': 'X',
'type': 'string',
'format': 'phone',
}
},
'required': ['x'],
},
),
(
S3Path,
{
'title': 'Model',
'type': 'object',
'properties': {
'x': {
'pattern': '^s3://([^/]+)/(.*?([^/]+)/?)$',
'title': 'X',
'type': 'string',
},
},
'required': [
'x',
],
},
),
(
DomainStr,
{
'title': 'Model',
'type': 'object',
'properties': {
'x': {
'title': 'X',
'type': 'string',
},
},
'required': [
'x',
],
},
),
],
)
def test_json_schema(cls, expected):

View file

@ -31,15 +31,21 @@ def test_formats_phone_number() -> None:
def test_supported_regions() -> None:
assert 'US' in PhoneNumber.supported_regions
assert 'GB' in PhoneNumber.supported_regions
assert PhoneNumber.supported_regions == []
PhoneNumber.supported_regions = ['US']
assert Something(phone_number='+1 901 555 1212')
def test_supported_formats() -> None:
assert 'E164' in PhoneNumber.supported_formats
assert 'RFC3966' in PhoneNumber.supported_formats
assert '__dict__' not in PhoneNumber.supported_formats
assert 'to_string' not in PhoneNumber.supported_formats
with pytest.raises(ValidationError, match='value is not from a supported region'):
Something(phone_number='+44 20 7946 0958')
USPhoneNumber = PhoneNumber()
USPhoneNumber.supported_regions = ['US']
assert USPhoneNumber.supported_regions == ['US']
assert Something(phone_number='+1 901 555 1212')
with pytest.raises(ValidationError, match='value is not from a supported region'):
Something(phone_number='+44 20 7946 0958')
def test_parse_error() -> None:
@ -64,20 +70,3 @@ def test_eq() -> None:
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',
'type': 'object',
'properties': {
'phone_number': {
'title': 'Phone Number',
'type': 'string',
'format': 'phone',
'minLength': 7,
'maxLength': 64,
}
},
'required': ['phone_number'],
}

View file

@ -0,0 +1,108 @@
from typing import Any, Optional, Union
try:
from typing import Annotated
except ImportError:
# Python 3.8
from typing_extensions import Annotated
import phonenumbers
import pytest
from phonenumbers import PhoneNumber
from pydantic import BaseModel, TypeAdapter, ValidationError
from pydantic_extra_types.phone_numbers import PhoneNumberValidator
Number = Annotated[Union[str, PhoneNumber], PhoneNumberValidator()]
NANumber = Annotated[
Union[str, PhoneNumber],
PhoneNumberValidator(
supported_regions=['US', 'CA'],
default_region='US',
),
]
UKNumber = Annotated[
Union[str, PhoneNumber],
PhoneNumberValidator(
supported_regions=['GB'],
default_region='GB',
number_format='E164',
),
]
number_adapter = TypeAdapter(Number)
class Numbers(BaseModel):
phone_number: Optional[Number] = None
na_number: Optional[NANumber] = None
uk_number: Optional[UKNumber] = None
def test_validator_constructor() -> None:
PhoneNumberValidator()
PhoneNumberValidator(supported_regions=['US', 'CA'], default_region='US')
PhoneNumberValidator(supported_regions=['GB'], default_region='GB', number_format='E164')
with pytest.raises(ValueError, match='Invalid default region code: XX'):
PhoneNumberValidator(default_region='XX')
with pytest.raises(ValueError, match='Invalid number format: XX'):
PhoneNumberValidator(number_format='XX')
with pytest.raises(ValueError, match='Invalid supported region code: XX'):
PhoneNumberValidator(supported_regions=['XX'])
# Note: the 555 area code will result in an invalid phone number
def test_valid_phone_number() -> None:
Numbers(phone_number='+1 901 555 1212')
def test_when_extension_provided() -> None:
Numbers(phone_number='+1 901 555 1212 ext 12533')
def test_when_phonenumber_instance() -> None:
phone_number = phonenumbers.parse('+1 901 555 1212', region='US')
numbers = Numbers(phone_number=phone_number)
assert numbers.phone_number == 'tel:+1-901-555-1212'
# Additional validation is still performed on the instance
with pytest.raises(ValidationError, match='value is not from a supported region'):
Numbers(uk_number=phone_number)
@pytest.mark.parametrize('invalid_number', ['', '123', 12, object(), '55 121'])
def test_invalid_phone_number(invalid_number: Any) -> None:
# Use a TypeAdapter to test the validation logic for None otherwise
# optional fields will not attempt to validate
with pytest.raises(ValidationError, match='value is not a valid phone number'):
number_adapter.validate_python(invalid_number)
def test_formats_phone_number() -> None:
result = Numbers(phone_number='+1 901 555 1212 ext 12533', uk_number='+44 20 7946 0958')
assert result.phone_number == 'tel:+1-901-555-1212;ext=12533'
assert result.uk_number == '+442079460958'
def test_default_region() -> None:
result = Numbers(na_number='901 555 1212')
assert result.na_number == 'tel:+1-901-555-1212'
with pytest.raises(ValidationError, match='value is not a valid phone number'):
Numbers(phone_number='901 555 1212')
def test_supported_regions() -> None:
assert Numbers(na_number='+1 901 555 1212')
assert Numbers(uk_number='+44 20 7946 0958')
with pytest.raises(ValidationError, match='value is not from a supported region'):
Numbers(na_number='+44 20 7946 0958')
def test_parse_error() -> None:
with pytest.raises(ValidationError, match='value is not a valid phone number'):
Numbers(phone_number='555 1212')
def test_parsed_but_not_a_valid_number() -> None:
with pytest.raises(ValidationError, match='value is not a valid phone number'):
Numbers(phone_number='+1 555-1212')

152
tests/test_s3.py Normal file
View file

@ -0,0 +1,152 @@
import pytest
from pydantic import BaseModel, ValidationError
from pydantic_extra_types.s3 import S3Path
class S3Check(BaseModel):
path: S3Path
@pytest.mark.parametrize(
'raw,bucket,key,last_key',
[
(
's3://my-data-bucket/2023/08/29/sales-report.csv',
'my-data-bucket',
'2023/08/29/sales-report.csv',
'sales-report.csv',
),
(
's3://logs-bucket/app-logs/production/2024/07/01/application-log.txt',
'logs-bucket',
'app-logs/production/2024/07/01/application-log.txt',
'application-log.txt',
),
(
's3://backup-storage/user_data/john_doe/photos/photo-2024-08-15.jpg',
'backup-storage',
'user_data/john_doe/photos/photo-2024-08-15.jpg',
'photo-2024-08-15.jpg',
),
(
's3://analytics-bucket/weekly-reports/Q3/2023/week-35-summary.pdf',
'analytics-bucket',
'weekly-reports/Q3/2023/week-35-summary.pdf',
'week-35-summary.pdf',
),
(
's3://project-data/docs/presentations/quarterly_review.pptx',
'project-data',
'docs/presentations/quarterly_review.pptx',
'quarterly_review.pptx',
),
(
's3://my-music-archive/genres/rock/2024/favorite-songs.mp3',
'my-music-archive',
'genres/rock/2024/favorite-songs.mp3',
'favorite-songs.mp3',
),
(
's3://video-uploads/movies/2024/03/action/thriller/movie-trailer.mp4',
'video-uploads',
'movies/2024/03/action/thriller/movie-trailer.mp4',
'movie-trailer.mp4',
),
(
's3://company-files/legal/contracts/contract-2023-09-01.pdf',
'company-files',
'legal/contracts/contract-2023-09-01.pdf',
'contract-2023-09-01.pdf',
),
(
's3://dev-environment/source-code/release_v1.0.2.zip',
'dev-environment',
'source-code/release_v1.0.2.zip',
'release_v1.0.2.zip',
),
(
's3://public-bucket/open-data/geojson/maps/city_boundaries.geojson',
'public-bucket',
'open-data/geojson/maps/city_boundaries.geojson',
'city_boundaries.geojson',
),
(
's3://image-storage/2024/portfolio/shoots/wedding/couple_photo_12.jpg',
'image-storage',
'2024/portfolio/shoots/wedding/couple_photo_12.jpg',
'couple_photo_12.jpg',
),
(
's3://finance-data/reports/2024/Q2/income_statement.xlsx',
'finance-data',
'reports/2024/Q2/income_statement.xlsx',
'income_statement.xlsx',
),
(
's3://training-data/nlp/corpora/english/2023/text_corpus.txt',
'training-data',
'nlp/corpora/english/2023/text_corpus.txt',
'text_corpus.txt',
),
(
's3://ecommerce-backup/2024/transactions/august/orders_2024_08_28.csv',
'ecommerce-backup',
'2024/transactions/august/orders_2024_08_28.csv',
'orders_2024_08_28.csv',
),
(
's3://gaming-assets/3d_models/characters/hero/model_v5.obj',
'gaming-assets',
'3d_models/characters/hero/model_v5.obj',
'model_v5.obj',
),
(
's3://iot-sensor-data/2024/temperature_sensors/sensor_42_readings.csv',
'iot-sensor-data',
'2024/temperature_sensors/sensor_42_readings.csv',
'sensor_42_readings.csv',
),
(
's3://user-uploads/avatars/user123/avatar_2024_08_29.png',
'user-uploads',
'avatars/user123/avatar_2024_08_29.png',
'avatar_2024_08_29.png',
),
(
's3://media-library/podcasts/2023/episode_45.mp3',
'media-library',
'podcasts/2023/episode_45.mp3',
'episode_45.mp3',
),
(
's3://logs-bucket/security/firewall-logs/2024/08/failed_attempts.log',
'logs-bucket',
'security/firewall-logs/2024/08/failed_attempts.log',
'failed_attempts.log',
),
(
's3://data-warehouse/financials/quarterly/2024/Q1/profit_loss.csv',
'data-warehouse',
'financials/quarterly/2024/Q1/profit_loss.csv',
'profit_loss.csv',
),
(
's3://data-warehouse/financials/quarterly/2024/Q1',
'data-warehouse',
'financials/quarterly/2024/Q1',
'Q1',
),
],
)
def test_s3(raw: str, bucket: str, key: str, last_key: str):
model = S3Check(path=raw)
assert model.path == S3Path(raw)
assert model.path.bucket == bucket
assert model.path.key == key
assert model.path.last_key == last_key
def test_wrong_s3():
with pytest.raises(ValidationError):
S3Check(path='s3/ok')

View file

@ -12,12 +12,14 @@ def application_object_fixture():
return Application
def test_valid_semantic_version(SemanticVersionObject):
application = SemanticVersionObject(version='1.0.0')
@pytest.mark.parametrize('version', ['1.0.0', '1.0.0-alpha.1', '1.0.0-alpha.1+build.1', '1.2.3'])
def test_valid_semantic_version(SemanticVersionObject, version):
application = SemanticVersionObject(version=version)
assert application.version
assert application.model_dump() == {'version': '1.0.0'}
assert application.model_dump() == {'version': version}
def test_invalid_semantic_version(SemanticVersionObject):
@pytest.mark.parametrize('invalid_version', ['no dots string', 'with.dots.string', ''])
def test_invalid_semantic_version(SemanticVersionObject, invalid_version):
with pytest.raises(ValidationError):
SemanticVersionObject(version='Peter Maffay')
SemanticVersionObject(version=invalid_version)

21
tests/test_semver.py Normal file
View file

@ -0,0 +1,21 @@
import pytest
from pydantic import BaseModel
from pydantic_extra_types.semver import _VersionPydanticAnnotation
class SomethingWithAVersion(BaseModel):
version: _VersionPydanticAnnotation
def test_valid_semver() -> None:
SomethingWithAVersion(version='1.2.3')
def test_valid_semver_with_prerelease() -> None:
SomethingWithAVersion(version='1.2.3-alpha.1')
def test_invalid_semver() -> None:
with pytest.raises(ValueError):
SomethingWithAVersion(version='jim.was.here')