From 76e0b16aef0a55888cfdbea2ee23e8c6d572d366 Mon Sep 17 00:00:00 2001 From: evilchili Date: Mon, 25 Mar 2024 22:24:31 -0700 Subject: [PATCH] initial import --- README.md | 76 +++++++++++++++++++++++++++++++++- pyproject.toml | 48 +++++++++++++++++++++ pytest.ini | 3 ++ src/poetry_slam/__init__.py | 0 src/poetry_slam/build_tool.py | 78 +++++++++++++++++++++++++++++++++++ src/poetry_slam/cli.py | 75 +++++++++++++++++++++++++++++++++ test/test_slam.py | 47 +++++++++++++++++++++ 7 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 pyproject.toml create mode 100644 pytest.ini create mode 100644 src/poetry_slam/__init__.py create mode 100644 src/poetry_slam/build_tool.py create mode 100644 src/poetry_slam/cli.py create mode 100644 test/test_slam.py diff --git a/README.md b/README.md index 2d8d221..3c60601 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,76 @@ # poetry-slam -An opinionated build tool for python poetry projects + +An opinionated build tool for python poetry projects, poetry-slam saves me having to add boilerplate build scripts to every project for things like tests, coverage, package installation, automatic formatting, and so on. + +## Installation + +Clone the repository and install slam locally: + +```bash +% git clone https://github.com/evilchili/poetry-slam.git +% cd poetry-slam +% poetry build +% pip3 install dist/*.whl +``` + +## Basic Usage: + +Use `slam` to build your projects. Your package source must be in the `src/` directory, and your tests must be in `test/`. The most common usage, and the default, if no command is specified, is to do a `build`, +which formats your source, tests it, (re)installs the source packages to your virtual environment, and does a release build all in one step: + +```bash +% cd /some/poetry-project/ +% slam build +Formatting... +Testing... +Installing... +Building... +slam build: SUCCESS +``` + +You can also run individual steps; see `slam --help` for details. + +### Testing With Pytest + +Anything passed to `slam test` will be passed directly to pytest as command-line arguments. So for example: + +```bash +% slam test -vv -k test_this_one_thing +``` + + +### Debugging + +Get gory details with the combination of `--verbose` and `--log-level` most suitable to your liking: + +```bash +% slam --verbose --log-level=DEBUG build + +Formatting... +[03/25/24 22:21:32] INFO poetry run isort src test build_tool.py:29 + INFO poetry run autoflake src test build_tool.py:29 +[03/25/24 22:21:33] INFO poetry run black src test build_tool.py:29 +All done! ✨ 🍰 ✨ +4 files left unchanged. +Testing... + INFO poetry run pytest build_tool.py:29 +============================ test session starts ============================= +platform linux -- Python 3.10.12, pytest-8.1.1, pluggy-1.4.0 +rootdir: /home/greg/dev/poetry-slam +configfile: pytest.ini +plugins: cov-4.1.0 +collected 5 items + +test/test_slam.py ..... [100%] + +---------- coverage: platform linux, python 3.10.12-final-0 ---------- +Name Stmts Miss Cover Missing +------------------------------------------------------------- +src/poetry_slam/__init__.py 0 0 100% +src/poetry_slam/build_tool.py 51 5 90% 38-40, 44, 48 +src/poetry_slam/cli.py 37 37 0% 1-75 +------------------------------------------------------------- +TOTAL 88 42 52% + +# ...and so on... +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..491e694 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,48 @@ +[tool.poetry] +name = "poetry-slam" +version = "0.1.0" +description = "An opinionated build tool for python poetry projects" +authors = ["evilchili "] +readme = "README.md" +packages = [ + {include = "*", from = "src"}, +] + +[tool.poetry.dependencies] +python = "^3.10" +typer = "^0.9.0" +rich = "^13.7.0" +pytest = "^8.1.1" +black = "^23.3.0" +isort = "^5.12.0" +pyproject-autoflake = "^1.0.2" +pytest-cov = "^4.0.0" + +[tool.poetry.group.dev.dependencies] + +[tool.black] +line-length = 120 +target-version = ['py310'] + +[tool.isort] +multi_line_output = 3 +line_length = 120 +include_trailing_comma = true + +[tool.autoflake] +check = false # return error code if changes are needed +in-place = true # make changes to files instead of printing diffs +recursive = true # drill down directories recursively +remove-all-unused-imports = true # remove all unused imports (not just those from the standard library) +ignore-init-module-imports = true # exclude __init__.py when removing unused imports +remove-duplicate-keys = true # remove all duplicate keys in objects +remove-unused-variables = true # remove unused variables + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + + +[tool.poetry.scripts] +slam = "poetry_slam.cli:app" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c41351d --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +log_cli_level = DEBUG +addopts = --cov=src --cov-report=term-missing diff --git a/src/poetry_slam/__init__.py b/src/poetry_slam/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/poetry_slam/build_tool.py b/src/poetry_slam/build_tool.py new file mode 100644 index 0000000..ca70ada --- /dev/null +++ b/src/poetry_slam/build_tool.py @@ -0,0 +1,78 @@ +import logging +import subprocess +from dataclasses import dataclass +from pathlib import Path + +logger = logging.getLogger("slam.build_tool") + + +class BuildError(Exception): + """ + Thrown when a subprocess command fails. + """ + + +@dataclass +class BuildTool: + """ + Thin wrapper around poetry and some dev tools. + """ + + poetry: Path = Path("poetry") + verbose: bool = False + + def do(self, *command_line) -> bool: + """ + Execute a poetry subprocess. + """ + cmdline = [str(self.poetry)] + list(command_line) + logger.info(" ".join(cmdline)) + if self.verbose: + result = subprocess.run(cmdline) + return result.returncode + + result = subprocess.run(cmdline, capture_output=True) + logger.debug(f"{result = }") + if result.stdout: + # log the output and optional print it + logger.info(result.stdout) + if self.verbose: + print(result.stdout.decode("utf-8")) + if result.stderr: + # log the error and optionally print it + if self.verbose: + print(result.stderr.decode("utf-8")) + if result.returncode != 0: + logger.error(result.stderr) + raise BuildError(f"Command Failed: {cmdline}") + logger.info(result.stderr) + return result.returncode + + def run(self, *command_line): + """ + Same as do(), but prepend a 'run' subcommand. + """ + return self.do("run", *command_line) + + def install(self) -> bool: + return self.do("install") + + def auto_format(self) -> bool: + self.run("isort", "src", "test") + self.run("autoflake", "src", "test") + self.run("black", "src", "test") + return 0 + + def test(self, args) -> bool: + return self.run("pytest", *args) + + def build(self) -> bool: + print("Formatting...") + success = self.auto_format() + print("Testing...") + success += self.test([]) + print("Installing...") + success += self.install() + print("Building...") + success += self.do("build") + return success diff --git a/src/poetry_slam/cli.py b/src/poetry_slam/cli.py new file mode 100644 index 0000000..38af4b8 --- /dev/null +++ b/src/poetry_slam/cli.py @@ -0,0 +1,75 @@ +import logging +from pathlib import Path +from typing import Optional + +import typer +from rich.logging import RichHandler + +from poetry_slam.build_tool import BuildTool + +app = typer.Typer() +app_state = dict() + +logger = logging.getLogger("slam.cli") + + +@app.callback(invoke_without_command=True) +def main( + context: typer.Context, + verbose: bool = typer.Option(False, help="Enable verbose output."), + log_level: str = typer.Option("error", help=" Set the log level."), + poetry: Optional[Path] = typer.Option( + "poetry", + help="Path to the poetry executable; defaults to the first in your path.", + ), +): + logging.basicConfig( + format="%(message)s", + level=getattr(logging, log_level.upper()), + handlers=[RichHandler(rich_tracebacks=True, tracebacks_suppress=[typer])], + ) + app_state["build_tool"] = BuildTool(poetry=poetry, verbose=verbose) + if context.invoked_subcommand is None: + logger.debug("No command specified; defaulting to build.") + build() + + +@app.command() +def format(): + """ + Run isort, autoflake, and black on the src/ and test/ directories. + """ + returncode = app_state["build_tool"].auto_format() + print(f"slam format: {'SUCCESS' if returncode == 0 else 'ERROR'}") + return returncode + + +@app.command() +def build(): + """ + Calls format, test, and install before invoking 'poetry build'. + """ + returncode = app_state["build_tool"].build() + print(f"slam build: {'SUCCESS' if returncode == 0 else 'ERROR'}") + return returncode + + +@app.command() +def install(): + """ + Synonym for 'poetry install' + """ + returncode = app_state["build_tool"].install() + print(f"slam install: {'SUCCESS' if returncode == 0 else 'ERROR'}") + return returncode + + +@app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True}) +def test(context: typer.Context): + """ + Synonym for 'poetry run pytest' Output is always verbose. + """ + app_state["build_tool"].verbose = True + returncode = app_state["build_tool"].test(context.args) + print(f"slam test: {'SUCCESS' if returncode == 0 else 'ERROR'}") + return returncode diff --git a/test/test_slam.py b/test/test_slam.py new file mode 100644 index 0000000..657b196 --- /dev/null +++ b/test/test_slam.py @@ -0,0 +1,47 @@ +import subprocess +from dataclasses import dataclass +from unittest.mock import MagicMock + +import pytest + +from poetry_slam.build_tool import BuildError, BuildTool + + +def result_factory(out: bytes = b"", err: bytes = b"", code: int = 0): + @dataclass + class returnval: + stdout: bytes = out + stderr: bytes = err + returncode: int = code + + return MagicMock(spec=subprocess, **{"run.return_value": returnval()}) + + +@pytest.mark.parametrize( + "out, err, returncode, exception_type", + [ + (b"", b"", 0, None), + (b"", b"some error", 1, BuildError), + ], +) +def test_slam(monkeypatch, out, err, returncode, exception_type): + monkeypatch.setattr("poetry_slam.build_tool.subprocess", result_factory(out, err, returncode)) + try: + assert BuildTool().build() == returncode + except exception_type: + pass + + +def test_format(monkeypatch): + monkeypatch.setattr("poetry_slam.build_tool.subprocess", result_factory()) + assert BuildTool().auto_format() == 0 + + +def test_install(monkeypatch): + monkeypatch.setattr("poetry_slam.build_tool.subprocess", result_factory()) + assert BuildTool().install() == 0 + + +def test_test(monkeypatch): + monkeypatch.setattr("poetry_slam.build_tool.subprocess", result_factory(out=b"out", err=b"err", code=0)) + assert BuildTool(verbose=True).test([]) == 0