Source code for rs_metadata.normalize.people

"""Matching authors and maintainers across metadata formats.

The hardest comparison in the project, and the one most likely to produce a
false alarm. Formats disagree about everything: CodeMeta writes ``givenName``
and CFF writes ``given-names``; packaging files often give only a single
display string; one file abbreviates a given name that another spells out; and
someone's surname may legitimately change between releases.

The rule is that an ORCID is authoritative in **both** directions — two
different ORCIDs are two different people even if the names match exactly —
and that names are only consulted when at least one side has no ORCID.
"""

from __future__ import annotations

import re
from dataclasses import dataclass
from typing import Any

from nameparser import HumanName

from .identifiers import extract_orcid
from .scalars import as_list, normalize_text

__all__ = [
    "PersonRecord",
    "people_match",
    "person_record",
    "split_full_name",
]

_WHITESPACE = re.compile(r"\s+")

#: Name particles that belong to the family name rather than to the given
#: names. Essential for Dutch and German names, which are common at LUMC:
#: splitting "Jan van der Berg" on whitespace alone gets the family name wrong.


[docs] @dataclass(frozen=True) class PersonRecord: """A person or organization reduced to what can be matched across formats. Attributes ---------- orcid : str or None Bare ORCID, if the entry carries one. family, given : str or None Normalized name parts. name : str or None Normalized full name, used for organizations and as a fallback. display : str Human-readable rendering for the report. """ orcid: str | None family: str | None given: str | None name: str | None display: str
[docs] def split_full_name(full: str) -> tuple[str | None, str | None]: """Split a single name string into given and family parts. Parameters ---------- full : str A name as one string, in either ``Given Family`` or ``Family, Given`` order. Returns ------- tuple ``(given, family)``, either of which may be ``None``. Notes ----- A heuristic by nature: formats that offer only one name field give us no better information. ``nameparser`` does the splitting, which is why particles land with the family name — an earlier version kept its own list of prefixes, which handled neither the locale-dependent capitalization nor the compound forms. Examples -------- >>> split_full_name("Jane Doe") ('Jane', 'Doe') Particles stay with the family name, across locales: >>> split_full_name("Jan van der Berg") ('Jan', 'van der Berg') >>> split_full_name("Ludwig von Mises") ('Ludwig', 'von Mises') >>> split_full_name("Maria de la Cruz") ('Maria', 'de la Cruz') ``Family, Given`` order is understood: >>> split_full_name("Doe, Jane") ('Jane', 'Doe') A mononym is treated as a family name, since that is what citation styles do with it: >>> split_full_name("Cher") (None, 'Cher') """ text = _WHITESPACE.sub(" ", full).strip() if not text: return None, None if "," in text: family, _, given = text.partition(",") return given.strip() or None, family.strip() or None if " " not in text: return None, text parsed = HumanName(text) family = " ".join(part for part in (parsed.last, parsed.suffix) if part).strip() given = " ".join(part for part in (parsed.first, parsed.middle) if part).strip() return given or None, family or None
[docs] def person_record(value: Any) -> PersonRecord: """Reduce an author entry from any supported format to a comparable record. Parameters ---------- value : Any A CodeMeta ``Person``/``Organization`` node, a CFF author entry, a packaging ``{"name": ..., "email": ...}`` table, or a bare string. Returns ------- PersonRecord The comparable form. Examples -------- Both naming conventions reduce identically, which is what lets a CodeMeta author be compared with a CFF one: >>> codemeta = person_record({"givenName": "Jane", "familyName": "Doe"}) >>> cff = person_record({"given-names": "Jane", "family-names": "Doe"}) >>> (codemeta.given, codemeta.family) == (cff.given, cff.family) True An ORCID is picked up from ``@id``, ``orcid`` or ``identifier``: >>> person_record({"@id": "https://orcid.org/0000-0002-1825-0097", ... "familyName": "Carberry"}).orcid '0000-0002-1825-0097' >>> person_record({"orcid": "0000-0002-1825-0097"}).orcid '0000-0002-1825-0097' A single display string is split: >>> record = person_record("Jan van der Berg") >>> record.given, record.family ('jan', 'van der berg') An organization has a name but no name parts: >>> record = person_record({"@type": "Organization", "name": "LUMC"}) >>> record.name, record.family ('lumc', None) """ if isinstance(value, str): given, family = split_full_name(value) return PersonRecord( orcid=None, family=normalize_text(family), given=normalize_text(given), name=normalize_text(value), display=value.strip(), ) if not isinstance(value, dict): return PersonRecord(None, None, None, None, str(value)) orcid = None for key in ("@id", "orcid", "identifier"): candidate = value.get(key) for item in as_list(candidate): orcid = extract_orcid(item) if orcid: break if orcid: break family = value.get("familyName") or value.get("family-names") given = value.get("givenName") or value.get("given-names") name = value.get("name") # An organization has a name, not name parts. Splitting "Leiden University # Medical Center" into given and family names would be meaningless, so # organizations are matched on their name alone. if not family and value.get("@type") != "Organization" and isinstance(name, str): split_given, split_family = split_full_name(name) family = split_family given = given or split_given display = ( " ".join( part for part in [str(given or "").strip(), str(family or "").strip()] if part ) or str(name or "").strip() or (orcid or "?") ) if orcid: display = f"{display} <{orcid}>" if display != orcid else orcid return PersonRecord( orcid=orcid, family=normalize_text(family), given=normalize_text(given), name=normalize_text(name) or normalize_text(f"{given or ''} {family or ''}".strip()), display=display, )
[docs] def given_names_compatible(left: str | None, right: str | None) -> bool: """Treat initials as compatible with the given names they abbreviate. Parameters ---------- left, right : str or None Normalized given names. A missing side is compatible with anything, since absence is not disagreement. Returns ------- bool Whether the two could name the same person. Examples -------- An initial matches the name it abbreviates: >>> given_names_compatible("josiah", "j.") True >>> given_names_compatible("j", "josiah") True Two different names do not, however similar: >>> given_names_compatible("jane", "john") False A middle name present on one side only is not a disagreement: >>> given_names_compatible("jane m", "jane") True Neither is a missing given name: >>> given_names_compatible(None, "jane") True """ if not left or not right: return True if left == right: return True left_parts = [part for part in re.split(r"[\s.]+", left) if part] right_parts = [part for part in re.split(r"[\s.]+", right) if part] if not left_parts or not right_parts: return True # Compare only the given names both sides supply; a middle name present # in one file and omitted in the other is not a disagreement. for a, b in zip(left_parts, right_parts, strict=False): if a == b: continue if len(a) == 1 and b.startswith(a): continue if len(b) == 1 and a.startswith(b): continue return False return True
[docs] def people_match(left: PersonRecord, right: PersonRecord) -> bool: """Decide whether two entries denote the same person or organization. Parameters ---------- left, right : PersonRecord Records to compare. Returns ------- bool Whether they denote the same agent. Notes ----- Resolution order: ORCIDs when both sides have one, then family name with compatible given names, then normalized full name. ORCIDs are decisive in both directions, so a name coincidence cannot merge two real people. Examples -------- A shared ORCID outranks differing names — someone's surname may change between releases: >>> a = person_record({"@id": "https://orcid.org/0000-0002-1825-0097", ... "givenName": "Josiah", "familyName": "Carberry"}) >>> b = person_record({"orcid": "0000-0002-1825-0097", ... "given-names": "J.", "family-names": "Karberri"}) >>> people_match(a, b) True Differing ORCIDs outrank identical names: >>> a = person_record({"@id": "https://orcid.org/0000-0002-1825-0097", ... "givenName": "Jane", "familyName": "Doe"}) >>> b = person_record({"orcid": "0000-0001-5109-3700", ... "given-names": "Jane", "family-names": "Doe"}) >>> people_match(a, b) False Without ORCIDs, names decide, and an initial is accepted: >>> people_match(person_record({"givenName": "Josiah", "familyName": "Carberry"}), ... person_record({"given-names": "J.", "family-names": "Carberry"})) True >>> people_match(person_record({"givenName": "Jane", "familyName": "Doe"}), ... person_record({"given-names": "John", "family-names": "Doe"})) False Organizations match on name: >>> people_match(person_record({"@type": "Organization", "name": "LUMC"}), ... person_record({"name": "lumc"})) True """ if left.orcid and right.orcid: return left.orcid == right.orcid if left.family and right.family: return left.family == right.family and given_names_compatible( left.given, right.given ) if left.name and right.name: return left.name == right.name return False