summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--pydantic_extra_types/__init__.py2
-rw-r--r--pydantic_extra_types/domain.py61
-rw-r--r--pydantic_extra_types/phone_numbers.py131
-rw-r--r--pydantic_extra_types/s3.py70
-rw-r--r--pydantic_extra_types/semver.py84
-rw-r--r--pyproject.toml16
-rw-r--r--requirements/linting.txt6
-rw-r--r--requirements/pyproject.txt11
-rw-r--r--requirements/testing.txt4
-rw-r--r--tests/test_coordinate.py2
-rw-r--r--tests/test_domain.py76
-rw-r--r--tests/test_json_schema.py116
-rw-r--r--tests/test_phone_numbers.py37
-rw-r--r--tests/test_phone_numbers_validator.py108
-rw-r--r--tests/test_s3.py152
-rw-r--r--tests/test_semantic_version.py12
-rw-r--r--tests/test_semver.py21
17 files changed, 843 insertions, 66 deletions
diff --git a/pydantic_extra_types/__init__.py b/pydantic_extra_types/__init__.py
index 387cfac..e2e6e4b 100644
--- a/pydantic_extra_types/__init__.py
+++ b/pydantic_extra_types/__init__.py
@@ -1 +1 @@
-__version__ = '2.9.0'
+__version__ = '2.10.0'
diff --git a/pydantic_extra_types/domain.py b/pydantic_extra_types/domain.py
new file mode 100644
index 0000000..916cd70
--- /dev/null
+++ b/pydantic_extra_types/domain.py
@@ -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)
diff --git a/pydantic_extra_types/phone_numbers.py b/pydantic_extra_types/phone_numbers.py
index 1fb9678..20f5a02 100644
--- a/pydantic_extra_types/phone_numbers.py
+++ b/pydantic_extra_types/phone_numbers.py
@@ -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__()
diff --git a/pydantic_extra_types/s3.py b/pydantic_extra_types/s3.py
new file mode 100644
index 0000000..c28ffee
--- /dev/null
+++ b/pydantic_extra_types/s3.py
@@ -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__,
+ )
diff --git a/pydantic_extra_types/semver.py b/pydantic_extra_types/semver.py
new file mode 100644
index 0000000..3418ec9
--- /dev/null
+++ b/pydantic_extra_types/semver.py
@@ -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]
diff --git a/pyproject.toml b/pyproject.toml
index 824db8c..e5e61a2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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
diff --git a/requirements/linting.txt b/requirements/linting.txt
index a117fc1..906747a 100644
--- a/requirements/linting.txt
+++ b/requirements/linting.txt
@@ -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
diff --git a/requirements/pyproject.txt b/requirements/pyproject.txt
index aab552b..a44249f 100644
--- a/requirements/pyproject.txt
+++ b/requirements/pyproject.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 --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)
diff --git a/requirements/testing.txt b/requirements/testing.txt
index 5a8a7f9..00a90a6 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.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
diff --git a/tests/test_coordinate.py b/tests/test_coordinate.py
index 199d988..e75ab86 100644
--- a/tests/test_coordinate.py
+++ b/tests/test_coordinate.py
@@ -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',
diff --git a/tests/test_domain.py b/tests/test_domain.py
new file mode 100644
index 0000000..a149b94
--- /dev/null
+++ b/tests/test_domain.py
@@ -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)
diff --git a/tests/test_json_schema.py b/tests/test_json_schema.py
index c39c88f..efeade3 100644
--- a/tests/test_json_schema.py
+++ b/tests/test_json_schema.py
@@ -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):
diff --git a/tests/test_phone_numbers.py b/tests/test_phone_numbers.py
index d2b8570..04b418e 100644
--- a/tests/test_phone_numbers.py
+++ b/tests/test_phone_numbers.py
@@ -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'],
- }
diff --git a/tests/test_phone_numbers_validator.py b/tests/test_phone_numbers_validator.py
new file mode 100644
index 0000000..b3a169c
--- /dev/null
+++ b/tests/test_phone_numbers_validator.py
@@ -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')
diff --git a/tests/test_s3.py b/tests/test_s3.py
new file mode 100644
index 0000000..abfb1e4
--- /dev/null
+++ b/tests/test_s3.py
@@ -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')
diff --git a/tests/test_semantic_version.py b/tests/test_semantic_version.py
index dc79bef..4df76a3 100644
--- a/tests/test_semantic_version.py
+++ b/tests/test_semantic_version.py
@@ -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)
diff --git a/tests/test_semver.py b/tests/test_semver.py
new file mode 100644
index 0000000..26092e5
--- /dev/null
+++ b/tests/test_semver.py
@@ -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')