Source code for rs_files_templates.models.codemeta
"""Model and normalization logic for ``codemeta.json``."""
from __future__ import annotations
from typing import Any
from rsm_schema.generated import ExternalDependency, Funding, Person
from .base import rsm_template_base
from .utils import (
AUTHOR_ROLES,
MAINTAINER_ROLES,
PRINCIPAL_INVESTIGATOR_ROLES,
contributors_with_roles,
metadata_keywords,
normalize_orcid,
organization_data,
person_name,
primary_affiliation,
publication_link,
registry_url,
)
CODEMETA_SCHEMA_URL = (
"https://lumc-dcc.github.io/rs-metadata/schema/1.0.0/codemeta-lumc.schema.json"
)
_CodeMetaRSM = rsm_template_base(
"_CodeMetaRSM",
"project_name",
"project_slug",
"project_short_description",
"project_long_description",
"development_status",
"keywords",
"topics",
"contributors",
"funding",
"motivation",
"related_software",
"urls",
"registries",
"persistent_identifiers",
"publications",
"licensing",
"access",
"include_metadata",
"documentation_types",
"community_files",
"support_routes",
"programming_languages",
"software_functions",
"interfaces",
"operating_systems",
"external_dependencies",
"test_types",
"quality_tools",
"versioning",
"distribution_channels",
"containerization",
)
[docs]
class CodeMetaModel(_CodeMetaRSM):
"""Published RSM fields needed to render ``codemeta.json``."""
template_name = "codemeta.json.j2"
output_name = "codemeta.json"
schema_url = CODEMETA_SCHEMA_URL
media_type = "json"
@staticmethod
def _person(person: Person) -> dict[str, Any]:
result: dict[str, Any] = {"@type": "Person", "name": person_name(person)}
if person.orcid:
result["@id"] = f"https://orcid.org/{normalize_orcid(person.orcid)}"
elif person.url:
result["@id"] = person.url
if person.given_names:
result["givenName"] = person.given_names
if person.family_names:
result["familyName"] = person.family_names
if person.email:
result["email"] = person.email
if affiliation := primary_affiliation(person):
result["affiliation"] = organization_data(affiliation)
if person.url:
result["url"] = person.url
return result
@staticmethod
def _dependency(dependency: ExternalDependency) -> dict[str, Any]:
result: dict[str, Any] = {"@type": "SoftwareSourceCode", "name": dependency.name}
if dependency.url:
result.update({"@id": dependency.url, "url": dependency.url})
if dependency.version_constraint:
result["version"] = dependency.version_constraint
if dependency.license:
result["license"] = (
dependency.license
if dependency.license.startswith(("http://", "https://"))
else f"https://spdx.org/licenses/{dependency.license}"
)
if dependency.purpose:
result["description"] = dependency.purpose
return result
@staticmethod
def _funder(funding: Funding) -> dict[str, Any] | None:
"""Map one RSM funding entry to a CodeMeta organization."""
identifier = str(funding.funder_identifier or "").strip()
identifier_type = str(funding.funder_identifier_type or "")
if identifier and not identifier.startswith(("http://", "https://")):
if identifier_type == "ror":
identifier = f"https://ror.org/{identifier.removeprefix('ror.org/')}"
elif identifier_type == "crossref-funder-id":
identifier = f"https://doi.org/{identifier}"
if not (funding.funder or identifier or funding.funder_url):
return None
result: dict[str, Any] = {
"@type": "Organization",
"name": funding.funder or identifier or funding.funder_url,
}
if identifier.startswith(("http://", "https://")):
result["@id"] = identifier
elif identifier:
result["identifier"] = identifier
if funding.funder_url:
result["url"] = funding.funder_url
return result
def _access(self) -> tuple[bool | None, Any]:
"""Map RSM access terms to CodeMeta and Schema.org values."""
access_type = str(self.access.type or "")
if not access_type:
return None, None
details = str(self.access.details or "").strip()
usage_info: Any = None
if details.startswith(("http://", "https://")):
usage_info = details
elif details:
usage_info = {"@type": "CreativeWork", "description": details}
return access_type != "commercial", usage_info
def _funders(self) -> list[dict[str, Any]]:
"""Build stable, deduplicated CodeMeta funder organizations."""
funders: list[dict[str, Any]] = []
seen: set[str] = set()
for funding in self.funding.entries:
funder = self._funder(funding)
if funder is None:
continue
identity = str(
funder.get("name")
or funder.get("@id")
or funder.get("identifier")
or funder.get("url")
).casefold()
if identity in seen:
continue
funders.append(funder)
seen.add(identity)
return funders
def _registry_links(self) -> list[str]:
links: list[str] = []
for registry in self.registries.entries:
link = registry_url(registry)
if link and link not in links:
links.append(link)
return links
def _features(self) -> list[str]:
features: list[str] = []
for function in self.software_functions.entries:
for operation in function.operations or []:
feature = operation.uri or operation.term
if feature and feature not in features:
features.append(feature)
return features or [
self.motivation.purpose
or self.project_short_description
or "REPLACE_WITH_SOFTWARE_FUNCTION"
]
def _application_subcategories(self) -> list[str]:
subcategories: list[str] = []
for topic in self.topics.entries:
subcategory = topic.uri or topic.term
if subcategory and subcategory not in subcategories:
subcategories.append(subcategory)
return subcategories
def _identifiers(self) -> list[Any]:
identifiers: list[Any] = []
for identifier in self.persistent_identifiers.entries:
identifier_type = str(identifier.type)
if identifier_type == "doi":
identifiers.append(
identifier.identifier
if identifier.identifier.startswith(("http://", "https://"))
else f"https://doi.org/{identifier.identifier}"
)
elif identifier.identifier.startswith(("http://", "https://")):
identifiers.append(identifier.identifier)
else:
item: dict[str, Any] = {
"@type": "PropertyValue",
"propertyID": identifier_type,
"value": identifier.identifier,
}
if identifier.associated_version:
item["description"] = (
f"Persistent identifier for version {identifier.associated_version}"
)
identifiers.append(item)
return identifiers or [
{
"@type": "PropertyValue",
"propertyID": "placeholder",
"value": "REPLACE_WITH_PERSISTENT_IDENTIFIER",
}
]
def _license(self) -> Any:
value = self.licensing.license or ""
if not value:
return {
"@type": "CreativeWork",
"name": "REPLACE_WITH_PROJECT_LICENSE",
"url": "https://example.org/REPLACE_WITH_LICENSE",
}
if value.startswith(("http://", "https://")):
return value
if "\n" not in value and len(value) < 100:
return f"https://spdx.org/licenses/{value}"
return {
"@type": "CreativeWork",
"name": "Custom project license",
"url": "https://example.org/REPLACE_WITH_CUSTOM_LICENSE_URL",
}
def _supporting_data(self) -> list[dict[str, str]]:
result: list[dict[str, str]] = []
seen: set[str] = set()
for function in self.software_functions.entries:
for direction, values in (
("input", function.inputs or []),
("output", function.outputs or []),
):
for function_io in values:
for data_format in function_io.format or []:
if not data_format.sample_url or data_format.sample_url in seen:
continue
result.append(
{
"@type": "DataFeed",
"@id": data_format.sample_url,
"name": data_format.term or f"Example {direction} data",
"url": data_format.sample_url,
}
)
seen.add(data_format.sample_url)
return result
def _has_generated_ci(self) -> bool:
quality = self.quality_tools
has_quality_ci = bool(quality.formatter or quality.linter or quality.type_checker)
return bool(
self.include_metadata
or "CHANGELOG.md" in self.community_files.entries
or self.documentation_types.entries
or self.test_types.entries
or has_quality_ci
or self.containerization.entries
or self.distribution_channels.entries
or (
self.licensing.license
and str(self.licensing.compatibility_check) == "Yes - automated tooling"
)
)
[docs]
def template_data(self) -> dict[str, Any]:
registry_links = self._registry_links()
interfaces = list(dict.fromkeys(str(item.type) for item in self.interfaces.entries))
runtime_platforms: list[str] = []
languages: list[Any] = []
for language in self.programming_languages.entries:
item = {"@type": "ComputerLanguage", "name": language.name}
if language.version_constraint:
item["version"] = language.version_constraint
languages.append(item)
if not languages:
languages = ["REPLACE_WITH_PROGRAMMING_LANGUAGE"]
for container in self.containerization.entries:
container_type = str(container.type)
if container_type not in runtime_platforms:
runtime_platforms.append(container_type)
operating_systems = [
str(item.name) + (f" {item.specification}" if item.specification else "")
for item in self.operating_systems.entries
if not item.status or str(item.status) in {"Officially supported", "Expected to work"}
]
funders = self._funders()
funding_values = [
x.award_number or x.project_code or x.award_title or x.funder
for x in self.funding.entries
]
publications = [
{"@type": "ScholarlyArticle", "@id": link, **({"name": pub.title} if pub.title else {})}
for pub in self.publications.entries
if (link := publication_link(pub))
]
repository = self.urls.repository or ""
is_accessible_for_free, usage_info = self._access()
return {
"project_name": self.project_name or self.project_slug,
"description": self.project_long_description
or self.project_short_description
or "REPLACE_WITH_DESCRIPTION",
"version": self.versioning.version or "0.1.0",
"development_status": str(self.development_status or "REPLACE_WITH_DEVELOPMENT_STATUS"),
"application_category": interfaces[0]
if len(interfaces) == 1
else (interfaces or "REPLACE_WITH_APPLICATION_CATEGORY"),
"application_subcategory": self._application_subcategories(),
"feature_list": self._features(),
"programming_languages": languages,
"authors": [
self._person(person)
for person in contributors_with_roles(self.contributors.entries, AUTHOR_ROLES)
]
or [{"@type": "Organization", "name": "Project team"}],
"identifiers": self._identifiers(),
"maintainers": [
self._person(person)
for person in contributors_with_roles(self.contributors.entries, MAINTAINER_ROLES)
],
"principal_investigators": [
self._person(person)
for person in contributors_with_roles(
self.contributors.entries, PRINCIPAL_INVESTIGATOR_ROLES
)
],
"keywords": metadata_keywords(self.keywords.entries, self.topics.entries),
"code_repository": repository or "https://example.org/REPLACE_WITH_REPOSITORY",
"continuous_integration": f"{repository.rstrip('/')}/actions"
if repository.startswith("https://github.com/") and self._has_generated_ci()
else None,
"license": self._license(),
"is_accessible_for_free": is_accessible_for_free,
"usage_info": usage_info,
"url": self.urls.homepage or self.urls.documentation,
"software_help": self.urls.documentation,
"issue_tracker": next((x.url for x in self.support_routes.entries if x.url), None),
"release_notes": f"{repository.rstrip('/')}/blob/HEAD/CHANGELOG.md"
if "CHANGELOG.md" in self.community_files.entries and repository
else None,
"runtime_platforms": runtime_platforms,
"operating_systems": operating_systems,
"software_requirements": [
self._dependency(x) for x in self.external_dependencies.entries
],
"supporting_data": self._supporting_data(),
"funders": funders,
"funding_values": [x for x in funding_values if x],
"related_links": list(
dict.fromkeys(x.url_or_doi for x in self.related_software.entries if x.url_or_doi)
),
"same_as": registry_links,
"reference_publications": publications,
}