diff --git a/pydantic_extra_types/phone_numbers.py b/pydantic_extra_types/phone_numbers.py index 7860bf2..20f5a02 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,108 @@ 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[ + 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/tests/test_json_schema.py b/tests/test_json_schema.py index 7483d12..84fced2 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 e34995f..04b418e 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 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')