Functional Weave
Code in Python

validation.phone-e164@1.0.0

impl/python.py

3,958 bytes · the Python implementation · view raw

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,
    )