Source code for rs_files_templates.models.biotools
"""Model and normalization logic for ``biotools.json``."""
from __future__ import annotations
import re
from typing import Any
from rsm_schema.generated import Contributor
from .base import rsm_template_base
from .utils import normalize_orcid, person_name
BIOTOOLS_SCHEMA_URL = (
"https://raw.githubusercontent.com/bio-tools/biotoolsSchema/"
"refs/heads/main/jsonschema/biotoolsj.json"
)
_BiotoolsRSM = rsm_template_base(
"_BiotoolsRSM",
"project_name",
"project_slug",
"project_short_description",
"project_long_description",
"development_status",
"topics",
"contributors",
"funding",
"urls",
"registries",
"persistent_identifiers",
"publications",
"licensing",
"access",
"support_routes",
"programming_languages",
"software_functions",
"interfaces",
"operating_systems",
"versioning",
)
_MATURITY = {
"concept": "Emerging",
"wip": "Emerging",
"active": "Mature",
"inactive": "Legacy",
"suspended": "Legacy",
"abandoned": "Legacy",
"unsupported": "Legacy",
"moved": "Legacy",
}
_COST = {
"free": "Free of charge",
"free-with-restrictions": "Free of charge (with restrictions)",
"commercial": "Commercial",
}
_OPERATING_SYSTEM = {
"Linux": "Linux",
"macOS": "Mac",
"Windows": "Windows",
"Android": "Android",
"iOS": "iOS",
}
_LANGUAGES = frozenset(
{
"Bash",
"C",
"C#",
"C++",
"CWL",
"Fortran",
"Go",
"Java",
"JavaScript",
"Julia",
"Kotlin",
"MATLAB",
"Perl",
"PHP",
"Python",
"R",
"Ruby",
"Rust",
"Shell",
"SQL",
"Swift",
"TypeScript",
}
)
_PUBLICATION_TYPES = {
"benchmarking study": "Benchmarking study",
"method": "Method",
"usage": "Usage",
"review": "Review",
"other": "Other",
}
_ROLE_MAP = {
"Original author": "Developer",
"Co-author": "Developer",
"Maintainer": "Maintainer",
"Principal investigator": "Provider",
"Successor": "Contributor",
}
[docs]
class BiotoolsModel(_BiotoolsRSM):
"""Published RSM fields needed to render ``biotools.json``."""
template_name = "biotools.json.j2"
output_name = "biotools.json"
schema_url = BIOTOOLS_SCHEMA_URL
media_type = "json"
@staticmethod
def _name(value: str, fallback: str) -> str:
"""Return a name accepted by the bio.tools character constraints."""
cleaned = re.sub(r"[^ ()+\-./0-9:;A-Z_a-z]", "-", value.strip())
cleaned = re.sub(r"-{2,}", "-", cleaned).strip("- ")
fallback = re.sub(r"[^ ()+\-./0-9:;A-Z_a-z]", "-", fallback.strip())
fallback = re.sub(r"-{2,}", "-", fallback).strip("- ")
return (cleaned or fallback or "project")[:100]
def _description(self) -> str:
"""Return a description within the schema's length bounds."""
value = str(
self.project_long_description
or self.project_short_description
or "Research software project."
).strip()
if len(value) < 10:
value = f"{value} software".strip()
return value[:1000]
@staticmethod
def _edam(value: Any, namespace: str) -> dict[str, str] | None:
"""Map one RSM EDAM term to the URI form required by bio.tools."""
result: dict[str, str] = {}
if term := str(getattr(value, "term", None) or "").strip():
result["term"] = term
uri = str(getattr(value, "uri", None) or "").strip()
match = re.fullmatch(rf"https?://edamontology\.org/{namespace}_([0-9]{{4}})", uri)
if match:
result["uri"] = f"http://edamontology.org/{namespace}_{match.group(1)}"
return result or None
def _biotools_id(self) -> str:
"""Read an existing bio.tools identifier from the registry metadata."""
for registry in self.registries.entries:
registry_name = re.sub(r"[^a-z]", "", registry.name.lower())
if registry_name != "biotools" or not registry.url_or_id:
continue
value = str(registry.url_or_id).strip().rstrip("/")
value = re.sub(r"^https?://bio\.tools/", "", value, flags=re.IGNORECASE)
value = re.sub(r"^biotools:", "", value, flags=re.IGNORECASE)
if re.fullmatch(r"[-.0-9A-Z_a-z]+", value):
return value
return ""
def _other_ids(self) -> list[dict[str, str]]:
"""Map persistent identifiers supported by the bio.tools schema."""
result: list[dict[str, str]] = []
for identifier in self.persistent_identifiers.entries:
identifier_type = str(identifier.type)
value = str(identifier.identifier).strip()
if identifier_type == "doi":
value = re.sub(r"^https?://(?:dx\.)?doi\.org/", "", value)
if re.fullmatch(r"10\.[0-9]{4,9}/\S+", value, flags=re.IGNORECASE):
record = {"type": "doi", "value": value}
if identifier.associated_version:
record["version"] = str(identifier.associated_version)[:100]
result.append(record)
return result
def _topics(self) -> list[dict[str, str]]:
"""Map project-level EDAM topics."""
return [topic for value in self.topics.entries if (topic := self._edam(value, "topic"))]
def _license(self) -> str:
"""Return a bio.tools license identifier or its custom-license marker."""
value = str(self.licensing.license or "").strip()
if not value:
return ""
if "\n" in value or value.startswith(("http://", "https://")):
return "Other"
if " " in value and value not in {"Not licensed"}:
return "Other"
return value
def _functions(self) -> list[dict[str, Any]]:
"""Map RSM software functions to bio.tools function records."""
result: list[dict[str, Any]] = []
for function in self.software_functions.entries:
operations = [
operation
for value in function.operations or []
if (operation := self._edam(value, "operation"))
]
if not operations:
continue
record: dict[str, Any] = {"operation": operations}
for source_name, target_name in (("inputs", "input"), ("outputs", "output")):
entries: list[dict[str, Any]] = []
for item in getattr(function, source_name) or []:
data = self._edam(item.data, "data") if item.data else None
if not data:
continue
entry: dict[str, Any] = {"data": data}
formats = [
data_format
for value in item.format or []
if (data_format := self._edam(value, "format"))
]
if formats:
entry["format"] = formats
entries.append(entry)
if entries:
record[target_name] = entries
note = str(function.note or "").strip()
if len(note) >= 10:
record["note"] = note[:1000]
if command := str(function.cmd or "").strip():
record["cmd"] = command[:1000]
result.append(record)
return result
@staticmethod
def _credit(contributor: Contributor) -> dict[str, Any]:
"""Map one RSM contributor to a bio.tools credit."""
record: dict[str, Any] = {
"name": person_name(contributor)[:100],
"typeEntity": "Person",
}
if contributor.email:
record["email"] = contributor.email
if contributor.url:
record["url"] = contributor.url
if contributor.orcid:
record["orcidid"] = f"https://orcid.org/{normalize_orcid(contributor.orcid)}"
roles = list(
dict.fromkeys(
_ROLE_MAP[str(role)] for role in contributor.roles if str(role) in _ROLE_MAP
)
)
if roles:
record["typeRole"] = roles
return record
def _credits(self) -> list[dict[str, Any]]:
"""Build contributor and funding-agency credits."""
result = [self._credit(person) for person in self.contributors.entries]
seen_funders: set[str] = set()
for funding in self.funding.entries:
name = str(funding.funder or "").strip()
if not name or name.casefold() in seen_funders:
continue
record: dict[str, Any] = {
"name": name[:100],
"typeEntity": "Funding agency",
"typeRole": ["Provider"],
}
identifier = str(funding.funder_identifier or "").strip()
identifier_type = str(funding.funder_identifier_type or "")
if identifier_type == "ror" and identifier:
record["rorid"] = identifier.removeprefix("https://ror.org/")
elif identifier_type == "crossref-funder-id" and identifier:
record["fundrefid"] = identifier
if funding.funder_url:
record["url"] = funding.funder_url
result.append(record)
seen_funders.add(name.casefold())
return result
def _publications(self) -> list[dict[str, Any]]:
"""Map DOI, PMID, and PMCID publication identifiers."""
result: list[dict[str, Any]] = []
for publication in self.publications.entries:
record: dict[str, Any] = {}
if publication.doi:
doi = re.sub(r"^https?://(?:dx\.)?doi\.org/", "", publication.doi)
if re.fullmatch(r"10\.[0-9]{4,9}/\S+", doi, flags=re.IGNORECASE):
record["doi"] = doi
if publication.pmid:
pmid = re.sub(r"^PMID:", "", publication.pmid, flags=re.IGNORECASE)
if re.fullmatch(r"[1-9][0-9]{0,8}", pmid):
record["pmid"] = pmid
if publication.pmcid:
pmcid = publication.pmcid.upper()
pmcid = pmcid if pmcid.startswith("PMC") else f"PMC{pmcid}"
if re.fullmatch(r"PMC[1-9][0-9]{0,8}", pmcid):
record["pmcid"] = pmcid
publication_type = _PUBLICATION_TYPES.get(str(publication.type or "").lower())
types = ["Primary"] if publication.preferred else []
if publication_type and publication_type not in types:
types.append(publication_type)
if types:
record["type"] = types
note = str(publication.note or publication.citation or "").strip()
if len(note) >= 10:
record["note"] = note[:1000]
if self.versioning.version:
record["version"] = str(self.versioning.version)[:100]
if record:
result.append(record)
return result
def _links(self) -> list[dict[str, Any]]:
"""Map repository and support links."""
result: list[dict[str, Any]] = []
if self.urls.repository:
result.append({"url": self.urls.repository, "type": ["Repository"]})
for route in self.support_routes.entries:
if not route.url:
continue
system = str(route.system or "").lower()
if "issue" in system:
link_type = "Issue tracker"
elif "mail" in system:
link_type = "Mailing list"
elif "discussion" in system or "forum" in system:
link_type = "Discussion forum"
elif "help" in system or "support" in system:
link_type = "Helpdesk"
else:
link_type = "Other"
result.append({"url": route.url, "type": [link_type]})
return result
[docs]
def template_data(self) -> dict[str, Any]:
"""Build the single tool record stored in the top-level array."""
homepage = self.urls.homepage or self.urls.documentation or self.urls.repository or ""
tool: dict[str, Any] = {
"name": self._name(self.project_name or self.project_slug, self.project_slug),
"description": self._description(),
"homepage": homepage,
}
optional_values: tuple[tuple[str, Any], ...] = (
("biotoolsID", self._biotools_id()),
("version", [str(self.versioning.version)[:100]] if self.versioning.version else []),
("otherID", self._other_ids()),
("toolType", list(dict.fromkeys(str(x.type) for x in self.interfaces.entries))),
("topic", self._topics()),
(
"operatingSystem",
list(
dict.fromkeys(
_OPERATING_SYSTEM[str(item.name)]
for item in self.operating_systems.entries
if str(item.name) in _OPERATING_SYSTEM
)
),
),
(
"language",
list(
dict.fromkeys(
language.name if language.name in _LANGUAGES else "Other"
for language in self.programming_languages.entries
)
),
),
("license", self._license()),
("maturity", _MATURITY.get(str(self.development_status or ""))),
("cost", _COST.get(str(self.access.type or ""))),
(
"accessibility",
"Open access"
if str(self.access.type or "") == "free"
else (
"Restricted access"
if str(self.access.type or "") in {"free-with-restrictions", "commercial"}
else None
),
),
("function", self._functions()),
("link", self._links()),
(
"documentation",
[{"url": self.urls.documentation, "type": ["General"]}]
if self.urls.documentation
else [],
),
("publication", self._publications()),
("credit", self._credits()),
)
tool.update({key: value for key, value in optional_values if value})
return {"tools": [tool]}