initial import

This commit is contained in:
evilchili 2024-03-25 22:24:31 -07:00
parent 76ba857223
commit 76e0b16aef
7 changed files with 326 additions and 1 deletions

View File

@ -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...
```

48
pyproject.toml Normal file
View File

@ -0,0 +1,48 @@
[tool.poetry]
name = "poetry-slam"
version = "0.1.0"
description = "An opinionated build tool for python poetry projects"
authors = ["evilchili <evilchili@gmail.com>"]
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"

3
pytest.ini Normal file
View File

@ -0,0 +1,3 @@
[pytest]
log_cli_level = DEBUG
addopts = --cov=src --cov-report=term-missing

View File

View File

@ -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

75
src/poetry_slam/cli.py Normal file
View File

@ -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

47
test/test_slam.py Normal file
View File

@ -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