Source code for rs_metadata.profile.terminology

"""EDAM terms and the controlled vocabularies CodeMeta properties draw on.

EDAM terms are checked for *shape* and for *branch*, never for existence:
confirming a term exists would need a network lookup, while an operation term
sitting in the topics list is detectable offline and is a real mistake.

``developmentStatus`` must use
repostatus.org, so anything else is a warning. ``applicationCategory`` merely
*prefers* bio.tools tool types and explicitly permits free text, so a
non-vocabulary value is only informational.
"""

from __future__ import annotations

from ..adapters.codemeta import FEATURE_LIST, any_feature_list_key
from ..diagnostics import Severity
from ..normalize import EDAM_RE, scalarize
from ..report import Diagnostic
from ..vocabulary import vocabularies
from .context import ProfileContext

__all__ = ["check_edam", "check_vocabularies"]

_EDAM_BROWSER = "https://edamontology.github.io/edam-browser/"


[docs] def check_edam(ctx: ProfileContext) -> list[Diagnostic]: """Check EDAM terms in the operations and topics properties. Parameters ---------- ctx : ProfileContext The record under validation. Returns ------- list of Diagnostic Findings about EDAM usage. Notes ----- Three things are checked, and one is deliberately not: * a value that *looks* like EDAM but is malformed is an **error**; * an ``operation_`` term in the topics list, or a ``topic_`` term in the operations list, is an **error** — the two describe different things; * ``https://edamontology.org/...`` is a **warning**, because EDAM's own identifiers use ``http`` and the ``https`` form will not match them; * whether the term *exists* is not checked, because that needs a network lookup and CI must be deterministic. Free text is explicitly permitted, so a property with no EDAM terms at all is only an informational nudge. Examples -------- A well-formed operation term produces nothing: >>> ctx = ProfileContext.for_document( ... {"schema:featureList": ["http://edamontology.org/operation_0292"]} ... ) >>> check_edam(ctx) [] A topic in the operations list is an error, and says where it belongs: >>> ctx = ProfileContext.for_document( ... {"schema:featureList": ["http://edamontology.org/topic_0622"]} ... ) >>> finding = check_edam(ctx)[0] >>> finding.code, finding.severity.value ('profile.invalid-value', 'error') >>> finding.suggestion 'Move this term to "applicationSubCategory".' The `https` spelling will not match EDAM's own identifiers: >>> ctx = ProfileContext.for_document( ... {"schema:featureList": ["https://edamontology.org/operation_0292"]} ... ) >>> finding = check_edam(ctx)[0] >>> finding.code 'profile.non-canonical-value' >>> finding.suggestion 'Use "http://edamontology.org/operation_0292".' A malformed term is an error. The nudge follows it because the property still contains no *usable* EDAM term: >>> ctx = ProfileContext.for_document( ... {"schema:featureList": ["http://edamontology.org/operation_92"]} ... ) >>> [d.code for d in check_edam(ctx)] ['profile.invalid-value', 'recommendation.incomplete'] Free text is allowed, with a nudge towards EDAM: >>> ctx = ProfileContext.for_document( ... {"schema:featureList": ["Aligning reads to a reference"]} ... ) >>> [(d.code, d.severity.value) for d in check_edam(ctx)] [('recommendation.incomplete', 'info')] """ return _check_branch(ctx, FEATURE_LIST, "operation", "operations") + _check_branch( ctx, "applicationSubCategory", "topic", "topics" )
def _check_branch( ctx: ProfileContext, prop: str, branch: str, label: str ) -> list[Diagnostic]: """Check one EDAM-bearing property against the branch it should draw on.""" key = any_feature_list_key(ctx.document) if prop == FEATURE_LIST else prop if key is None: return [] values = ctx.values(key) if not values: return [] other = "topic" if branch == "operation" else "operation" diagnostics: list[Diagnostic] = [] found_edam = False for value, path in values: scalar = scalarize(value) if scalar is None or "edamontology.org" not in scalar.lower(): continue if not EDAM_RE.match(scalar): diagnostics.append( ctx.diagnostic( "profile.invalid-value", Severity.ERROR, f"{ctx.file}: {scalar!r} looks like an EDAM term but is " f"not a well-formed EDAM URI.", path=path, value=scalar, suggestion=( f"EDAM URIs have the form " f"http://edamontology.org/{branch}_1234. Search for " f"the term at {_EDAM_BROWSER}." ), prop=prop, ) ) continue found_edam = True if f"/{other}_" in scalar.lower(): target = "applicationSubCategory" if other == "topic" else FEATURE_LIST diagnostics.append( ctx.diagnostic( "profile.invalid-value", Severity.ERROR, f"{ctx.file}: {scalar!r} is an EDAM {other}, but " f'"{prop}" records {label}.', path=path, value=scalar, suggestion=f'Move this term to "{target}".', prop=prop, ) ) elif scalar.lower().startswith("https://"): diagnostics.append( ctx.diagnostic( "profile.non-canonical-value", Severity.WARNING, f"{ctx.file}: EDAM term IRIs use http, not https. " f"{scalar!r} will not match the ontology's own identifier.", path=path, value=scalar, suggestion=f'Use "{scalar.replace("https://", "http://", 1)}".', prop=prop, ) ) if not found_edam: diagnostics.append( ctx.diagnostic( "recommendation.incomplete", Severity.INFO, f'{ctx.file}: "{prop}" records only free text. EDAM {label} ' f"enable semantic search in bio.tools and ELIXIR " f"infrastructure.", path=(key,), suggestion=( f"Find terms at {_EDAM_BROWSER} and keep the free-text " f"entries alongside them." ), prop=prop, ) ) return diagnostics
[docs] def check_vocabularies(ctx: ProfileContext) -> list[Diagnostic]: """Check properties that draw on a controlled vocabulary. Parameters ---------- ctx : ProfileContext The record under validation. Returns ------- list of Diagnostic Findings about vocabulary usage. Notes ----- Strictness follows the vocabulary's own ``closed`` flag in the vendored data: repostatus.org is a closed vocabulary for ``developmentStatus``, while bio.tools tool types are merely preferred for ``applicationCategory``. Examples -------- A repostatus term is accepted: >>> ctx = ProfileContext.for_document({"developmentStatus": "active"}) >>> check_vocabularies(ctx) [] The vocabulary is closed, so anything else is an error and the message lists every accepted term: >>> ctx = ProfileContext.for_document( ... {"developmentStatus": "actively maintained"} ... ) >>> finding = check_vocabularies(ctx)[0] >>> finding.code, finding.severity.value ('profile.invalid-value', 'error') >>> "abandoned, active" in finding.suggestion True A bio.tools tool type is accepted: >>> ctx = ProfileContext.for_document( ... {"applicationCategory": "Command-line tool"} ... ) >>> check_vocabularies(ctx) [] That includes the repostatus.org URL: the property is typed as Text, so the bare term is the only member of the vocabulary. >>> ctx = ProfileContext.for_document( ... {"developmentStatus": "https://www.repostatus.org/#active"} ... ) >>> [d.severity.value for d in check_vocabularies(ctx)] ['error'] Free text in applicationCategory is conformant CodeMeta but not what this profile expects, so it is a warning rather than an error: >>> ctx = ProfileContext.for_document({"applicationCategory": "Notebook"}) >>> [(d.code, d.severity.value) for d in check_vocabularies(ctx)] [('profile.invalid-value', 'warning')] """ catalog = vocabularies() diagnostics: list[Diagnostic] = [] status = catalog["developmentStatus"] allowed_status = {term.casefold() for term in status["terms"]} for value, path in ctx.values("developmentStatus"): scalar = scalarize(value) if scalar and scalar.strip().casefold() not in allowed_status: diagnostics.append( ctx.diagnostic( "profile.invalid-value", Severity.ERROR, f"{ctx.file}: {scalar!r} is not a repostatus.org term. " f"CodeMeta types developmentStatus as Text and points at " f"that vocabulary; this profile closes it to those terms, " f"so nothing else is accepted.", path=path, value=scalar, suggestion=( f"Use one of: {', '.join(status['terms'])}. Write the " f"bare term, not the repostatus.org URL — the property " f"is typed as Text, so the URL is not a member of the " f"vocabulary." ), prop="developmentStatus", ) ) category = catalog["applicationCategory"] allowed_category = {term.casefold() for term in category["terms"]} for value, path in ctx.values("applicationCategory"): scalar = scalarize(value, ("name", "value", "@id", "url")) if scalar and scalar.strip().casefold() not in allowed_category: diagnostics.append( ctx.diagnostic( "profile.invalid-value", Severity.WARNING, f"{ctx.file}: {scalar!r} is not a bio.tools tool type. " f"CodeMeta permits free text here; this profile expects a " f"vocabulary term so the record is discoverable in that " f"registry.", path=path, value=scalar, suggestion=f"Preferred values: {', '.join(category['terms'])}.", prop="applicationCategory", ) ) return diagnostics