andreoliwa/nitpick

View on GitHub
tasks.py

Summary

Maintainability
A
35 mins
Test Coverage
"""Invoke targets.

Helpful docs:
- https://www.pyinvoke.org/
- https://docs.pyinvoke.org/en/stable/api/runners.html#invoke.runners.Runner.run
"""

from __future__ import annotations

import sys
from configparser import ConfigParser
from typing import Iterator

from invoke import Collection, Exit, task  # pylint: disable=import-error

COLOR_NONE = "\x1b[0m"
COLOR_GREEN = "\x1b[32m"
COLOR_BOLD_RED = "\x1b[1;31m"
DOCS_BUILD_PATH = "docs/_build"

# pylint: disable=too-many-arguments


class ToxCommands:
    """Tox commands read from the config file."""

    def __init__(self) -> None:
        self._parser = ConfigParser()
        self._parser.read("tox.ini")

    def list_commands(self, section: str) -> Iterator[str]:
        """List all commands in a section."""
        for line in self._parser[section]["commands"].splitlines():
            if not line:
                continue
            yield line

    def find_command(self, section: str, search: str) -> str:
        """Find a command on a section."""
        for line in self.list_commands(section):
            if search in line:
                return line.lstrip("- ")
        return ""

    @property
    def pytest_command(self):
        """Pytest."""
        return self.find_command("testenv", "pytest").replace("{posargs:", "").replace("}", "")

    def coverage_commands(self) -> Iterator[str]:
        """All coverage commands."""
        yield from self.list_commands("testenv:report")

    @property
    def autofix_docs(self):
        """Autofix ReST documentation from docstrings and TOML."""
        return self.find_command("testenv:docs", "autofix_docs")

    @property
    def api(self):
        """Generate API docs."""
        return self.find_command("testenv:docs", "apidoc")

    @property
    def check_links(self):
        """Generate API docs."""
        return self.find_command("testenv:docs", "linkcheck").replace("{toxworkdir}", DOCS_BUILD_PATH)

    @property
    def html_docs(self):
        """Generate HTML docs."""
        return (
            self.find_command("testenv:docs", "html").replace("{posargs}", "").replace("{toxworkdir}", DOCS_BUILD_PATH)
        )

    @staticmethod
    def config(c) -> str:
        """Enable/disable macOS on tox.ini."""
        if sys.platform != "darwin":
            return ""

        temp_tox_ini = ".temp-tox.ini"
        # Trick to be able to run `invoke lint` on a macOS machine during development.
        c.run(f"sed 's/platform = linux/platform = darwin/g' tox.ini > {temp_tox_ini}")
        return f"-c {temp_tox_ini}"

    @property
    def python_versions(self) -> list[str]:
        """Python versions executed in tox."""
        versions = []
        for py_plus_version_without_dot in self._parser["tox"]["envlist"].split(","):
            if not py_plus_version_without_dot.startswith("py"):
                continue

            major = py_plus_version_without_dot[2]
            minor = py_plus_version_without_dot[3:]
            versions.append(f"{major}.{minor}")
        return list(reversed(versions))

    @property
    def minimum_python_version(self) -> str:
        """Minimum Python version."""
        return self.python_versions[0]

    @property
    def stable_python_version(self) -> str:
        """The Python version considered stable to develop Nitpick."""
        # Python 3.11 doesn't work with poetry install:
        #   • Installing wrapt (1.13.3): Failed
        #
        #   ChefBuildError
        #
        #   Backend subprocess exited when trying to invoke build_wheel
        # Note: This error originates from the build backend, and is likely not a problem with poetry
        # but with wrapt (1.13.3) not supporting PEP 517 builds. You can verify this by running
        # 'pip wheel --use-pep517 "wrapt (==1.13.3)"'.
        return "3.10"

    @staticmethod
    def as_tox_env(python_version_with_dot: str) -> str:
        """Convert a Python version with dot to a tox environment name."""
        no_dot = python_version_with_dot.replace(".", "")
        return f"py{no_dot}"


@task(
    help={
        "deps": "Poetry dependencies",
        "hooks": "pre-commit hooks",
        "version": "Desired Python version number. Default: stable Python version",
    }
)
def install(c, deps=True, hooks=False, version=""):
    """Install dependencies and pre-commit hooks.

    Poetry install is needed to create the Nitpick plugin entries on setuptools, used by pluggy.
    """
    if deps:
        tox = ToxCommands()
        minimum = tox.minimum_python_version
        if not version:
            version = tox.stable_python_version

        print(
            f"{COLOR_GREEN}Nitpick runs in Python {minimum} and later;"
            f" setting up version {version} for development{COLOR_NONE}"
        )
        c.run(f"poetry env use python{version}")
        c.run("poetry install -E test -E lint -E doc --sync")
    if hooks:
        c.run("pre-commit install -t pre-commit -t commit-msg --install-hooks")
        c.run("pre-commit gc")


@task(
    help={
        "file": "Choose (with fzf) a specific file to run tests",
        "coverage": "Run the HTML coverage report",
        "browse": "Browse the HTML coverage report",
        "watch": "Watch modified files and run tests with testmon",
        "reset": "Force testmon to update its data before watching tests",
    }
)
def test(c, file="", coverage=False, browse=False, watch=False, reset=False):
    """Run tests and coverage using the commands from tox config.

    `Testmon <https://github.com/tarpas/pytest-testmon>`_
    """
    tox = ToxCommands()
    if reset:
        c.run(f"poetry run {tox.pytest_command} --testmon-noselect")
        watch = True
    if watch:
        c.run('poetry run ptw --runner "pytest --testmon"')
        return

    file_opt = ""
    if file:
        from conjuring.grimoire import run_with_fzf  # pylint: disable=import-error,import-outside-toplevel

        chosen_file = run_with_fzf(c, "fd -H -t f test_.*.py", query=file)
        if not chosen_file:
            return
        file_opt = f" -- {chosen_file}"
    c.run(f"poetry run {tox.pytest_command}{file_opt}")

    if coverage:
        for cmd in tox.coverage_commands():
            c.run(f"poetry run {cmd}")

    if browse:
        c.run("open htmlcov/index.html")


