Skip to content

Commit

Permalink
Add phone number validator (#203)
Browse files Browse the repository at this point in the history
* feature: Add phone number validator

* comment and import cleanup

* comment and import cleanup

* fix: Add typing extension fallback for python 3.8

* fix: Lint

* fix: Make mypy happy

* chore: Move json schema tests to json schema file
  • Loading branch information
mZbZ authored Aug 18, 2024
1 parent 49db83a commit d409bfa
Show file tree
Hide file tree
Showing 4 changed files with 282 additions and 16 deletions.
111 changes: 110 additions & 1 deletion pydantic_extra_types/phone_numbers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"'
Expand Down Expand Up @@ -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__()
64 changes: 64 additions & 0 deletions tests/test_json_schema.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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',
Expand Down Expand Up @@ -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):
Expand Down
15 changes: 0 additions & 15 deletions tests/test_phone_numbers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
}
108 changes: 108 additions & 0 deletions tests/test_phone_numbers_validator.py
Original file line number Diff line number Diff line change
@@ -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')

0 comments on commit d409bfa

Please sign in to comment.