Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Add phone number validator #203

Merged
merged 7 commits into from
Aug 18, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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__()
157 changes: 157 additions & 0 deletions tests/test_phone_numbers_validator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
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')


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'],
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amazing work @mZbZ but can you migrate this part of the tests to tests/test_json_schema.py in the tests directory so we can keep the tree of tests follow a pattern

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you

No problem

FYI, there is still a codecov report that is still running...

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you

No problem

FYI, there is still a codecov report that is still running...

somethings its get bugged and stick like this you can try another change or an empty commit and its will be fixed

Loading