@task(
    help={
        "full": "Run all steps",
        "recreate": "Delete and recreate RST for source files",
        "links": "Check links",
        "browse": "Browse the HTML index",
        "debug": "Debug HTML generation to fix warnings",
    }
)
def doc(c, full=False, recreate=False, links=False, browse=False, debug=False):
    """Build documentation."""
    tox = ToxCommands()

    if full:
        recreate = links = True
    if recreate:
        c.run("mkdir -p docs/_static")
        c.run(f"rm -rf {DOCS_BUILD_PATH} docs/source")

    c.run("poetry export --without-hashes -E doc > docs/requirements.txt")
    c.run(f"poetry run {tox.autofix_docs}", warn=True)
    c.run(f"poetry run {tox.api}")
    if debug:
        c.run("poetry run sphinx-apidoc --help")

    debug_options = "-nWT --keep-going -vvv" if debug else ""
    c.run(f"poetry run {tox.html_docs} {debug_options}")

    if links:
        c.run(f"poetry run {tox.check_links}", warn=True)

    if browse:
        c.run(f"open {DOCS_BUILD_PATH}/docs_out/index.html")


@task(
    help={
        "full": "Full build using tox",
        "recreate": "Recreate tox environment",
        "docs": "Generate Sphinx docs",
        "python": "Python version",
    }
)
def ci_build(c, full=False, recreate=False, docs=True, python=""):
    """Simulate a CI build."""
    tox = ToxCommands()

    tox_cmd = " ".join(["tox", tox.config(c), "-r" if recreate else ""])
    if full:
        c.run(f"rm -rf {DOCS_BUILD_PATH} docs/source")
        c.run(tox_cmd)
        return

    chosen_version = python or tox.stable_python_version
    envs = ["clean", "lint", tox.as_tox_env(chosen_version)]
    if docs:
        envs.append("docs")
    envs.append("report")
    c.run(f"{tox_cmd} -e {','.join(envs)}")


@task(help={"recreate": "Recreate tox environment"})
def lint(c, recreate=False):
    """Lint using tox."""
    tox = ToxCommands()

    tox_cmd = "tox -r" if recreate else "tox"
    result = c.run(f"{tox_cmd}{tox.config(c)} -e lint", warn=True)

    # Exit only after restoring tox.ini
    if result.exited > 0:
        msg = "pylint failed"
        raise Exit(msg, 1)


@task(help={"venv": "Remove the Poetry virtualenv and the tox dir"})
def clean(c, venv=False):
    """Clean build output and temp files."""
    c.run("find . -type f -name '*.py[co]' -print -delete")
    c.run("find . -type d -name '__pycache__' -print -delete")
    c.run("find . -type d \\( -name '*.egg-info' -or -name 'pip-wheel-metadata' -or -name 'dist' \\) -print -delete")
    c.run(f"rm -rf .cache .mypy_cache {DOCS_BUILD_PATH} src/*.egg-info .pytest_cache .coverage htmlcov .testmondata")
    if venv:
        c.run("rm -rf .tox")
        version = ToxCommands().minimum_python_version
        c.run(f"poetry env remove python{version}", warn=True)


@task
def reactions(c):
    """List issues with reactions.

    https://github.blog/2021-03-11-scripting-with-github-cli/
    https://docs.github.com/en/rest/reference/issues#get-an-issue
    https://developer.github.com/changes/2016-05-12-reactions-api-preview/
    """
    result = c.run("gh api -X GET 'repos/andreoliwa/nitpick/issues' --paginate --jq '.[].number'", pty=False)
    for issue in result.stdout.splitlines():
        result_users = c.run(
            f"gh api -X GET 'repos/andreoliwa/nitpick/issues/{int(issue)}/reactions'"
            " -H 'Accept: application/vnd.github.squirrel-girl-preview'"
            " | jq -r '.[].user.html_url'"
        )
        users = result_users.stdout.splitlines()
        if users:
            print(COLOR_GREEN)
            print(f">>> https://github.com/andreoliwa/nitpick/issues/{issue}")
            for index, user in enumerate(users):
                print(f"    {index + 1}. {user}")
            print(COLOR_NONE)


@task(
    help={
        "convert_file_name": "Partial name of the file you want to convert into a Nitpick TOML style",
        "lab_help": "Display the help for the lab CLI (a Click CLI within an Invoke CLI... CLI inception!)",
    }
)
def lab(c, convert_file_name="", lab_help=False):
    """Laboratory of experiments and ideas.

    You need to install certain tools if you want to use this command.

    Pre-requisites:
    - https://github.com/andreoliwa/conjuring
    - https://github.com/junegunn/fzf
    - https://github.com/sharkdp/fd
    """
    extra_args = []
    if lab_help:
        extra_args.append("--help")
    if convert_file_name:
        from conjuring.grimoire import run_with_fzf  # pylint: disable=import-error,import-outside-toplevel

        chosen_file = run_with_fzf(c, "fd -H -t f", query=convert_file_name)
        extra_args.extend(["convert", chosen_file])

    c.run(f"poetry run python docs/ideas/lab.py {' '.join(extra_args)}")


namespace = Collection(install, test, doc, ci_build, lint, clean, reactions, lab)
namespace.configure(
    {
        "run": {
            # Use a pseudo-terminal to display colorful output
            "pty": True
        }
    }
)