Source code for rs_metadata.report

"""The validation report: rs-metadata's single output contract.

Everything the validator learns ends up in a :class:`Report`. The CLI, the
GitHub Action and any institutional tooling render *this* model; none of them
re-derive validation behavior. That is what keeps ``rs-metadata validate`` on
a laptop and the Action in CI from ever disagreeing.

The serialized form is specified by ``schema/rs-metadata-report.schema.json``
and is validated against that schema in the test suite, so the schema cannot
drift away from the code.
"""

from __future__ import annotations

import datetime as dt
import os
from dataclasses import dataclass, field
from typing import Any

from . import (
    CODEMETA_VERSION,
    PROFILE_NAME,
    PROFILE_VERSION,
    __version__,
)
from .diagnostics import Severity, get

TOOL_URL = "https://github.com/LUMC-DCC/rs-metadata"

#: Values longer than this are truncated when echoed back in a report, so a
#: pasted-in abstract cannot make a diagnostic unreadable.
MAX_VALUE_CHARS = 300


def _truncate(value: Any) -> Any:
    if isinstance(value, str) and len(value) > MAX_VALUE_CHARS:
        return value[: MAX_VALUE_CHARS - 1] + "…"
    return value


[docs] @dataclass(frozen=True) class MappingRef: """Which crosswalk related a source to CodeMeta, and where it came from. Carried on every consistency finding so a reader can tell a mapping taken from the upstream CodeMeta crosswalk tables apart from one rs-metadata introduced itself. """ id: str origin: str version: str | None = None source: str | None = None retrieved: str | None = None
[docs] def to_dict(self) -> dict[str, Any]: data: dict[str, Any] = {"id": self.id, "origin": self.origin} if self.version: data["version"] = self.version if self.source: data["source"] = self.source if self.retrieved: data["retrieved"] = self.retrieved return data
[docs] @dataclass(frozen=True) class Location: """Where in a file a finding applies. ``line`` and ``column`` are resolved from the original file text rather than reconstructed from the parsed value, so CI annotations land on the offending line instead of at the top of the file. """ file: str path: str | None = None line: int | None = None column: int | None = None value: Any = None
[docs] def to_dict(self) -> dict[str, Any]: data: dict[str, Any] = {"file": self.file} if self.path is not None: data["path"] = self.path if self.line is not None: data["line"] = self.line if self.column is not None: data["column"] = self.column if self.value is not None: data["value"] = _truncate(self.value) return data
[docs] def describe(self) -> str: """Render as ``file:line``, or just ``file`` when no line is known. Returns ------- str A clickable-looking location for terminal output. Examples -------- >>> Location(file="codemeta.json", line=12).describe() 'codemeta.json:12' A missing line is normal because source positions are best-effort. In that case, return the filename rather than a misleading line 0: >>> Location(file="codemeta.json").describe() 'codemeta.json' """ return f"{self.file}:{self.line}" if self.line else self.file
[docs] @dataclass(frozen=True) class Diagnostic: """One finding. Never generic: it names the metadata, the value and the fix.""" code: str severity: Severity message: str property: str | None = None location: Location | None = None anchor: Location | None = None source: Location | None = None strategy: str | None = None rule: str | None = None mapping: MappingRef | None = None suggestion: str | None = None
[docs] def to_dict(self) -> dict[str, Any]: data: dict[str, Any] = { "code": self.code, "severity": self.severity.value, "message": self.message, } if self.property: data["property"] = self.property for name in ("location", "anchor", "source"): value = getattr(self, name) if value is not None: data[name] = value.to_dict() if self.strategy: data["strategy"] = self.strategy if self.rule: data["rule"] = self.rule if self.mapping is not None: data["mapping"] = self.mapping.to_dict() if self.suggestion: data["suggestion"] = self.suggestion data["docs"] = get(self.code).docs return data
[docs] def primary_location(self) -> Location | None: """The location an editor or CI annotation should point at. Returns ------- Location or None The single most relevant place, or ``None`` for a finding about the repository as a whole. Notes ----- Consistency findings point to ``codemeta.json`` because it is the canonical record a maintainer is expected to correct. Examples -------- A profile finding points at where it was found: >>> from rs_metadata.diagnostics import Severity >>> finding = Diagnostic( ... code="profile.required-field", severity=Severity.ERROR, ... message="x", location=Location(file="codemeta.json", line=3), ... ) >>> finding.primary_location().describe() 'codemeta.json:3' A consistency finding prefers the anchor over the source: >>> finding = Diagnostic( ... code="consistency.mismatch", severity=Severity.ERROR, ... message="x", ... anchor=Location(file="codemeta.json", line=10), ... source=Location(file="CITATION.cff", line=8), ... ) >>> finding.primary_location().describe() 'codemeta.json:10' """ return self.location or self.anchor or self.source
[docs] def sort_key(self) -> tuple[Any, ...]: location = self.primary_location() return ( self.severity.rank, location.file if location else "", location.line or 0 if location else 0, self.property or "", self.code, self.message, )
[docs] @dataclass class Source: """A metadata file that was considered, whether or not it was found. Absent sources are recorded too. A report that lists ``pyproject.toml`` as ``absent`` tells the reader something a silent report does not: that rs-metadata looked, and there was nothing there. """ id: str format: str role: str status: str file: str | None = None format_version: str | None = None mapping: MappingRef | None = None
[docs] def to_dict(self) -> dict[str, Any]: data: dict[str, Any] = { "id": self.id, "format": self.format, "role": self.role, "status": self.status, } if self.file is not None: data["file"] = self.file if self.format_version is not None: data["formatVersion"] = self.format_version if self.mapping is not None: data["mapping"] = self.mapping.to_dict() return data
[docs] @dataclass class Report: """The complete outcome of one validation run.""" root: str = "." sources: list[Source] = field(default_factory=list) diagnostics: list[Diagnostic] = field(default_factory=list) checks: int = 0 # -- building ---------------------------------------------------------
[docs] def add(self, diagnostic: Diagnostic) -> None: self.diagnostics.append(diagnostic)
[docs] def add_source(self, source: Source) -> None: self.sources.append(source)
[docs] def count(self, severity: Severity) -> int: return sum(1 for d in self.diagnostics if d.severity is severity)
# -- outcome ---------------------------------------------------------- @property def errors(self) -> list[Diagnostic]: return [d for d in self.diagnostics if d.severity is Severity.ERROR] @property def warnings(self) -> list[Diagnostic]: return [d for d in self.diagnostics if d.severity is Severity.WARNING] @property def status(self) -> str: """Overall outcome, describing the findings rather than the build. Returns ------- {"passed", "passed-with-warnings", "failed"} ``failed`` if any error was found, ``passed-with-warnings`` if only warnings were, ``passed`` otherwise. Notes ----- Independent of :meth:`exit_code`. A run can report ``passed-with-warnings`` and still exit non-zero under ``--fail-on warning``, because the status describes the *metadata* and the exit code describes the *policy*. Examples -------- >>> from rs_metadata.diagnostics import Severity >>> def finding(severity): ... return Diagnostic(code="internal.error", severity=severity, ... message="x") >>> Report().status 'passed' >>> Report(diagnostics=[finding(Severity.INFO)]).status 'passed' >>> Report(diagnostics=[finding(Severity.WARNING)]).status 'passed-with-warnings' >>> Report(diagnostics=[finding(Severity.ERROR), ... finding(Severity.WARNING)]).status 'failed' """ if self.count(Severity.ERROR): return "failed" if self.count(Severity.WARNING): return "passed-with-warnings" return "passed"
[docs] def exit_code(self, fail_on: str = "error") -> int: """Map the report onto a process exit status. Parameters ---------- fail_on : {"error", "warning", "never"}, optional Lowest severity that should fail the run. Returns ------- int ``1`` if the run should fail, ``0`` otherwise. Notes ----- Severity is a property of a finding; turning severity into a build failure is *policy*, and lives here rather than in the checks. That separation is what lets one report drive a strict institutional gate and a lenient local check without re-running anything. Examples -------- >>> from rs_metadata.diagnostics import Severity >>> def finding(severity): ... return Diagnostic(code="internal.error", severity=severity, ... message="x") Warnings are non-fatal by default, which is what makes recommendations safe to ship: >>> warned = Report(diagnostics=[finding(Severity.WARNING)]) >>> warned.exit_code() 0 >>> warned.exit_code("warning") 1 Errors fail unless failures are switched off, which is useful when adopting the standard across existing repositories: >>> failed = Report(diagnostics=[finding(Severity.ERROR)]) >>> failed.exit_code(), failed.exit_code("never") (1, 0) Informational findings never fail a run: >>> Report(diagnostics=[finding(Severity.INFO)]).exit_code("warning") 0 """ if fail_on == "never": return 0 if self.count(Severity.ERROR): return 1 if fail_on == "warning" and self.count(Severity.WARNING): return 1 return 0
[docs] def sorted_diagnostics(self) -> list[Diagnostic]: return sorted(self.diagnostics, key=lambda d: d.sort_key())
# -- serialization ----------------------------------------------------
[docs] def to_dict(self) -> dict[str, Any]: data: dict[str, Any] = { "status": self.status, "tool": {"name": "rs-metadata", "version": __version__, "url": TOOL_URL}, "profile": { "name": PROFILE_NAME, "version": PROFILE_VERSION, "codemetaVersion": CODEMETA_VERSION, "schema": "codemeta-lumc.schema.json", }, "root": self.root, "sources": [source.to_dict() for source in self.sources], "summary": { "error": self.count(Severity.ERROR), "warning": self.count(Severity.WARNING), "info": self.count(Severity.INFO), "checks": self.checks, }, "diagnostics": [d.to_dict() for d in self.sorted_diagnostics()], } generated = _generated_at() if generated is not None: data["generatedAt"] = generated return data
def _generated_at() -> str | None: """Timestamp the run, honoring ``SOURCE_DATE_EPOCH`` for reproducibility. Set ``SOURCE_DATE_EPOCH`` to compare two reports byte for byte, which the golden-file tests rely on. """ epoch = os.environ.get("SOURCE_DATE_EPOCH") if epoch: try: moment = dt.datetime.fromtimestamp(int(epoch), tz=dt.UTC) except (ValueError, OverflowError, OSError): return None return moment.isoformat().replace("+00:00", "Z") return ( dt.datetime.now(tz=dt.UTC) .replace(microsecond=0) .isoformat() .replace("+00:00", "Z") )