Merging upstream version 2.10.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
9304378357
commit
912971d44b
17 changed files with 843 additions and 66 deletions
|
@ -1 +1 @@
|
|||
__version__ = '2.9.0'
|
||||
__version__ = '2.10.0'
|
||||
|
|
61
pydantic_extra_types/domain.py
Normal file
61
pydantic_extra_types/domain.py
Normal 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)
|
|
@ -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__()
|
||||
|
|
70
pydantic_extra_types/s3.py
Normal file
70
pydantic_extra_types/s3.py
Normal 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__,
|
||||
)
|
84
pydantic_extra_types/semver.py
Normal file
84
pydantic_extra_types/semver.py
Normal 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]
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
76
tests/test_domain.py
Normal 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)
|
|
@ -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):
|
||||
|
|
|
@ -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'],
|
||||
}
|
||||
|
|
108
tests/test_phone_numbers_validator.py
Normal file
108
tests/test_phone_numbers_validator.py
Normal 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
152
tests/test_s3.py
Normal 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')
|
|
@ -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
21
tests/test_semver.py
Normal 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')
|
Loading…
Add table
Reference in a new issue