Source code for rs_files_templates.models.zenodo

"""Input model and normalization helpers for Zenodo software metadata."""

from __future__ import annotations

import re
from typing import Any

from rsm_schema.generated import Person

from .base import rsm_template_base
from .utils import (
    AUTHOR_ROLES,
    MAINTAINER_ROLES,
    PRINCIPAL_INVESTIGATOR_ROLES,
    contributors_with_roles,
    metadata_keywords,
    primary_affiliation,
)

_ZenodoRSM = rsm_template_base(
    "_ZenodoRSM",
    "project_name",
    "project_slug",
    "project_short_description",
    "project_long_description",
    "licensing",
    "keywords",
    "topics",
    "contributors",
    "funding",
    "publications",
)

ZENODO_SCHEMA_URL = (
    "https://raw.githubusercontent.com/zenodo/zenodo/master/zenodo/modules/deposit/"
    "jsonschemas/deposits/records/legacyrecord.json"
)


[docs] class ZenodoModel(_ZenodoRSM): """Input model for the GitHub integration's ``.zenodo.json`` file. Zenodo reads this file from the repository when it archives a GitHub release. The release itself supplies the version and files; this model supplies the deposit metadata that the repository author wants to override or enrich. """ template_name = ".zenodo.json.j2" output_name = ".zenodo.json" schema_url = ZENODO_SCHEMA_URL media_type = "json" @staticmethod def _normalize_orcid(value: str | None) -> str: """Return a bare ORCID identifier.""" return re.sub(r"^https?://orcid\.org/", "", str(value or "").strip(), flags=re.IGNORECASE) @staticmethod def _person_name(person: Person) -> str: """Format a person as ``family, given`` for Zenodo.""" given = str(person.given_names or "").strip() family = str(person.family_names or "").strip() if family: return f"{family}, {given}" if given else family return str(person.name or "").strip() or "Project member" @classmethod def _person_identity(cls, person: Person) -> str: """Build a stable identity used when deduplicating contributors.""" if person.orcid: return f"orcid:{cls._normalize_orcid(person.orcid).lower()}" if person.email: return f"email:{person.email.strip().lower()}" return f"name:{cls._person_name(person).lower()}" @classmethod def _zenodo_person(cls, person: Person, contributor_type: str = "") -> dict[str, str]: """Map one shared person value to a Zenodo record.""" record = {"name": cls._person_name(person)} if affiliation := primary_affiliation(person): record["affiliation"] = affiliation.name if person.orcid: record["orcid"] = cls._normalize_orcid(person.orcid) if contributor_type: record["type"] = contributor_type return record def _contributors(self) -> list[dict[str, str]]: """Build deduplicated maintainer and investigator records.""" contributors: list[dict[str, str]] = [] authors = contributors_with_roles(self.contributors.entries, AUTHOR_ROLES) seen = {self._person_identity(author) for author in authors} for people, contributor_type in ( ( contributors_with_roles(self.contributors.entries, MAINTAINER_ROLES), "ContactPerson", ), ( contributors_with_roles(self.contributors.entries, PRINCIPAL_INVESTIGATOR_ROLES), "ProjectLeader", ), ): for person in people: identity = self._person_identity(person) if identity not in seen: contributors.append(self._zenodo_person(person, contributor_type)) seen.add(identity) return contributors def _related_identifiers(self) -> list[dict[str, str]]: """Map publication identifiers to Zenodo relationships.""" related: list[dict[str, str]] = [] seen: set[str] = set() for publication in self.publications.entries: doi = str(publication.doi or "").strip() url = str(publication.url or "").strip() identifier = doi or url if not identifier or identifier in seen: continue record = { "identifier": identifier, "relation": "isDocumentedBy", "scheme": "doi" if doi else "url", } if doi: record["resource_type"] = "publication-article" related.append(record) seen.add(identifier) return related def _grants(self) -> list[dict[str, str]]: """Keep only grant identifiers that Zenodo can resolve.""" grant_ids: list[str] = [] for funding in self.funding.entries: grant_id = str(funding.award_number or funding.project_code or "").strip() if re.fullmatch(r"\d+|10\.13039/[^\s:]+::\S+", grant_id) and grant_id not in grant_ids: grant_ids.append(grant_id) return [{"id": grant_id} for grant_id in grant_ids]
[docs] def template_data(self) -> dict[str, Any]: """Prepare values consumed by the explicit ``.zenodo.json.j2`` layout.""" creators = [ self._zenodo_person(author) for author in contributors_with_roles(self.contributors.entries, AUTHOR_ROLES) ] return { "title": self.project_name or self.project_slug, "description": ( self.project_long_description or self.project_short_description or "Research software project." ), "creators": creators or [{"name": "Project team"}], "license": self.licensing.license or "", "keywords": metadata_keywords(self.keywords.entries, self.topics.entries), "contributors": self._contributors(), "grants": self._grants(), "related_identifiers": self._related_identifiers(), }