From 133fb3eb4cf8c20ec66941f2155b6adf4d135e9f Mon Sep 17 00:00:00 2001 From: mZbZ <5442528+mZbZ@users.noreply.github.com> Date: Fri, 16 Aug 2024 16:17:38 +0200 Subject: [PATCH 1/7] feature: Add phone number validator --- pydantic_extra_types/phone_numbers.py | 105 +++++++++++++++++- tests/test_phone_numbers_validator.py | 149 ++++++++++++++++++++++++++ 2 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 tests/test_phone_numbers_validator.py diff --git a/pydantic_extra_types/phone_numbers.py b/pydantic_extra_types/phone_numbers.py index 7860bf26..7087b9d5 100644 --- a/pydantic_extra_types/phone_numbers.py +++ b/pydantic_extra_types/phone_numbers.py @@ -7,13 +7,17 @@ from __future__ import annotations -from typing import Any, ClassVar +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"' @@ -71,3 +75,102 @@ def __eq__(self, other: Any) -> bool: 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[str, PhoneNumberValidator()] + USNumberType = Annotated[str, 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, *_) -> 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/tests/test_phone_numbers_validator.py b/tests/test_phone_numbers_validator.py new file mode 100644 index 00000000..c4505f42 --- /dev/null +++ b/tests/test_phone_numbers_validator.py @@ -0,0 +1,149 @@ +from typing import Annotated, Any, Optional, Union + +import phonenumbers +import pytest +from pydantic import BaseModel, TypeAdapter, ValidationError + +from pydantic_extra_types.phone_numbers import PhoneNumberValidator + +Number = Annotated[Union[str, phonenumbers.PhoneNumber], PhoneNumberValidator()] +NANumber = Annotated[ + Union[str, phonenumbers.PhoneNumber], + PhoneNumberValidator( + supported_regions=['US', 'CA'], + default_region='US', + ), +] +UKNumber = Annotated[ + Union[str, phonenumbers.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') + + +def test_json_schema() -> None: + assert Numbers.model_json_schema() == { + 'title': 'Numbers', + 'type': 'object', + 'properties': { + 'phone_number': { + 'title': 'Phone Number', + 'anyOf': [ + {'type': 'string', 'format': 'phone'}, + {'type': 'null'}, + ], + 'default': None, + }, + 'na_number': { + 'title': 'Na Number', + 'anyOf': [ + {'type': 'string', 'format': 'phone'}, + {'type': 'null'}, + ], + 'default': None, + }, + 'uk_number': { + 'title': 'Uk Number', + 'anyOf': [ + {'type': 'string', 'format': 'phone'}, + {'type': 'null'}, + ], + 'default': None, + }, + }, + } + + class SomethingElse(BaseModel): + phone_number: Number + + assert SomethingElse.model_json_schema() == { + 'title': 'SomethingElse', + 'type': 'object', + 'properties': { + 'phone_number': { + 'title': 'Phone Number', + 'type': 'string', + 'format': 'phone', + } + }, + 'required': ['phone_number'], + } From ea7054f795c61ae8c91bb0fe84bad8da0b109a0e Mon Sep 17 00:00:00 2001 From: mZbZ <5442528+mZbZ@users.noreply.github.com> Date: Fri, 16 Aug 2024 16:56:37 +0200 Subject: [PATCH 2/7] comment and import cleanup --- pydantic_extra_types/phone_numbers.py | 10 ++++++++-- tests/test_phone_numbers_validator.py | 7 ++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/pydantic_extra_types/phone_numbers.py b/pydantic_extra_types/phone_numbers.py index 7087b9d5..5aa30147 100644 --- a/pydantic_extra_types/phone_numbers.py +++ b/pydantic_extra_types/phone_numbers.py @@ -94,8 +94,14 @@ class PhoneNumberValidator: str: The formatted phone number. Example: - MyNumberType = Annotated[str, PhoneNumberValidator()] - USNumberType = Annotated[str, PhoneNumberValidator(supported_regions=['US'], default_region='US')] + 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 diff --git a/tests/test_phone_numbers_validator.py b/tests/test_phone_numbers_validator.py index c4505f42..d287f3ca 100644 --- a/tests/test_phone_numbers_validator.py +++ b/tests/test_phone_numbers_validator.py @@ -1,21 +1,22 @@ from typing import Annotated, Any, Optional, Union import phonenumbers +from phonenumbers import PhoneNumber import pytest from pydantic import BaseModel, TypeAdapter, ValidationError from pydantic_extra_types.phone_numbers import PhoneNumberValidator -Number = Annotated[Union[str, phonenumbers.PhoneNumber], PhoneNumberValidator()] +Number = Annotated[Union[str, PhoneNumber], PhoneNumberValidator()] NANumber = Annotated[ - Union[str, phonenumbers.PhoneNumber], + Union[str, PhoneNumber], PhoneNumberValidator( supported_regions=['US', 'CA'], default_region='US', ), ] UKNumber = Annotated[ - Union[str, phonenumbers.PhoneNumber], + Union[str, PhoneNumber], PhoneNumberValidator( supported_regions=['GB'], default_region='GB', From 798a2479a1e965b96e29b80ae04c66586c5814ca Mon Sep 17 00:00:00 2001 From: mZbZ <5442528+mZbZ@users.noreply.github.com> Date: Fri, 16 Aug 2024 16:57:31 +0200 Subject: [PATCH 3/7] comment and import cleanup --- tests/test_phone_numbers_validator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_phone_numbers_validator.py b/tests/test_phone_numbers_validator.py index d287f3ca..6fd57ff5 100644 --- a/tests/test_phone_numbers_validator.py +++ b/tests/test_phone_numbers_validator.py @@ -1,8 +1,8 @@ from typing import Annotated, Any, Optional, Union import phonenumbers -from phonenumbers import PhoneNumber import pytest +from phonenumbers import PhoneNumber from pydantic import BaseModel, TypeAdapter, ValidationError from pydantic_extra_types.phone_numbers import PhoneNumberValidator From f1f36fd5305ed1f33bfd827dfdd2f773a37b0f85 Mon Sep 17 00:00:00 2001 From: mZbZ <5442528+mZbZ@users.noreply.github.com> Date: Fri, 16 Aug 2024 17:02:18 +0200 Subject: [PATCH 4/7] fix: Add typing extension fallback for python 3.8 --- tests/test_phone_numbers_validator.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_phone_numbers_validator.py b/tests/test_phone_numbers_validator.py index 6fd57ff5..6d46aef2 100644 --- a/tests/test_phone_numbers_validator.py +++ b/tests/test_phone_numbers_validator.py @@ -1,4 +1,10 @@ -from typing import Annotated, Any, Optional, Union +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 b3728cb15a12ffd8baf5d32177dbef9bb5f9eae1 Mon Sep 17 00:00:00 2001 From: mZbZ <5442528+mZbZ@users.noreply.github.com> Date: Fri, 16 Aug 2024 17:03:22 +0200 Subject: [PATCH 5/7] fix: Lint --- tests/test_phone_numbers_validator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_phone_numbers_validator.py b/tests/test_phone_numbers_validator.py index 6d46aef2..27031f2d 100644 --- a/tests/test_phone_numbers_validator.py +++ b/tests/test_phone_numbers_validator.py @@ -1,4 +1,5 @@ from typing import Any, Optional, Union + try: from typing import Annotated except ImportError: From 52bcef62496dfa7653f6686aad5edd504707cd99 Mon Sep 17 00:00:00 2001 From: mZbZ <5442528+mZbZ@users.noreply.github.com> Date: Fri, 16 Aug 2024 17:06:49 +0200 Subject: [PATCH 6/7] fix: Make mypy happy --- pydantic_extra_types/phone_numbers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydantic_extra_types/phone_numbers.py b/pydantic_extra_types/phone_numbers.py index 5aa30147..20f5a02d 100644 --- a/pydantic_extra_types/phone_numbers.py +++ b/pydantic_extra_types/phone_numbers.py @@ -160,7 +160,7 @@ def _parse( return phonenumbers.format_number(parsed_number, getattr(phonenumbers.PhoneNumberFormat, number_format)) - def __get_pydantic_core_schema__(self, *_) -> core_schema.CoreSchema: + 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, From b3d12f7fc40b7cfbc3b043cbfe77dc9a916881c5 Mon Sep 17 00:00:00 2001 From: mZbZ <5442528+mZbZ@users.noreply.github.com> Date: Sun, 18 Aug 2024 19:38:57 +0200 Subject: [PATCH 7/7] chore: Move json schema tests to json schema file --- tests/test_json_schema.py | 64 +++++++++++++++++++++++++++ tests/test_phone_numbers.py | 15 ------- tests/test_phone_numbers_validator.py | 49 -------------------- 3 files changed, 64 insertions(+), 64 deletions(-) diff --git a/tests/test_json_schema.py b/tests/test_json_schema.py index 7483d12d..84fced28 100644 --- a/tests/test_json_schema.py +++ b/tests/test_json_schema.py @@ -1,7 +1,15 @@ +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 @@ -22,6 +30,7 @@ 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.script_code import ISO_15924 from pydantic_extra_types.semantic_version import SemanticVersion from pydantic_extra_types.semver import _VersionPydanticAnnotation @@ -47,6 +56,16 @@ 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', @@ -369,6 +388,51 @@ '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'], + }, + ), ], ) def test_json_schema(cls, expected): diff --git a/tests/test_phone_numbers.py b/tests/test_phone_numbers.py index e34995f8..04b418e3 100644 --- a/tests/test_phone_numbers.py +++ b/tests/test_phone_numbers.py @@ -70,18 +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', - } - }, - 'required': ['phone_number'], - } diff --git a/tests/test_phone_numbers_validator.py b/tests/test_phone_numbers_validator.py index 27031f2d..b3a169ce 100644 --- a/tests/test_phone_numbers_validator.py +++ b/tests/test_phone_numbers_validator.py @@ -106,52 +106,3 @@ def test_parse_error() -> None: 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') - - -def test_json_schema() -> None: - assert Numbers.model_json_schema() == { - 'title': 'Numbers', - 'type': 'object', - 'properties': { - 'phone_number': { - 'title': 'Phone Number', - 'anyOf': [ - {'type': 'string', 'format': 'phone'}, - {'type': 'null'}, - ], - 'default': None, - }, - 'na_number': { - 'title': 'Na Number', - 'anyOf': [ - {'type': 'string', 'format': 'phone'}, - {'type': 'null'}, - ], - 'default': None, - }, - 'uk_number': { - 'title': 'Uk Number', - 'anyOf': [ - {'type': 'string', 'format': 'phone'}, - {'type': 'null'}, - ], - 'default': None, - }, - }, - } - - class SomethingElse(BaseModel): - phone_number: Number - - assert SomethingElse.model_json_schema() == { - 'title': 'SomethingElse', - 'type': 'object', - 'properties': { - 'phone_number': { - 'title': 'Phone Number', - 'type': 'string', - 'format': 'phone', - } - }, - 'required': ['phone_number'], - }