Source code for rs_metadata.profile.placeholders

"""Detection of template values that were never filled in.

A placeholder is worse than a missing field. A missing field is visibly
absent; a placeholder asserts a specific identifier that does not exist, and
registries harvest it as though it did — attributing software to an ORCID
nobody holds, or pointing a citation at a DOI that resolves to nothing.

Every pattern here appears verbatim in a published template or in this
project's own examples, which is exactly why people leave them behind.
"""

from __future__ import annotations

import re

from ..adapters.codemeta import FEATURE_LIST, any_feature_list_key
from ..diagnostics import Severity
from ..locate import Path as DocPath
from ..normalize import extract_doi, extract_orcid, scalarize
from ..report import Diagnostic
from ..vocabulary import agent_properties
from ..vocabulary import placeholders as placeholders_data
from .context import ProfileContext

__all__ = [
    "PLACEHOLDER_DOI_PREFIX",
    "PLACEHOLDER_HOSTS",
    "PLACEHOLDER_ORCID",
    "check_placeholders",
    "has_placeholder_host",
]

_DATA = placeholders_data()

#: The all-zero ORCID used in author examples everywhere. Its check digit is
#: wrong, but that is not the useful thing to tell someone about it.
PLACEHOLDER_ORCID: str = _DATA["orcid"]

#: Reserved by RFC 2606 for documentation. These can never be a real
#: repository, so they are errors.
PLACEHOLDER_HOSTS = frozenset(_DATA["hosts"])

#: The DOI prefix conventionally used in examples. 10.0000 is not an assigned
#: registrant prefix.
PLACEHOLDER_DOI_PREFIX: str = _DATA["doiPrefix"]

#: The repository URL used in examples. Reported only as a warning: unlike a
#: reserved domain, a real account could in principle be named `example`, so
#: this is a strong hint rather than proof.
_PLACEHOLDER_REPO_RE = re.compile(
    _DATA["patterns"]["repository"]["regex"], re.IGNORECASE
)

#: EDAM's zero terms, used in examples to stand for a real operation or topic.
_PLACEHOLDER_EDAM = re.compile(_DATA["patterns"]["edamTerm"]["regex"], re.IGNORECASE)

_URL_PROPERTIES = ("identifier", "codeRepository", "url", "issueTracker")


[docs] def has_placeholder_host(value: str) -> bool: """Whether a URL points at an RFC 2606 reserved example domain. Parameters ---------- value : str A URL. Returns ------- bool Whether its host is a reserved example domain. Examples -------- >>> has_placeholder_host("https://example.com/mytool") True >>> has_placeholder_host("https://www.example.org/mytool") True A real host is not a placeholder, even if the path mentions examples: >>> has_placeholder_host("https://github.com/org/example-tool") False Nor is a domain that merely contains the word: >>> has_placeholder_host("https://example.lumc.nl/tool") False """ match = re.match(r"^https?://([^/]+)", value, re.IGNORECASE) if not match: return False host = match.group(1).casefold().removeprefix("www.") return host in PLACEHOLDER_HOSTS
[docs] def check_placeholders(ctx: ProfileContext) -> list[Diagnostic]: """Report template values that were copied but never replaced. Parameters ---------- ctx : ProfileContext The record under validation. Returns ------- list of Diagnostic One finding per placeholder found. Notes ----- Severity is calibrated to how certain the detection is. A reserved ``example.com`` host, a ``10.0000/`` DOI, the all-zero ORCID and EDAM's zero terms cannot be real, so they are **errors**. A ``github.com/example/`` repository is only a **warning**, because an account could in principle really be named ``example``. Examples -------- The guide's placeholder DOI cannot be real: >>> ctx = ProfileContext.for_document( ... {"identifier": "https://doi.org/10.0000/example.mytool.1"} ... ) >>> [(d.code, d.severity.value) for d in check_placeholders(ctx)] [('profile.placeholder-value', 'error')] Nor can a reserved example domain: >>> ctx = ProfileContext.for_document( ... {"codeRepository": "https://example.com/mytool"} ... ) >>> [d.severity.value for d in check_placeholders(ctx)] ['error'] A `github.com/example/` repository is a hint, not proof: >>> ctx = ProfileContext.for_document( ... {"codeRepository": "https://github.com/example/mytool"} ... ) >>> [d.severity.value for d in check_placeholders(ctx)] ['warning'] The all-zero ORCID is caught wherever an agent appears: >>> ctx = ProfileContext.for_document({ ... "author": [{"@id": "https://orcid.org/0000-0000-0000-0000", ... "familyName": "Doe"}] ... }) >>> [d.code for d in check_placeholders(ctx)] ['profile.placeholder-value'] So is EDAM's zero operation, even when the property is misspelled — the reader should see every problem at once rather than a second round after renaming it: >>> ctx = ProfileContext.for_document( ... {"featureList": ["http://edamontology.org/operation_0000"]} ... ) >>> [d.code for d in check_placeholders(ctx)] ['profile.placeholder-value'] Real values produce nothing: >>> ctx = ProfileContext.for_document({ ... "identifier": "https://doi.org/10.5281/zenodo.0000000", ... "codeRepository": "https://github.com/lumc/rs-metadata", ... }) >>> check_placeholders(ctx) [] """ diagnostics: list[Diagnostic] = [] for prop in _URL_PROPERTIES: for value, path in ctx.values(prop): scalar = scalarize(value) if scalar is None: continue doi = extract_doi(scalar) if doi and doi.startswith(PLACEHOLDER_DOI_PREFIX): diagnostics.append(_placeholder(ctx, prop, path, scalar, "DOI")) elif has_placeholder_host(scalar): diagnostics.append(_placeholder(ctx, prop, path, scalar, "URL")) elif _PLACEHOLDER_REPO_RE.match(scalar): diagnostics.append( _placeholder( ctx, prop, path, scalar, "repository URL", Severity.WARNING ) ) for prop in agent_properties(): for value, path in ctx.values(prop): if not isinstance(value, dict): continue if extract_orcid(value.get("@id")) == PLACEHOLDER_ORCID: diagnostics.append( _placeholder( ctx, prop, (*path, "@id"), str(value.get("@id")), "ORCID" ) ) key = any_feature_list_key(ctx.document) for prop in ([key] if key else []) + ["applicationSubCategory"]: for value, path in ctx.values(prop): scalar = scalarize(value) if scalar and _PLACEHOLDER_EDAM.match(scalar): diagnostics.append( _placeholder( ctx, FEATURE_LIST if prop == key else prop, path, scalar, "EDAM term", ) ) return diagnostics
def _placeholder( ctx: ProfileContext, prop: str, path: DocPath, value: str, kind: str, severity: Severity = Severity.ERROR, ) -> Diagnostic: """Build a placeholder diagnostic naming the kind of value involved.""" return ctx.diagnostic( "profile.placeholder-value", severity, f"{ctx.file}: the {kind} {value!r} is a placeholder from the " f"documentation examples and does not identify anything real.", path=path, value=value, suggestion=f"Replace it with this software's actual {kind}.", prop=prop, )