Source code for swh.coarnotify.indexer

# Copyright (C) 2024-2026  The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU General Public License version 3, or any later version
# See top-level LICENSE file for more information
"""Metadata mappings for swh-indexer."""

import json
import logging
from typing import Any

from pyld import jsonld
from rdflib import URIRef

from swh.coarnotify.namespaces import CM
from swh.coarnotify.relationships import MENTIONS_RELATIONSHIPS
from swh.indexer.codemeta import CODEMETA_TERMS
from swh.indexer.metadata_mapping.base import BaseExtrinsicMapping

logger = logging.getLogger(__name__)


[docs] class CoarNotifyValidationError(Exception): pass
[docs] def load_and_compact_notification(content: bytes | str) -> dict[str, Any] | None: """Load and compact a notification from the REMS. Errors logs will be written if something went wrong in the process. Args: content: the expanded COAR Notification Returns: The compacted form of the COAR Notification or None if we weren't able to read it """ try: raw_json = json.loads(content) notification = jsonld.compact( raw_json, { "@context": [ "https://www.w3.org/ns/activitystreams", "https://coar-notify.net", ] }, ) except json.JSONDecodeError: logger.error("Failed to parse JSON document: %s", content) return None except jsonld.JsonLdError: logger.error("Failed to compact JSON-LD document: %s", content) return None return notification
[docs] def validate_mention(notification: dict[str, Any]) -> URIRef: """Validate minimal notification's requirements before indexation. FIXME: CN specs (1.0.1) are a bit unclear about what should context_data contains, especially the id. It would be more logical to find the paper URI in the id and then some metadata about it, but instead we might find the software URI in the id and then metadata about the paper. We are trying to make some changes on the specs but meanwhile we'll skip verifying that context.id == object.as:subject Args: notification: a compact form of a COAR Notification Raises: CoarNotifyValidationError: something is wrong with the notification payload Returns: The relationship type """ object_ = notification.get("object", {}).get("object") if object_ is None: raise CoarNotifyValidationError( f"Missing object[as:object] key in {notification}" ) if not isinstance(object_, str): raise CoarNotifyValidationError( f"object[as:object] value is not a string in {notification}" ) paper = notification.get("context", {}).get("id") if not paper: raise CoarNotifyValidationError(f"Missing context[id] key in {notification}") if not isinstance(paper, str): raise CoarNotifyValidationError( f"context[id] value is not a string in {notification}" ) relationship = notification.get("object", {}).get("relationship") if not relationship: raise CoarNotifyValidationError( f"Missing object[as:relationship] key in {notification}" ) if not isinstance(relationship, str): raise CoarNotifyValidationError( f"object[as:relationship] value is not a string in {notification}" ) relationship = URIRef(relationship) if relationship not in MENTIONS_RELATIONSHIPS: raise CoarNotifyValidationError( f"object[as:relationship] value is not valid {notification}" ) notification_id = notification.get("id") if not notification_id: raise CoarNotifyValidationError(f"missing id key in {notification}") if not isinstance(notification_id, str): raise CoarNotifyValidationError(f"id value is not a string in {notification}") return relationship
[docs] class CoarNotifyMentionMapping(BaseExtrinsicMapping): """Map & translate a COAR Notify software mention in a CodeMeta format. COAR Notify mentions are received by ``swh-coarnotify`` and saved expanded. Mentions contains metadata on a scientific paper that cites a software. """ name = "coarnotify-mention-codemeta"
[docs] @classmethod def supported_terms(cls) -> list[str]: codemeta_terms = [term for term in CODEMETA_TERMS if not term.startswith("@")] relationships_terms = [ str(term) for term in ( MENTIONS_RELATIONSHIPS.keys() | MENTIONS_RELATIONSHIPS.values() ) if not term.startswith(str(CM)) ] return codemeta_terms + relationships_terms
[docs] @classmethod def extrinsic_metadata_formats(cls) -> tuple[str, ...]: return ("coarnotify-mention-v1",)
[docs] def translate(self, content: bytes) -> dict[str, Any] | None: """Parse JSON and compact the payload to access the mention. The whole `context` of the `AnnounceRelationship` notification will be indexed as it contains metadata about the scientific paper citing the software. TODO: At some point we might need to fetch metadata from the paper URL as COAR Notifications are not made to contain **all** the metadata but to indicate where we should find them. TODO: We will need to handle cancellations of a mention if it was made by mistake. Maybe we could use the original notification id and an empty context to overwrite the previous citation when merging documents ? It is with this in mind that the notification ID is added to the citation. Args: content: the raw expanded COAR Notification Returns: A CodeMeta citation if the notification was valid or None """ notification = load_and_compact_notification(content) if not notification: return None try: relationship = validate_mention(notification) except CoarNotifyValidationError as exc: logger.error(exc) return None inverted_relationship = MENTIONS_RELATIONSHIPS[relationship] citation = { "@context": ["http://schema.org/", "https://w3id.org/codemeta/3.0"], str(inverted_relationship): [ {"id": notification["id"], "ScholarlyArticle": notification["context"]} ], } return citation