endremborza/branthebuilder

View on GitHub
branthebuilder/main.py

Summary

Maintainability
A
1 hr
Test Coverage
import datetime as dt
import json
import os
import re
import sys
from functools import reduce
from pathlib import Path
from shutil import rmtree
from subprocess import check_call, check_output
from warnings import warn

import typer
import yaml
from cookiecutter.main import cookiecutter

from .nb_scripts import get_nb_scripts, get_notebooks, nb_dir
from .vars import CFF_PATH, DOC_DIR, ORCID_DIC_ENV, README_PATH, Bump, cc_repo, conf

app = typer.Typer()

osc_path = Path(".github", "workflows", "compatibility_test.yml")
ghw_path = osc_path.parent.parent


class SetupException(Exception):
    pass


@app.command()
def lint(line_len: int = None, full: bool = False):
    ll = line_len or conf.line_len
    target = "." if full else conf.module_path
    _no_tb_call(["black", target, "-l", ll])
    _no_tb_call(["isort", target, "--profile", "black", "-l", ll])
    _no_tb_call(["flake8", target, "--max-line-length", ll])


@app.command()
def init(
    input: bool = True,
    docs: bool = False,
    notebooks: bool = False,
    actions: bool = False,
    single_file: bool = False,
    git: bool = True,
    os_compatibility: bool = False,
):
    res_dir = cookiecutter(cc_repo, no_input=not input)
    os.chdir(res_dir)
    _cleanup(docs, actions, notebooks, single_file, os_compatibility)
    if not git:
        return
    for cmd in [
        ["init"],
        ["add", "*"],
        ["commit", "-m", "init"],
        ["branch", "template"],
    ]:
        check_call(["git", *cmd])


@app.command()
def update_boilerplate(merge: bool = False):
    author_base = conf.project_conf["authors"][0]
    if isinstance(author_base, dict):
        name = author_base["name"]
        email = author_base["email"]
        url = conf.project_conf["urls"]["Homepage"]
        pykey = "requires-python"
        description = conf.module.__doc__
    else:
        warn("legacy pyproject.toml! fill in email and delete some files")
        name = author_base
        email = "FILL@ME"
        url = conf.project_conf["url"]
        pykey = "python"
        description = conf.project_conf["description"]

    cc_context = {
        "full_name": name,
        "email": email,
        "github_user": url.split("/")[-2],
        "project_name": conf.name,
        "description": description,
        "python_version": conf.project_conf[pykey][2:],
    }

    branch = _get_branch()
    check_call(["git", "checkout", "template"])
    cookiecutter(
        cc_repo,
        no_input=True,
        extra_context=cc_context,
        output_dir="..",
        overwrite_if_exists=True,
    )

    single = conf.module_path.endswith(".py")
    _cleanup(
        DOC_DIR.exists(), ghw_path.exists(), nb_dir.exists(), single, osc_path.exists()
    )
    adds = check_output(["git", "add", "*"]).strip()
    if adds:
        check_call(["git", "commit", "-m", "update-boilerplate"])
    if merge:
        check_call(["git", "checkout", branch])
        check_call(["git", "merge", "template", "--no-edit"])


@app.command()
def test(html: bool = False, v: bool = False, notebooks: bool = True, cov: bool = True):
    lint()
    test_paths = [conf.module_path]
    test_notebook_path = Path("test_nb_integrations.py")
    if notebooks:
        test_notebook_path.write_text("\n\n".join(get_nb_scripts()))
        test_paths.append(test_notebook_path.as_posix())
    comm = ["python", "-m", "pytest", *test_paths, "--doctest-modules"]
    if cov:
        form = "html" if html else "xml"
        comm += [f"--cov={conf.name}", f"--cov-report={form}"]
    if v:
        comm.append("-s")
    try:
        _no_tb_call(comm)
    finally:
        test_notebook_path.unlink(missing_ok=True)


@app.command()
def build_docs():
    rmtree(DOC_DIR / "api", ignore_errors=True)
    rmtree(DOC_DIR / "notebooks", ignore_errors=True)

    _nbs = [*map(str, get_notebooks())]
    if _nbs:
        out = f"--output-dir={DOC_DIR}/notebooks"
        check_call(["jupyter", "nbconvert", *_nbs, "--to", "rst", out])
    check_call(["sphinx-build", DOC_DIR.as_posix(), f"{DOC_DIR}/_build"])


