from typing import Optional from .validation_phone_e164_data import PHONE_COUNTRIES, PhoneCountry 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, )