initial import
This commit is contained in:
parent
76ba857223
commit
76e0b16aef
76
README.md
76
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...
|
||||
```
|
||||
|
|
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