Add build hook system

Hook scripts in .slam/hooks/ are executed at each stage of the build
pipeline. If a script exists and is executable, it runs; a non-zero
exit stops the build.

Available hooks:
  pre_format / post_format
  pre_install / post_install
  pre_test / post_test
  pre_build / post_build

The init command now creates .slam/hooks/ with a README documenting
usage and examples, and includes a default pre_format hook that
initializes any git submodules that are present.
This commit is contained in:
gsb 2026-04-28 23:57:08 +00:00
parent 70e0a5db42
commit 1b4b4b51a5
4 changed files with 165 additions and 1 deletions

View File

@ -10,7 +10,7 @@ packages = [
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.10" python = "^3.10"
typer = "^0.9.0" typer = ">=0.9.0"
rich = "^13.7.0" rich = "^13.7.0"
pytest = "^8.1.1" pytest = "^8.1.1"
black = "^23.3.0" black = "^23.3.0"

View File

@ -22,6 +22,13 @@ class BuildTool:
poetry: Path = Path("poetry") poetry: Path = Path("poetry")
verbose: bool = False verbose: bool = False
@property
def hooks_dir(self) -> Path:
"""
The directory where hook scripts are stored.
"""
return self.project_root / ".slam" / "hooks"
def _exec(self, *command_line) -> bool: def _exec(self, *command_line) -> bool:
""" """
Execute a subprocess. Execute a subprocess.
@ -49,6 +56,29 @@ class BuildTool:
logger.info(result.stderr) logger.info(result.stderr)
return result.returncode return result.returncode
def run_hook(self, stage: str) -> None:
"""
Run the hook script for the given stage, if it exists.
Hook scripts are executable files in .slam/hooks/ named after
build stages: pre_format, post_format, pre_install, post_install,
pre_test, post_test, pre_build, post_build.
A non-zero exit code stops the build.
"""
hook = self.hooks_dir / stage
if not hook.exists():
return
logger.info(f"Running hook: {hook}")
try:
returncode = self._exec(str(hook))
except BuildError:
raise
except Exception as e:
raise BuildError(f"Hook failed: {hook}: {e}")
if returncode != 0:
raise BuildError(f"Hook failed: {hook}")
def run_with_poetry(self, *command_line): def run_with_poetry(self, *command_line):
return self._exec(str(self.poetry), *command_line) return self._exec(str(self.poetry), *command_line)
@ -77,12 +107,24 @@ class BuildTool:
return returncode return returncode
def build(self) -> bool: def build(self) -> bool:
self.run_hook("pre_format")
print("Formatting...") print("Formatting...")
success = self.auto_format() success = self.auto_format()
self.run_hook("post_format")
self.run_hook("pre_install")
print("Installing...") print("Installing...")
success += self.install() success += self.install()
self.run_hook("post_install")
self.run_hook("pre_test")
print("Testing...") print("Testing...")
success += self.test([]) success += self.test([])
self.run_hook("post_test")
self.run_hook("pre_build")
print("Building...") print("Building...")
success += self.run_with_poetry("build") success += self.run_with_poetry("build")
self.run_hook("post_build")
return success return success

View File

@ -10,6 +10,32 @@ from rich.logging import RichHandler
from poetry_slam.build_tool import BuildTool from poetry_slam.build_tool import BuildTool
from poetry_slam.templates import TEMPLATE_ROOT, templates from poetry_slam.templates import TEMPLATE_ROOT, templates
HOOKS_README = """\
Place executable scripts here named after build stages:
pre_format post_format
pre_install post_install
pre_test post_test
pre_build post_build
Scripts must be executable (chmod +x). A non-zero exit stops the build.
Example .slam/hooks/pre_build:
#!/bin/sh
set -e
cd lib/my-js-lib && npm install && npm run build
cp lib/my-js-lib/dist/*.js src/myapp/static/
"""
DEFAULT_PRE_FORMAT_HOOK = """\
#!/bin/sh
# Initialize git submodules if .gitmodules exists.
if [ -f .gitmodules ]; then
git submodule update --init --recursive
fi
"""
app = typer.Typer() app = typer.Typer()
app_state = dict() app_state = dict()
@ -64,6 +90,16 @@ def init():
logging.debug(f"Renamed {new} to {target}") logging.debug(f"Renamed {new} to {target}")
print(f"Added poetry-slam defaults to {target}") print(f"Added poetry-slam defaults to {target}")
hooks_dir = app_state["build_tool"].hooks_dir
if not hooks_dir.exists():
hooks_dir.mkdir(parents=True)
readme = hooks_dir / "README"
readme.write_text(HOOKS_README)
pre_format = hooks_dir / "pre_format"
pre_format.write_text(DEFAULT_PRE_FORMAT_HOOK)
pre_format.chmod(0o755)
print(f"Created {hooks_dir}/ with README and default pre_format hook.")
logging.debug("Adding test dependencies to dev group.") logging.debug("Adding test dependencies to dev group.")
app_state["build_tool"].run_with_poetry("add", "-G", "dev", "pytest", "pytest-cov") app_state["build_tool"].run_with_poetry("add", "-G", "dev", "pytest", "pytest-cov")

View File

@ -45,3 +45,89 @@ def test_install(monkeypatch):
def test_test(monkeypatch): def test_test(monkeypatch):
monkeypatch.setattr("poetry_slam.build_tool.subprocess", result_factory(out=b"out", err=b"err", code=0)) monkeypatch.setattr("poetry_slam.build_tool.subprocess", result_factory(out=b"out", err=b"err", code=0))
assert BuildTool(verbose=True).test([]) == 0 assert BuildTool(verbose=True).test([]) == 0
def test_run_hook_executes_script(monkeypatch, tmp_path):
"""Hook script exists and is executable — it should be run."""
hooks_dir = tmp_path / ".slam" / "hooks"
hooks_dir.mkdir(parents=True)
hook = hooks_dir / "pre_build"
hook.write_text("#!/bin/sh\nexit 0\n")
hook.chmod(0o755)
mock_sub = result_factory()
monkeypatch.setattr("poetry_slam.build_tool.subprocess", mock_sub)
bt = BuildTool(project_root=tmp_path)
bt.run_hook("pre_build")
mock_sub.run.assert_called_once()
def test_run_hook_skips_missing(monkeypatch, tmp_path):
"""No hook script — run_hook should do nothing."""
mock_sub = result_factory()
monkeypatch.setattr("poetry_slam.build_tool.subprocess", mock_sub)
bt = BuildTool(project_root=tmp_path)
bt.run_hook("pre_build")
mock_sub.run.assert_not_called()
def test_run_hook_non_executable_raises(monkeypatch, tmp_path):
"""Hook script exists but is not executable — should raise BuildError."""
hooks_dir = tmp_path / ".slam" / "hooks"
hooks_dir.mkdir(parents=True)
hook = hooks_dir / "pre_build"
hook.write_text("#!/bin/sh\nexit 0\n")
hook.chmod(0o644)
monkeypatch.setattr("poetry_slam.build_tool.subprocess", result_factory(err=b"permission denied", code=126))
bt = BuildTool(project_root=tmp_path)
with pytest.raises(BuildError):
bt.run_hook("pre_build")
def test_run_hook_raises_on_failure(monkeypatch, tmp_path):
"""Hook script fails — should raise BuildError."""
hooks_dir = tmp_path / ".slam" / "hooks"
hooks_dir.mkdir(parents=True)
hook = hooks_dir / "pre_build"
hook.write_text("#!/bin/sh\nexit 1\n")
hook.chmod(0o755)
monkeypatch.setattr("poetry_slam.build_tool.subprocess", result_factory(err=b"hook failed", code=1))
bt = BuildTool(project_root=tmp_path)
with pytest.raises(BuildError):
bt.run_hook("pre_build")
def test_build_runs_hooks(monkeypatch, tmp_path):
"""Build pipeline should call run_hook for each stage."""
mock_sub = result_factory()
monkeypatch.setattr("poetry_slam.build_tool.subprocess", mock_sub)
bt = BuildTool(project_root=tmp_path)
hook_calls = []
original_run_hook = bt.run_hook
def tracking_run_hook(stage):
hook_calls.append(stage)
original_run_hook(stage)
bt.run_hook = tracking_run_hook
bt.build()
assert hook_calls == [
"pre_format",
"post_format",
"pre_install",
"post_install",
"pre_test",
"post_test",
"pre_build",
"post_build",
]
def test_hooks_dir_property(tmp_path):
"""hooks_dir should point to .slam/hooks/ under project_root."""
bt = BuildTool(project_root=tmp_path)
assert bt.hooks_dir == tmp_path / ".slam" / "hooks"