Source code for rs_metadata.profile.context

"""The state every profile check shares.

Each check family is a plain function taking a :class:`ProfileContext` and
returning diagnostics. That keeps the families independent of each other and
independently testable: a check can be exercised on a bare dictionary, with no
files on disk and no pipeline around it.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any

from ..concepts import ParsedSource, location
from ..diagnostics import Severity
from ..locate import Path as DocPath
from ..report import Diagnostic
from ..vocabulary import profile_field_policy, profile_schema

__all__ = ["ProfileContext", "flatten"]


[docs] def flatten(name: str, raw: Any, prefix: DocPath) -> list[tuple[Any, DocPath]]: """Pair each value of a property with the path that locates it. Parameters ---------- name : str Property name as written. raw : Any Its value: a scalar, a node, or an array of either. prefix : DocPath Path of the object the property belongs to. Empty at the document root. Returns ------- list of tuple ``(value, path)`` pairs, one per item when ``raw`` is an array. Notes ----- CodeMeta permits a single value to be written bare or as a one-element array, and every check has to cope with both. Doing that arithmetic once is what keeps a finding's reported line number right without each check re-deriving it. Examples -------- >>> flatten("name", "MyTool", ()) [('MyTool', ('name',))] >>> flatten("keywords", ["a", "b"], ()) [('a', ('keywords', 0)), ('b', ('keywords', 1))] Inside a node, the path carries on from where the node sits: >>> flatten("familyName", "Doe", ("author", 0)) [('Doe', ('author', 0, 'familyName'))] """ if isinstance(raw, list): return [(item, (*prefix, name, index)) for index, item in enumerate(raw)] return [(raw, (*prefix, name))]
[docs] @dataclass class ProfileContext: """One anchor record under validation, plus the profile it is judged by. Parameters ---------- parsed : ParsedSource The parsed ``codemeta.json``, carrying its source map so diagnostics can report line numbers. Attributes ---------- document : dict The parsed record. schema : dict The LUMC profile JSON Schema. policy : dict Mandatory, recommended and alias field lists, read from the schema so documentation, validator and schema cannot drift apart. Examples -------- Build one from a bare document, without touching the filesystem: >>> ctx = ProfileContext.for_document({"name": "MyTool"}) >>> ctx.document["name"] 'MyTool' >>> "license" in ctx.policy["mandatory"] True :meth:`values` normalizes CodeMeta's scalar-or-array cardinality, pairing each value with the path it came from: >>> ctx = ProfileContext.for_document({"license": "MIT"}) >>> ctx.values("license") [('MIT', ('license',))] >>> ctx = ProfileContext.for_document({"license": ["MIT", "Apache-2.0"]}) >>> ctx.values("license") [('MIT', ('license', 0)), ('Apache-2.0', ('license', 1))] An absent property yields nothing, so checks iterate without guarding: >>> ProfileContext.for_document({}).values("license") [] """ parsed: ParsedSource schema: dict[str, Any] = field(default_factory=profile_schema) policy: dict[str, list[str]] = field(default_factory=profile_field_policy)
[docs] @classmethod def for_document( cls, document: dict[str, Any], file: str = "codemeta.json" ) -> ProfileContext: """Build a context from a document alone, for tests and examples. Parameters ---------- document : dict A CodeMeta record. file : str, optional Filename to attribute diagnostics to. Returns ------- ProfileContext A context with an empty source map, so diagnostics carry no line numbers — which is exactly how they behave when a source map could not be built. """ return cls( parsed=ParsedSource( adapter_id="codemeta", file=file, format="CodeMeta", document=document, ) )
@property def document(self) -> dict[str, Any]: """The parsed CodeMeta record.""" return self.parsed.document # type: ignore[no-any-return] @property def file(self) -> str: """The record's repository-relative path, used in every message.""" return self.parsed.file
[docs] def values(self, name: str) -> list[tuple[Any, DocPath]]: """Every value of a property, paired with its path. Parameters ---------- name : str Property name as written in the document. Returns ------- list of tuple ``(value, path)`` pairs. Empty when the property is absent. Notes ----- CodeMeta permits a single value to be written bare or as a one-element array. Both are handled here so no check has to. """ raw = self.document.get(name) if raw is None: return [] return flatten(name, raw, ())
[docs] def node_values( self, node: dict[str, Any], path: DocPath ) -> list[tuple[str, Any, DocPath]]: """Every real property of a nested node, flattened over arrays. Parameters ---------- node : dict A nested node, such as a ``Person`` inside ``author``. path : DocPath Where that node sits in the document. Returns ------- list of tuple ``(name, value, path)`` triples. JSON-LD keywords are skipped: ``@id`` and ``@type`` are not CodeMeta properties and are checked by the modules that understand them. Notes ----- The nested counterpart of :meth:`values`, and the reason both :mod:`.types` and :mod:`.formats` can walk into nodes without each keeping its own copy of the array-versus-scalar path arithmetic. Examples -------- >>> ctx = ProfileContext.for_document({}) >>> ctx.node_values({"@type": "Person", "familyName": "Doe"}, ("author",)) [('familyName', 'Doe', ('author', 'familyName'))] An array-valued property yields one entry per item, each with its own index in the path: >>> node = {"email": ["a@x.org", "b@x.org"]} >>> for entry in ctx.node_values(node, ("author",)): ... print(entry) ('email', 'a@x.org', ('author', 'email', 0)) ('email', 'b@x.org', ('author', 'email', 1)) """ entries: list[tuple[str, Any, DocPath]] = [] for name, raw in node.items(): if name.startswith("@"): continue entries.extend( (name, value, item_path) for value, item_path in flatten(name, raw, path) ) return entries
[docs] def title(self, name: str) -> str: """The schema's human-readable title for a property. Falls back to the property name, so a message is never blank. Examples -------- >>> ProfileContext.for_document({}).title("codeRepository") 'Code repository' >>> ProfileContext.for_document({}).title("notAProperty") 'notAProperty' """ entry = self.schema.get("properties", {}).get(name, {}) return str(entry.get("title", name))
[docs] def description(self, name: str) -> str | None: """The schema's description for a property, used as remediation text.""" entry = self.schema.get("properties", {}).get(name) return entry.get("description") if isinstance(entry, dict) else None
[docs] def diagnostic( self, code: str, severity: Severity, message: str, *, path: DocPath = (), value: Any = None, suggestion: str | None = None, prop: str | None = None, ) -> Diagnostic: """Build a diagnostic against this record. Resolves ``path`` to a line and column through the record's source map, and defaults the reported property to the path's first component. Parameters ---------- code : str A registered diagnostic code. severity : Severity How much the finding matters. message : str A complete sentence naming the file and what is wrong. path : DocPath, optional Where in the document the finding applies. value : Any, optional The observed value, echoed back in the report. suggestion : str, optional Concrete remediation. prop : str, optional Override the reported property name. Returns ------- Diagnostic The finding. Examples -------- >>> ctx = ProfileContext.for_document({"version": "1.0"}) >>> finding = ctx.diagnostic( ... "profile.invalid-value", ... Severity.ERROR, ... "codemeta.json: version is wrong.", ... path=("version",), ... value="1.0", ... ) >>> finding.property, finding.severity.value ('version', 'error') >>> finding.location.file, finding.location.path ('codemeta.json', 'version') """ return Diagnostic( code=code, severity=severity, message=message, property=prop or (str(path[0]) if path else None), location=location(self.parsed, path, value), suggestion=suggestion, )