diff options
-rw-r--r-- | pydantic_extra_types/__init__.py | 2 | ||||
-rw-r--r-- | pydantic_extra_types/domain.py | 61 | ||||
-rw-r--r-- | pydantic_extra_types/phone_numbers.py | 131 | ||||
-rw-r--r-- | pydantic_extra_types/s3.py | 70 | ||||
-rw-r--r-- | pydantic_extra_types/semver.py | 84 | ||||
-rw-r--r-- | pyproject.toml | 16 | ||||
-rw-r--r-- | requirements/linting.txt | 6 | ||||
-rw-r--r-- | requirements/pyproject.txt | 11 | ||||
-rw-r--r-- | requirements/testing.txt | 4 | ||||
-rw-r--r-- | tests/test_coordinate.py | 2 | ||||
-rw-r--r-- | tests/test_domain.py | 76 | ||||
-rw-r--r-- | tests/test_json_schema.py | 116 | ||||
-rw-r--r-- | tests/test_phone_numbers.py | 37 | ||||
-rw-r--r-- | tests/test_phone_numbers_validator.py | 108 | ||||
-rw-r--r-- | tests/test_s3.py | 152 | ||||
-rw-r--r-- | tests/test_semantic_version.py | 12 | ||||
-rw-r--r-- | tests/test_semver.py | 21 |
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') |