Source code for rs_metadata.profile.identifiers

"""Checks on people, organizations and persistent identifiers.

ORCID and ROR both carry check digits, so a mistyped identifier is an
**error** without asking whether the person or institution exists: the
string cannot be a valid identifier either way.
"""

from __future__ import annotations

from typing import Any

from ..diagnostics import Severity
from ..locate import Path as DocPath
from ..normalize import (
    as_list,
    extract_doi,
    extract_orcid,
    extract_ror,
    orcid_checksum_valid,
    ror_checksum_valid,
)
from ..report import Diagnostic
from ..vocabulary import agent_properties
from .context import ProfileContext
from .formats import url_problem
from .placeholders import PLACEHOLDER_ORCID

__all__ = ["agent_label", "check_agents", "check_identifiers"]


[docs] def agent_label(value: dict[str, Any]) -> str: """Name an agent for use in a message. Parameters ---------- value : dict A ``Person`` or ``Organization`` node. Returns ------- str The best available human-readable name. Examples -------- >>> agent_label({"givenName": "Jane", "familyName": "Doe"}) 'Jane Doe' >>> agent_label({"@type": "Organization", "name": "LUMC"}) 'LUMC' >>> agent_label({"familyName": "Doe"}) 'Doe' A node with nothing to name it still yields a usable phrase, so the message never reads "... given for ." : >>> agent_label({}) 'an unnamed agent' """ parts = [value.get("givenName"), value.get("familyName")] joined = " ".join(str(part) for part in parts if part) return joined or str(value.get("name") or "an unnamed agent")
[docs] def check_identifiers(ctx: ProfileContext) -> list[Diagnostic]: """Check the record's persistent identifiers. Parameters ---------- ctx : ProfileContext The record under validation. Returns ------- list of Diagnostic At most one informational finding. Notes ----- Only the absence of a DOI is reported, and only as **information**. It explicitly accepts a Git tag URL for software that will never be formally published, so a missing DOI cannot be an error — but a DOI is what makes a release citable, so it is worth saying. Examples -------- A DOI in any spelling satisfies the check: >>> ctx = ProfileContext.for_document( ... {"identifier": "https://doi.org/10.5281/zenodo.1"} ... ) >>> check_identifiers(ctx) [] A Git tag URL is accepted, with a note: >>> ctx = ProfileContext.for_document( ... {"identifier": "https://github.com/org/tool/tree/v1.0.0"} ... ) >>> [(d.code, d.severity.value) for d in check_identifiers(ctx)] [('recommendation.incomplete', 'info')] A DOI among several identifiers is enough: >>> ctx = ProfileContext.for_document({ ... "identifier": ["https://bio.tools/x", "10.5281/zenodo.1"] ... }) >>> check_identifiers(ctx) [] Absence is left to the mandatory-field check: >>> check_identifiers(ProfileContext.for_document({})) [] """ values = ctx.values("identifier") if not values: return [] if any(extract_doi(value) for value, _ in values): return [] return [ ctx.diagnostic( "recommendation.incomplete", Severity.INFO, f"{ctx.file} records no DOI among its identifiers. A DOI is what " f"makes a specific release citable and is required by most " f"archives.", path=("identifier",), suggestion=( "Mint one by enabling the Zenodo–GitHub integration and " "publishing a release. A Git tag URL is acceptable for " "internal tools that will never be formally published." ), prop="identifier", ) ]
[docs] def check_agents(ctx: ProfileContext) -> list[Diagnostic]: """Check authors, maintainers and their affiliations. Parameters ---------- ctx : ProfileContext The record under validation. Returns ------- list of Diagnostic Findings about agent identifiers. Notes ----- Verifies ORCID and ROR check digits, detects one ORCID used for two different people, and notes authors and affiliations that carry no identifier at all. The all-zero placeholder ORCID is deliberately **not** reported here even though its check digit fails; :mod:`.placeholders` gives it a message that explains the real problem. Examples -------- A well-formed author produces nothing: >>> author = { ... "@type": "Person", ... "@id": "https://orcid.org/0000-0002-1825-0097", ... "givenName": "Josiah", ... "familyName": "Carberry", ... "affiliation": { ... "@type": "Organization", ... "@id": "https://ror.org/05xvt9f17", ... "name": "Leiden University Medical Center", ... }, ... } >>> check_agents(ProfileContext.for_document({"author": [author]})) [] A mistyped ORCID fails its check digit, and that is an error: >>> broken = dict(author, **{"@id": "https://orcid.org/0000-0002-1825-0098"}) >>> finding = check_agents(ProfileContext.for_document({"author": [broken]}))[0] >>> finding.code, finding.severity.value ('profile.invalid-value', 'error') >>> "check digit" in finding.message True So does a mistyped ROR: >>> bad_ror = dict(author) >>> bad_ror["affiliation"] = dict(author["affiliation"], ... **{"@id": "https://ror.org/05xvt9f18"}) >>> ctx = ProfileContext.for_document({"author": [bad_ror]}) >>> [d.code for d in check_agents(ctx)] ['profile.invalid-value'] One ORCID for two people is a warning: >>> other = dict(author, givenName="Someone", familyName="Else") >>> ctx = ProfileContext.for_document({"author": [author, other]}) >>> [(d.code, d.severity.value) for d in check_agents(ctx)] [('profile.invalid-value', 'warning')] An author with no ORCID gets an informational nudge, but an organization does not — organizations do not have ORCIDs: >>> anonymous = {"@type": "Person", "givenName": "Jane", "familyName": "Doe"} >>> ctx = ProfileContext.for_document({"author": [anonymous]}) >>> [d.code for d in check_agents(ctx)] ['recommendation.incomplete'] >>> org = {"@type": "Organization", "@id": "https://ror.org/05xvt9f17", ... "name": "LUMC"} >>> check_agents(ProfileContext.for_document({"author": [org]})) [] """ diagnostics: list[Diagnostic] = [] seen_orcids: dict[str, str] = {} for prop in agent_properties(): for value, path in ctx.values(prop): if not isinstance(value, dict): continue diagnostics.extend(_check_agent_id(ctx, prop, value, path, seen_orcids)) diagnostics.extend(_check_affiliation(ctx, prop, value, path)) return diagnostics
def _check_agent_id( ctx: ProfileContext, prop: str, value: dict[str, Any], path: DocPath, seen_orcids: dict[str, str], ) -> list[Diagnostic]: """Check one agent's identifier, tracking ORCIDs already seen. An ORCID or ROR may be written as the node's ``@id`` or as its ``identifier``. Both are correct JSON-LD and both appear in the wild, so both are checked; the comparison layer already reads either. """ name = agent_label(value) node_id = _agent_identifier(value) if node_id is None: if prop == "author" and value.get("@type") != "Organization": return [ ctx.diagnostic( "recommendation.incomplete", Severity.INFO, f"{ctx.file}: author {name} has no ORCID, so the person " f"cannot be disambiguated from others with the same name.", path=path, suggestion=( 'Add "@id": "https://orcid.org/0000-0000-0000-0000" ' "with the author's real ORCID." ), prop=prop, ) ] return [] orcid = extract_orcid(node_id) if orcid is not None: if orcid == PLACEHOLDER_ORCID: # Reported by the placeholder check, which explains the real # problem. Its check digit is wrong too, but saying so twice # helps nobody. return [] if not orcid_checksum_valid(orcid): return [ ctx.diagnostic( "profile.invalid-value", Severity.ERROR, f"{ctx.file}: the ORCID {node_id!r} given for {name} fails " f"its check digit, so it is mistyped or invented.", path=(*path, "@id"), value=node_id, suggestion=( "Look the author up at https://orcid.org/ and copy the " "identifier exactly." ), prop=prop, ) ] if orcid in seen_orcids and seen_orcids[orcid] != name: return [ ctx.diagnostic( "profile.invalid-value", Severity.WARNING, f"{ctx.file}: the ORCID {orcid} is used for both " f"{seen_orcids[orcid]} and {name}. An ORCID identifies one " f"person.", path=(*path, "@id"), value=node_id, suggestion="Give each author their own ORCID.", prop=prop, ) ] seen_orcids.setdefault(orcid, name) return [] ror = extract_ror(node_id) if ror is not None: # An organization's own @id is checked here; affiliations are checked # by _check_affiliation. Both are RORs and both can be mistyped. if not ror_checksum_valid(ror): return [ ctx.diagnostic( "profile.invalid-value", Severity.ERROR, f"{ctx.file}: the ROR identifier {node_id!r} given for " f"{name} fails its check digits, so it is mistyped.", path=(*path, "@id"), value=node_id, suggestion="Look the organization up at https://ror.org/search.", prop=prop, ) ] return [] # Any absolute IRI identifies globally, not only an http one: urn: and # doi: identifiers are perfectly good @id values, and a startswith check # on "http" both rejected those and let "https://" through with no host. problem = url_problem(str(node_id).strip()) if problem is not None: return [ ctx.diagnostic( "profile.invalid-value", Severity.ERROR, f"{ctx.file}: the @id {node_id!r} of {name} cannot identify " f"anyone, because {problem}.", path=(*path, "@id"), value=node_id, suggestion=( "Use the person's ORCID URL, or the organization's ROR URL. " "Any absolute IRI is accepted if neither applies." ), prop=prop, ) ] return [] def _agent_identifier(value: dict[str, Any]) -> Any: """The identifier a node carries, from ``@id`` or from ``identifier``. ``@id`` is the JSON-LD-native place and is preferred, but a record may put the ORCID in ``identifier`` instead. Reading only ``@id`` meant a mistyped ORCID there was reported as *no* ORCID rather than a broken one. """ node_id = value.get("@id") if node_id is not None: return node_id for candidate in as_list(value.get("identifier")): if extract_orcid(candidate) or extract_ror(candidate): return candidate return None def _check_affiliation( ctx: ProfileContext, prop: str, value: dict[str, Any], path: DocPath ) -> list[Diagnostic]: """Check an agent's affiliations for a well-formed ROR.""" diagnostics: list[Diagnostic] = [] for index, affiliation in enumerate(as_list(value.get("affiliation"))): if not isinstance(affiliation, dict): continue base = (*path, "affiliation") affiliation_path = ( (*base, index) if isinstance(value.get("affiliation"), list) else base ) node_id = affiliation.get("@id") label = affiliation.get("name") or "an affiliation" if node_id is None: diagnostics.append( ctx.diagnostic( "recommendation.incomplete", Severity.INFO, f"{ctx.file}: the affiliation {label!r} has no ROR " f"identifier, so it cannot be matched to an institution " f"automatically.", path=affiliation_path, suggestion=( 'Add "@id" with the organization\'s ROR URL; look it ' "up at https://ror.org/search." ), prop=prop, ) ) continue ror = extract_ror(node_id) if ror is not None and not ror_checksum_valid(ror): diagnostics.append( ctx.diagnostic( "profile.invalid-value", Severity.ERROR, f"{ctx.file}: the ROR identifier {node_id!r} given for " f"{label!r} fails its check digits, so it is mistyped.", path=(*affiliation_path, "@id"), value=node_id, suggestion="Look the organization up at https://ror.org/search.", prop=prop, ) ) return diagnostics