Source code for rs_files_templates.models.readme

"""Metadata-only model and renderer for ``README.md``."""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

from ..commands import (
    FORMATTER_COMMANDS,
    LINTER_COMMANDS,
    PROJECT_MANAGER_PROFILES,
    TEST_COMMANDS,
    TYPE_CHECKER_COMMANDS,
    python_installation_command,
)
from .base import rsm_template_base

_ReadmeRSM = rsm_template_base(
    "_ReadmeRSM",
    "project_name",
    "project_slug",
    "project_short_description",
    "project_long_description",
    "versioning",
    "motivation",
    "topics",
    "audiences",
    "related_software",
    "urls",
    "persistent_identifiers",
    "publications",
    "licensing",
    "access",
    "include_metadata",
    "support_routes",
    "contacts",
    "operating_systems",
    "software_functions",
    "interfaces",
    "community_files",
    "project_manager",
    "programming_languages",
    "distribution_channels",
    "documentation_types",
    "quality_tools",
    "test_frameworks",
    "test_types",
    "resource_requirements",
    "maintenance_level",
    "continuity_plan",
    "retirement_criteria",
    "public_risk_notes",
    "security_measures",
    "data_management",
)

DOCUMENTATION_LABELS = {
    "user": "User guide: installation, configuration, usage, and examples",
    "deployment": "Deployment notes: environment setup and operational assumptions",
    "developer": "Developer guide: development, tests, contribution, and reference",
}


def _publication_url(publication: Any) -> str:
    """Resolve the best public URL for one associated publication."""
    if publication.doi:
        doi = str(publication.doi)
        return doi if doi.startswith(("http://", "https://")) else f"https://doi.org/{doi}"
    if publication.url:
        return str(publication.url)
    if publication.pmcid:
        return f"https://www.ncbi.nlm.nih.gov/pmc/articles/{publication.pmcid}/"
    if publication.pmid:
        return f"https://pubmed.ncbi.nlm.nih.gov/{publication.pmid}/"
    return ""


def _publication_label(publication: Any) -> str:
    """Return one compact human-readable publication label."""
    return str(
        publication.title
        or publication.citation
        or (f"DOI {publication.doi}" if publication.doi else "")
        or (f"PMID {publication.pmid}" if publication.pmid else "")
        or publication.pmcid
        or publication.url
        or "Associated publication"
    )


def _primary_publication(publications: list[Any]) -> Any | None:
    """Select the preferred or first populated publication."""
    populated = [
        publication
        for publication in publications
        if any(
            (
                publication.title,
                publication.citation,
                publication.doi,
                publication.url,
                publication.pmid,
                publication.pmcid,
            )
        )
    ]
    return next((item for item in populated if item.preferred), populated[0] if populated else None)


def _related_software_label(software: Any) -> str:
    """Format one related-software record."""
    name = str(software.name or "")
    url = str(software.url_or_doi or "")
    relationship = str(software.relationship or "")
    label = f"[{name}]({url})" if name and url else name or url or "Related software"
    return f"{label} - {relationship}" if relationship else label


def _term_lines(key: str, records: list[dict[str, Any]]) -> list[str]:
    """Render a bio.tools term collection as YAML lines."""
    lines = [f"{key}:"] if records else []
    for record in records:
        values = [(name, record.get(name)) for name in ("term", "uri") if record.get(name)]
        if not values:
            continue
        first_name, first_value = values[0]
        lines.append(f"- {first_name}: {json.dumps(first_value)}")
        lines.extend(f"  {name}: {json.dumps(value)}" for name, value in values[1:])
    return lines


def _io_lines(key: str, records: list[dict[str, Any]]) -> list[str]:
    """Render bio.tools input or output metadata as YAML lines."""
    lines = [f"{key}:"] if records else []
    for record in records:
        data = record.get("data") or {}
        lines.append("- data:")
        for name in ("term", "uri"):
            if data.get(name):
                lines.append(f"    {name}: {json.dumps(data[name])}")
        formats = record.get("format") or []
        if formats:
            lines.append("  format:")
            for item in formats:
                values = [(name, item.get(name)) for name in ("term", "uri") if item.get(name)]
                for value_index, (name, value) in enumerate(values):
                    prefix = "  - " if value_index == 0 else "    "
                    lines.append(f"{prefix}{name}: {json.dumps(value)}")
    return lines


def _function_blocks(entries: list[dict[str, Any]]) -> str:
    """Render README function metadata directly from RSM software functions."""
    blocks = []
    for entry in entries:
        yaml_lines = ["# biotools-function"]
        yaml_lines.extend(_term_lines("operation", entry.get("operations") or []))
        yaml_lines.extend(_io_lines("input", entry.get("inputs") or []))
        yaml_lines.extend(_io_lines("output", entry.get("outputs") or []))
        if command := entry.get("cmd"):
            yaml_lines.append(f"cmd: {json.dumps(command)}")
        if note := entry.get("note"):
            yaml_lines.append(f"note: {json.dumps(note)}")
        if len(yaml_lines) == 1:
            continue
        operations = [
            item.get("term", "") for item in entry.get("operations") or [] if item.get("term")
        ]
        label = ", ".join(operations) or "Software function"
        block = "\n".join(yaml_lines)
        blocks.append(
            f"<details>\n<summary>{label}</summary>\n\n```yaml\n{block}\n```\n\n</details>"
        )
    return "## Functions\n\n" + "\n\n".join(blocks) if blocks else ""


