Source code for rs_metadata.diagnostics

"""The catalog of stable diagnostic codes.

Every finding rs-metadata reports carries a code from this module. The codes
are a public interface: they appear in the JSON report, in CI annotations and
in documentation links, and downstream tooling is expected to filter on them.
Within a major version a code never changes meaning. Retiring a code is a
breaking change; adding one is not.

Codes are namespaced by the layer that produced them, which tells a reader
immediately where to look:

``profile.*``
    The anchor record does not satisfy the LUMC profile.
``recommendation.*``
    The record is valid but could be more useful to registries and archives.
``source.*``
    A metadata file could not be found, read, or validated against its own
    format's schema.
``consistency.*``
    Two metadata files describe the same concept differently.
``internal.*``
    rs-metadata itself failed. Always a bug report.
"""

from __future__ import annotations

from dataclasses import dataclass
from enum import StrEnum

DOCS_URL = "https://github.com/LUMC-DCC/rs-metadata/blob/main/docs/using/diagnostics.md"


[docs] class Severity(StrEnum): """How much a finding matters. Severity is a property of the finding, not a policy decision. Whether a warning fails a build is decided separately by ``--fail-on``, so the same report can drive a strict institutional gate and a lenient local check. """ ERROR = "error" WARNING = "warning" INFO = "info" @property def meaning(self) -> str: """What this severity tells a reader, for documentation and help text. Examples -------- >>> Severity.ERROR.meaning 'The metadata is wrong. Fails the build by default.' """ return { Severity.ERROR: "The metadata is wrong. Fails the build by default.", Severity.WARNING: ( "The metadata works but should be improved. Non-fatal unless " "`--fail-on warning`." ), Severity.INFO: ( "Context: something was noticed, or deliberately not checked. " "Never fatal." ), }[self] @property def rank(self) -> int: """Sort key placing the most severe findings first. Returns ------- int ``0`` for errors, ``1`` for warnings, ``2`` for information. Examples -------- >>> [s.value for s in sorted(Severity, key=lambda s: s.rank)] ['error', 'warning', 'info'] """ return {"error": 0, "warning": 1, "info": 2}[self.value]
[docs] class Namespace(StrEnum): """The layer a diagnostic code came from. Every code is ``<namespace>.<name>``, and the namespace tells a reader immediately where to look. Kept here rather than in the documentation generator so the prose and the codes cannot describe different sets. """ PROFILE = "profile" RECOMMENDATION = "recommendation" SOURCE = "source" CONSISTENCY = "consistency" INTERNAL = "internal" @property def heading(self) -> str: """Heading for this namespace's section of the reference. Examples -------- >>> Namespace.PROFILE.heading 'Profile' """ return { Namespace.PROFILE: "Profile", Namespace.RECOMMENDATION: "Recommendations", Namespace.SOURCE: "Sources", Namespace.CONSISTENCY: "Consistency", Namespace.INTERNAL: "Internal", }[self] @property def description(self) -> str: """What the codes in this namespace have in common. Examples -------- >>> Namespace.CONSISTENCY.description 'Two metadata files describe the same concept differently.' """ return { Namespace.PROFILE: ("The anchor record does not satisfy the LUMC profile."), Namespace.RECOMMENDATION: ( "The record is valid, but could be more useful to registries " "and archives. Recommendations never fail a build by default." ), Namespace.SOURCE: ( "A metadata file could not be found, read, or validated " "against its own format's specification." ), Namespace.CONSISTENCY: ( "Two metadata files describe the same concept differently." ), Namespace.INTERNAL: ( "rs-metadata itself failed. Always worth a bug report." ), }[self]
[docs] @dataclass(frozen=True) class DiagnosticCode: """Documentation for one diagnostic code, surfaced by ``explain``.""" code: str title: str #: The most severe level this code is ever reported at. A few codes are #: reported less severely where the check is less certain of itself — a #: value that cannot be right is an error, one that merely looks wrong is a #: warning — so this is a ceiling rather than a promise. ``test_contracts`` #: pins it to what the code actually emits. severity: Severity description: str remediation: str @property def docs(self) -> str: return f"{DOCS_URL}#{self.code.replace('.', '')}" @property def namespace(self) -> Namespace: """The layer this code comes from. Examples -------- >>> CATALOG["consistency.mismatch"].namespace <Namespace.CONSISTENCY: 'consistency'> """ return Namespace(self.code.split(".", 1)[0])
def _register(*codes: DiagnosticCode) -> dict[str, DiagnosticCode]: return {code.code: code for code in codes} CATALOG: dict[str, DiagnosticCode] = _register( # ---------------------------------------------------------------- profile DiagnosticCode( code="profile.required-field", title="A mandatory profile field is missing", severity=Severity.ERROR, description=( "The LUMC profile requires this CodeMeta property in every " "research software record, so it is an error rather than a " "recommendation." ), remediation=( "Add the property to codemeta.json. See docs/using/profile.md for " "the accepted value shapes and worked examples." ), ), DiagnosticCode( code="profile.invalid-type", title="A property has the wrong shape", severity=Severity.ERROR, description=( "The value does not match the structure CodeMeta defines for this " "property, for example a bare string where a node object is " "required, or an object where a list of them is expected." ), remediation="Correct the value's shape to match the profile schema.", ), DiagnosticCode( code="profile.invalid-value", title="A property value is not valid", severity=Severity.ERROR, description=( "The value has the right shape but cannot be correct: a malformed " "identifier, an ORCID or ROR whose checksum does not verify, or a " "date that is not a calendar date. Severity follows how certain " "the check can be — a value that cannot be right is an error, " "while one that is merely outside a preferred vocabulary is a " "warning." ), remediation="Replace the value with a well-formed one.", ), DiagnosticCode( code="profile.invalid-context", title="The JSON-LD context is missing or does not declare CodeMeta", severity=Severity.ERROR, description=( "Without the CodeMeta context, property names in the file have no " "globally defined meaning and consumers cannot interpret the " "record. The context is what turns a JSON file into linked data." ), remediation=( 'Set "@context" to ["https://w3id.org/codemeta/3.1", ' '{"schema": "https://schema.org/"}].' ), ), DiagnosticCode( code="profile.unprefixed-term", title="A property outside the CodeMeta vocabulary is written unprefixed", severity=Severity.ERROR, description=( "CodeMeta 3.1 does not define this term, so written without a " "prefix it expands to nothing and JSON-LD processors drop it " "silently. The data looks present in the file but is invisible to " "every consumer." ), remediation=( "Write the property with an explicit prefix, and make sure the " "prefix is declared in @context." ), ), DiagnosticCode( code="profile.unknown-property", title="A property is not part of CodeMeta 3.1", severity=Severity.WARNING, description=( "The property is not defined by the CodeMeta 3.1 context and is " "not an absolute IRI or a prefixed term. Most often this is a " "typo. The LUMC profile does not forbid extra properties, so this " "is a warning: unknown terms are simply ignored by consumers." ), remediation=( "Correct the spelling, or write the property as a prefixed term " "whose prefix is declared in @context if the extension is " "intentional." ), ), DiagnosticCode( code="profile.placeholder-value", title="A value was copied from an example and never filled in", severity=Severity.ERROR, description=( "The value is one of the placeholders that circulate in metadata " "templates and in the rs-metadata examples, such as the all-zero " "ORCID, a 10.0000/ DOI, or an example.com repository URL. Left in " "place it is worse than an empty field: it asserts a specific " "identity or identifier that does not exist, and registries will " "harvest it." ), remediation="Replace the placeholder with the real value for this software.", ), DiagnosticCode( code="profile.non-canonical-value", title="A value is understood but not in the canonical form", severity=Severity.WARNING, description=( "The value is unambiguous, but CodeMeta and this profile both " "prescribe a different spelling of it. Non-canonical values still " "work with rs-metadata, which normalizes them, but they may not " "round-trip through other CodeMeta consumers." ), remediation="Rewrite the value in the canonical form shown in the message.", ), DiagnosticCode( code="profile.deprecated-value", title="A value comes from a deprecated vocabulary entry", severity=Severity.WARNING, description=( "The identifier is recognized but its issuing vocabulary has " "deprecated it in favor of a replacement." ), remediation="Replace it with the current identifier.", ), # --------------------------------------------------------- recommendation DiagnosticCode( code="recommendation.missing", title="A recommended field is absent", severity=Severity.WARNING, description=( "The field is not mandatory under the LUMC profile, but several " "registries require it and its absence limits how discoverable " "the software is. Recommendations never fail CI by default." ), remediation="Add the field to codemeta.json where it applies.", ), DiagnosticCode( code="recommendation.incomplete", title="A field is present but could carry more information", severity=Severity.INFO, description=( "The value is valid, but a richer form exists that downstream " "systems can use: an author without an ORCID, or an affiliation " "without a ROR, cannot be disambiguated automatically." ), remediation="Add the missing identifier or sub-field where you have it.", ), # ----------------------------------------------------------------- source DiagnosticCode( code="source.missing", title="A required metadata file was not found", severity=Severity.ERROR, description=( "This file is required alongside codemeta.json, because " "registries and archives read it directly and a record without it " "is not citable through them. Optional, auto-detected formats are " "never reported this way; their absence is not a finding." ), remediation="Create the file in the repository root.", ), DiagnosticCode( code="source.unreadable", title="A metadata file could not be parsed", severity=Severity.ERROR, description=( "The file exists but is not well-formed in its own syntax, so " "nothing in it can be validated or compared." ), remediation="Fix the syntax error reported in the message.", ), DiagnosticCode( code="source.invalid", title="A metadata file does not satisfy its own format's schema", severity=Severity.ERROR, description=( "The file parses, but violates the specification of the format it " "claims to be, so consumers of that format will reject it." ), remediation="Correct the file against its format specification.", ), DiagnosticCode( code="source.detected", title="An optional metadata source was detected", severity=Severity.INFO, description=( "Informational. rs-metadata found an additional metadata file and " "included it in the consistency comparison." ), remediation="", ), # ------------------------------------------------------------ consistency DiagnosticCode( code="consistency.mismatch", title="Two metadata files disagree about the same concept", severity=Severity.ERROR, description=( "Both files describe the same metadata concept but give values " "that are not equivalent after normalization. One of them is " "stale. Because codemeta.json is the anchor record, the " "discrepancy is reported against it." ), remediation=( "Update whichever file is out of date so both describe the same release." ), ), DiagnosticCode( code="consistency.incomplete", title="A source carries values the anchor record does not", severity=Severity.INFO, description=( "Not a contradiction. Another metadata file records something " "codemeta.json omits, which is usually fine but occasionally " "reveals metadata that was never copied across." ), remediation=( "Consider adding the missing values to codemeta.json if they " "belong in the canonical record." ), ), DiagnosticCode( code="consistency.uncomparable", title="A mapped field could not be meaningfully compared", severity=Severity.INFO, description=( "A mapping between the two formats exists, but this particular " "value cannot be compared: the source declares it dynamically, " "the mapping loses too much information, or one side is absent. " "Reported so the report is a complete account of what was and was " "not checked." ), remediation="", ), # --------------------------------------------------------------- internal DiagnosticCode( code="internal.error", title="rs-metadata failed unexpectedly", severity=Severity.ERROR, description=( "An unhandled condition inside rs-metadata. The metadata being " "validated is not necessarily at fault." ), remediation=( "Please report this at " "https://github.com/LUMC-DCC/rs-metadata/issues with the file that " "triggered it." ), ), )
[docs] def get(code: str) -> DiagnosticCode: """Look up a diagnostic code in the catalog. Parameters ---------- code : str A registered diagnostic code. Returns ------- DiagnosticCode Its documentation: title, default severity, description, remediation. Raises ------ KeyError If the code is not registered. A test asserts that every code emitted anywhere in the source is in the catalog, so this can only be reached by a typo in rs-metadata's own source. Examples -------- >>> entry = get("consistency.mismatch") >>> entry.severity.value 'error' >>> entry.title 'Two metadata files disagree about the same concept' Every code carries a documentation link, which travels onto the finding: >>> entry.docs.endswith("docs/using/diagnostics.md#consistencymismatch") True An unregistered code fails loudly rather than producing a finding nobody can look up: >>> get("made.up") Traceback (most recent call last): ... KeyError: "Unknown diagnostic code 'made.up'. Codes must be...CATALOG." """ try: return CATALOG[code] except KeyError: # pragma: no cover - guards against internal typos raise KeyError( f"Unknown diagnostic code {code!r}. Codes must be registered in " f"rs_metadata.diagnostics.CATALOG." ) from None