Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from typing import Optional
from .validation_phone_e164_data import PHONE_COUNTRIES, PhoneCountry ← this capability’s own data, compiled from data/phone-countries.json into the same file by fune build
from .validation_phone_e164_types import PhoneE164
#: Characters people put between digits. Brackets matter because of the UK
#: habit of writing "+44 (0)20 ...".
_SEPARATORS = " -.()"
def _invalid(reason: str) -> PhoneE164:
return PhoneE164(valid=False, normalised=None, calling_code=None, national_number=None, reason=reason)
def _country(code: str) -> Optional[PhoneCountry]:
for row in PHONE_COUNTRIES:
if row.country == code:
return row
return None
def phone_e164(value: str, default_country: str) -> PhoneE164:
"""Normalise a phone number to E.164 for a default country.
This is a plausibility check over a small table of countries, not a
numbering-plan database: it knows each country's calling code, trunk
prefix, international dialling prefix, national number lengths and the
digits a national number may start with. It does not know which ranges
are allocated, or whether a number is a mobile.
"""
# ASCII-only upper-casing, so every language folds identically.
code = "".join(chr(ord(ch) - 32) if "a" <= ch <= "z" else ch for ch in default_country)
home = None
if code != "":
home = _country(code)
# A bad default country is the caller's bug, not the user's typo, so
# it is raised rather than answered.
if home is None:
raise ValueError('unsupported default country "%s"' % (default_country,))
if not isinstance(value, str):
return _invalid("empty")
digits = ""
plus = False
for ch in value:
if ch in _SEPARATORS:
continue
if ch == "+":
# A plus is only meaningful as the very first thing typed.
if plus or digits != "":
return _invalid("bad-format")
plus = True
continue
if ch < "0" or ch > "9":
return _invalid("bad-character")
digits += ch
if digits == "":
return _invalid("bad-format" if plus else "empty")
if not plus and home is not None and digits.startswith(home.international_prefix):
# Dialled with the home country's international prefix: 00 44 ...
# from the UK, 011 44 ... from North America, 0011 44 ... from
# Australia.
digits = digits[len(home.international_prefix):]
plus = True
if plus:
country = None
# Calling codes are prefix-free, so the first match is the only one.
for size in (1, 2, 3):
head = digits[:size]
if home is not None and home.calling_code == head:
country = home
break
candidate = next((row for row in PHONE_COUNTRIES if row.calling_code == head), None)
if candidate is not None:
country = candidate
break
if country is None:
return _invalid("unknown-country-code")
national = digits[len(country.calling_code):]
else:
if home is None:
return _invalid("no-country")
country = home
national = digits
# The trunk prefix is dialled only inside the country ("0" in the UK, "1"
# in North America). It is dropped once, in national form and in the
# "+44 (0)20" form alike; no national number starts with it anyway.
if country.trunk_prefix != "" and national.startswith(country.trunk_prefix):
national = national[len(country.trunk_prefix):]
if len(national) < country.min_length or len(national) > country.max_length:
return _invalid("bad-length")
if national[0] not in country.leading_digits:
return _invalid("bad-prefix")
return PhoneE164(
valid=True,
normalised="+" + country.calling_code + national,
calling_code=country.calling_code,
national_number=national,
reason=None,
)