Skip to content

Phone numbers

Phone numbers are normalised to the international E.164 format (think +442083661177). Since phone numbers are often provided by a source dataset without their country prefix, it is very helpful to supply the type validation functions (phone.validate(value) and phone.clean(value)) with an proxy argument of the entity they will be assigned to, and from which country context can be inferred.

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. This means that phone numbers always include an international country prefix (e.g. +38760183628). The cleaning and validation functions for this try to be smart about by accepting a list of countries as an argument in order to add the number prefix.

When adding a property of this type to an entity, any country-type properties defined for the entity are considered for validation. That means that adding a phone number to an entity before adding a country can have a different validation outcome from doing the two operations the other way around. Always define the country first.

Source code in followthemoney/types/phone.py
class PhoneType(PropertyType):
    """A phone number in E.164 format. This means that phone numbers always
    include an international country prefix (e.g. `+38760183628`). The
    cleaning and validation functions for this try to be smart about by
    accepting a list of countries as an argument in order to add the number
    prefix.

    When adding a property of this type to an entity, any country-type properties
    defined for the entity are considered for validation. That means that adding a
    phone number to an entity before adding a country can have a different
    validation outcome from doing the two operations the other way around. Always
    define the country first."""

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

    def _clean_countries(
        self, proxy: Optional["EntityProxy"]
    ) -> Iterable[Optional[str]]:
        yield None
        if proxy is not None:
            for country in proxy.countries:
                yield country.upper()

    def _parse_number(
        self, number: str, proxy: Optional["EntityProxy"] = None
    ) -> Iterable[PhoneNumber]:
        """Parse a phone number and return in international format.

        If no valid phone number can be detected, None is returned. If
        a country code is supplied, this will be used to infer the
        prefix.

        https://github.com/daviddrysdale/python-phonenumbers
        """
        for code in self._clean_countries(proxy):
            try:
                yield parse_number(number, code)
            except NumberParseException:
                pass

    def validate(
        self, value: str, fuzzy: bool = False, format: Optional[str] = None
    ) -> bool:
        for num in self._parse_number(value):
            if is_valid_number(num):
                return True
        return False

    def clean_text(
        self,
        text: str,
        fuzzy: bool = False,
        format: Optional[str] = None,
        proxy: Optional["EntityProxy"] = None,
    ) -> Optional[str]:
        for num in self._parse_number(text, proxy=proxy):
            if is_valid_number(num):
                return str(format_number(num, PhoneNumberFormat.E164))
        return None

    def country_hint(self, value: str) -> Optional[str]:
        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) -> Optional[str]:
        return f"tel:{value}"

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