Skip to content

Phone numbers

Phone numbers are normalised to the international E.164 format (think +442083661177). Source datasets often give numbers in national format, without the country prefix. Such values cannot be interpreted on their own and are rejected, unless you name the country the number is dialed in via the format argument:

from followthemoney import model
from followthemoney.types import registry

registry.phone.clean("020 8366 1177")               # None
registry.phone.clean("020 8366 1177", format="gb")  # '+442083661177'

entity = model.make_entity("Company")
entity.add("phone", "020 8366 1177", format="gb")

The hint accepts any country code rigour recognises, including three-letter codes and subdivisions such as gb-eng, which resolve to the country they dial through. Territories without a dialing plan of their own (eu, historical states) raise an error, as does a code that cannot be resolved at all.

Upgrading from 4.10 and earlier

Earlier versions parsed national-format numbers using the country properties of the proxy they were added to, which made the result depend on the order in which properties were added. The entity is not consulted: pass format, or such numbers are rejected.

Attribute Value Detail
name phone Used in schema definitions
label Phone number plural: Phone numbers
group phones Used in search indexing to query all properties of a given type
matchable Suitable for use in entity matching
pivot Suitable for use as a pivot point for connecting to other entities

Python API

FtM uses Google's phonenumbers library to validate and normalise phone numbers.

followthemoney.types.PhoneType

Bases: PropertyType

A phone number in E.164 format, i.e. one that always carries an international dialing prefix (e.g. +38760183628).

Source data often gives numbers in national format, without that prefix. Pass the country the number is dialed in as the format hint to have the prefix applied, e.g. entity.add("phone", "017623423980", format="de"). A number that is neither in international format nor accompanied by a hint is rejected.

Source code in followthemoney/types/phone.py
class PhoneType(PropertyType):
    """A phone number in E.164 format, i.e. one that always carries an
    international dialing prefix (e.g. `+38760183628`).

    Source data often gives numbers in national format, without that prefix. Pass
    the country the number is dialed in as the `format` hint to have the prefix
    applied, e.g. `entity.add("phone", "017623423980", format="de")`. A number
    that is neither in international format nor accompanied by a hint is rejected."""

    name = "phone"
    group = "phones"
    label = _("Phone number")
    plural = _("Phone numbers")
    matchable = True
    pivot = True
    max_length = 64

    def clean_text(
        self,
        text: str,
        fuzzy: bool = False,
        format: str | None = None,
        proxy: Optional["EntityProxy"] = None,
    ) -> str | None:
        # Resolved up front so that an invalid hint is reported even when the
        # number turns out to be in international format already:
        region = None if format is None else _dialing_region(format)
        parsed = _parse_valid(text, None)
        if parsed is None and region is not None:
            parsed = _parse_valid(text, region)
        if parsed is None:
            return None
        return str(format_number(parsed, PhoneNumberFormat.E164))

    def country_hint(self, value: str) -> str | None:
        try:
            number = parse_number(value)
            code = region_code_for_number(number)
            if code is None:
                return None
            return str(code).lower()
        except NumberParseException:
            return None

    def _specificity(self, value: str) -> float:
        # TODO: insert artificial intelligence here.
        return dampen(7, 11, value)

    def node_id(self, value: str) -> str | None:
        return f"tel:{value}"

    def caption(self, value: str, format: str | None = None) -> str:
        try:
            number = parse_number(value)
            formatted = format_number(number, PhoneNumberFormat.INTERNATIONAL)
            return str(formatted)
        except NumberParseException:
            return value