@app.command()
def tag(msg: str, bump: Bump):
    branch = _get_branch()
    if branch != "main":
        raise SetupException(f"only main branch can be tagged - {branch}")

    new_version = conf.get_bumped_version(bump)
    tag_version = f"v{new_version}"
    tags = check_output(["git", "tag"]).split()
    if tag_version in tags:
        raise SetupException(f"{tag_version} version already tagged")
    if DOC_DIR.exists():
        note_rst = f"{tag_version}\n---------------------\n\n" + msg
        (DOC_DIR / "release_notes" / f"{tag_version}.rst").write_text(note_rst)
        build_docs()
        check_call(["git", "add", "docs"])
        check_call(["git", "commit", "-m", f"docs for {tag_version}"])
    _mod_cff({"version": new_version, "date-released": dt.date.today()}, tag_version)
    check_call(["git", "add", conf.version_file])
    check_call(["git", "commit", "-m", f"bump {bump} __version__ for {tag_version}"])
    check_call(["git", "tag", "-a", tag_version, "-m", msg])
    check_call(["git", "push"])
    check_call(["git", "push", "origin", tag_version])


@app.command()
def init_cff(
    msg: str = "If you use this software, please cite it as below.",
    license: str = "MIT",
):
    license = "MIT"
    cff_version: str = "1.2.0"
    url = conf.project_conf["urls"]["Homepage"]
    cff_dic = {
        "cff-version": cff_version,
        "message": msg,
        "url": url,
        "authors": [],
        "title": "/".join(url.split("/")[-2:]),
        "license": license,
        "keywords": conf.project_conf.get("keywords", []),
        "type": "software",  # / dataset
    }

    orcid_dic = json.loads(os.environ.get(ORCID_DIC_ENV, "{}"))
    for author in conf.project_conf["authors"]:
        names = author["name"].split()
        adic = {"family-names": names[-1], "given-names": " ".join(names[:-1])}
        orcid = orcid_dic.get(author["name"])
        if orcid:
            adic["orcid"] = orcid
        cff_dic["authors"].append(adic)

    _dump_cff(cff_dic)


@app.command()
def add_zenodo_concept_doi(doi: int):
    full_doi = f"10.5281/zenodo.{doi}"
    fstr = "[![DOI](https://zenodo.org/badge/doi/{}.svg)](https://doi.org/{})"
    z_find = re.compile(escape(fstr, "()[]!").format(".*", ".*")).findall
    lines = list(filter(lambda li: not z_find(li), README_PATH.read_text().split("\n")))
    _mod_cff({"doi": full_doi})
    lines.insert(2, fstr.format(full_doi, full_doi))
    README_PATH.write_text("\n".join(lines))


def _get_branch():
    comm = ["git", "rev-parse", "--abbrev-ref", "HEAD"]
    return check_output(comm).strip().decode("utf-8")


def _cleanup(leave_docs, leave_actions, leave_notebooks, single_file, os_compatibility):
    if not leave_docs:
        rmtree(DOC_DIR)
        Path(".readthedocs.yml").unlink()
    if not leave_actions:
        rmtree(ghw_path)
    elif not os_compatibility:
        osc_path.unlink()
    if not leave_notebooks:
        rmtree(nb_dir)
    if single_file:
        pack_dir = Path(conf.name)
        init_str = (pack_dir / "__init__.py").read_text()
        rmtree(pack_dir)
        Path(f"{conf.name}.py").write_text(init_str)


def escape(s, chars) -> str:
    return reduce(lambda le, ri: le.replace(ri, f"\\{ri}"), chars, s)


def _no_tb_call(args):
    try:
        check_call(args)
    except Exception:
        sys.exit(1)


def _mod_cff(mod_dic: dict, msg: str = None):
    if not CFF_PATH.exists():
        return

    cff_dic = yaml.safe_load(CFF_PATH.read_text())
    _dump_cff(cff_dic | mod_dic)
    if msg:
        check_call(["git", "add", CFF_PATH.as_posix()])
        check_call(["git", "commit", "-m", f"update cff {msg}"])


def _dump_cff(dic):
    CFF_PATH.write_text(yaml.safe_dump(dic, allow_unicode=True, sort_keys=False))