initial import
This commit is contained in:
parent
76ba857223
commit
76e0b16aef
76
README.md
76
README.md
|
@ -1,2 +1,76 @@
|
||||||
# poetry-slam
|
# 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
48
pyproject.toml
Normal 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
3
pytest.ini
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
[pytest]
|
||||||
|
log_cli_level = DEBUG
|
||||||
|
addopts = --cov=src --cov-report=term-missing
|
0
src/poetry_slam/__init__.py
Normal file
0
src/poetry_slam/__init__.py
Normal file
78
src/poetry_slam/build_tool.py
Normal file
78
src/poetry_slam/build_tool.py
Normal 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
75
src/poetry_slam/cli.py
Normal 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
47
test/test_slam.py
Normal 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
|
Loading…
Reference in New Issue
Block a user