[docs] class ReadmeModel(_ReadmeRSM): """Published RSM fields used by the reusable project README.""" template_name = "README.md.j2" output_name = "README.md" def _first_doi(self) -> str: """Return the first normalized software DOI.""" for identifier in self.persistent_identifiers.entries: if str(identifier.type).lower() != "doi": continue value = str(identifier.identifier) for prefix in ( "https://doi.org/", "http://doi.org/", "http://dx.doi.org/", "doi:", ): if value.lower().startswith(prefix): return value[len(prefix) :] return value return "" def _installation(self) -> tuple[str, str, str]: """Return installation prose, command, and fence language from RSM.""" slug = str(self.project_slug or "project") channels = {str(value).strip().lower() for value in self.distribution_channels.entries} languages = { str(language.name).strip().lower() for language in self.programming_languages.entries if language.name } if "pypi" in channels: return ( "Install the published package from PyPI:", python_installation_command( str(self.project_manager), slug.replace("_", "-"), published=True ), "bash", ) if "cran" in channels: return "Install the published package from CRAN:", f'install.packages("{slug}")', "r" if "bioconductor" in channels: return ( "Install the published package with BiocManager:", f'BiocManager::install("{slug}")', "r", ) if "r" in languages or str(self.project_manager) in {"renv", "rix"}: return "Install from a source checkout:", "R CMD INSTALL .", "bash" if "python" in languages or str(self.project_manager) in { "hatch", "pdm", "pip", "pixi", "poetry", "uv", }: return ( "Install from a source checkout:", python_installation_command(str(self.project_manager), slug, published=False), "bash", ) if self.urls.repository: return ( "Clone the source repository:", f"git clone {self.urls.repository}\ncd {slug}", "bash", ) return "", "", "bash" def _usage_command(self) -> tuple[str, str]: """Return a declared command or a language-derived fallback.""" declared = next( (str(function.cmd) for function in self.software_functions.entries if function.cmd), "", ) slug = str(self.project_slug or "project") languages = { str(language.name).strip().lower() for language in self.programming_languages.entries if language.name } if "r" in languages or str(self.project_manager) in {"renv", "rix"}: command = f"library({slug})" if declared: command += f"\n{declared}" return command, "r" if declared: prefix = PROJECT_MANAGER_PROFILES.get(str(self.project_manager), {}).get( "run_prefix", "" ) return f"{prefix}{declared}", "bash" if "python" in languages: prefix = PROJECT_MANAGER_PROFILES.get(str(self.project_manager), {}).get( "run_prefix", "" ) return f"{prefix}python -m {slug}", "bash" return "", "bash" def _development(self) -> dict[str, Any]: """Resolve setup and checks entirely from RSM selections.""" profile = PROJECT_MANAGER_PROFILES.get(str(self.project_manager), {}) run_prefix = str(profile.get("run_prefix", "")) test_command = next( ( TEST_COMMANDS[str(framework)] for framework in self.test_frameworks.entries if str(framework) in TEST_COMMANDS ), "", ) if not test_command and self.test_types.entries: if str(self.project_manager) in {"renv", "rix"}: test_command = TEST_COMMANDS["testthat"] else: test_command = TEST_COMMANDS["pytest"] candidates = ( FORMATTER_COMMANDS.get(str(self.quality_tools.formatter), ""), LINTER_COMMANDS.get(str(self.quality_tools.linter), ""), TYPE_CHECKER_COMMANDS.get(str(self.quality_tools.type_checker), ""), test_command, ) return { "setup": str(profile.get("setup", "")), "checks": [f"{run_prefix}{command}" for command in candidates if command], } def _platform_lines(self) -> list[str]: """Return detailed supported-platform labels.""" lines = [] for system in self.operating_systems.entries: if not system.name: continue label = str(system.name) if system.specification: label += f" {system.specification}" if system.status: label += f" - {system.status}" lines.append(label) return lines
[docs] def template_data(self) -> dict[str, Any]: """Prepare a complete README from RSM values only.""" project = self.model_dump(mode="json", exclude_none=False) publication = _primary_publication(list(self.publications.entries)) installation_text, installation_command, installation_language = self._installation() usage_command, usage_language = self._usage_command() return { "project": project, "title": self.project_name or self.project_slug or "Project", "purpose_categories": ( [str(value) for value in self.motivation.categories.entries] if self.motivation.categories else [] ), "related_software": [ _related_software_label(item) for item in self.related_software.entries ], "preferred_publication": ( {"label": _publication_label(publication), "url": _publication_url(publication)} if publication is not None else None ), "publication_note": ( f"Publication: [{_publication_label(publication)}]({_publication_url(publication)})" if publication is not None and _publication_url(publication) else f"Publication: {_publication_label(publication)}" if publication is not None else "" ), "doi": self._first_doi(), "platforms": self._platform_lines(), "installation_text": installation_text, "installation_command": installation_command, "installation_language": installation_language, "usage_command": usage_command, "usage_language": usage_language, "documentation": [ DOCUMENTATION_LABELS.get(str(value).strip().lower(), str(value)) for value in self.documentation_types.entries ], "functions_section": _function_blocks(project["software_functions"]["entries"]), "development": self._development(), "citation_file": "CITATION.cff" if self.include_metadata else "", "support_file": ( "SUPPORT.md" if "SUPPORT.md" in {str(value) for value in self.community_files.entries} else "" ), "license_file": ( "LICENSE.md" if "r" in { str(language.name).strip().lower() for language in self.programming_languages.entries if language.name } and str(self.licensing.license) == "MIT" else "LICENSE" ), }
[docs] def render_readme_text(model: ReadmeModel) -> str: """Render a README whose only inputs are validated RSM fields.""" from ..renderer import render_template return render_template(model.template_name, model.template_data())
[docs] def render_readme(model: ReadmeModel, output_dir: str | Path) -> Path: """Write a metadata-only README and return its path.""" target = Path(output_dir) / model.output_name target.parent.mkdir(parents=True, exist_ok=True) target.write_text(render_readme_text(model), encoding="utf-8") return target