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:
parent
70e0a5db42
commit
1b4b4b51a5
|
|
@ -10,7 +10,7 @@ packages = [
|
|||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10"
|
||||
typer = "^0.9.0"
|
||||
typer = ">=0.9.0"
|
||||
rich = "^13.7.0"
|
||||
pytest = "^8.1.1"
|
||||
black = "^23.3.0"
|
||||
|
|
|
|||
|
|
@ -22,6 +22,13 @@ class BuildTool:
|
|||
poetry: Path = Path("poetry")
|
||||
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:
|
||||
"""
|
||||
Execute a subprocess.
|
||||
|
|
@ -49,6 +56,29 @@ class BuildTool:
|
|||
logger.info(result.stderr)
|
||||
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):
|
||||
return self._exec(str(self.poetry), *command_line)
|
||||
|
||||
|
|
@ -77,12 +107,24 @@ class BuildTool:
|
|||
return returncode
|
||||
|
||||
def build(self) -> bool:
|
||||
self.run_hook("pre_format")
|
||||
print("Formatting...")
|
||||
success = self.auto_format()
|
||||
self.run_hook("post_format")
|
||||
|
||||
self.run_hook("pre_install")
|
||||
print("Installing...")
|
||||
success += self.install()
|
||||
self.run_hook("post_install")
|
||||
|
||||
self.run_hook("pre_test")
|
||||
print("Testing...")
|
||||
success += self.test([])
|
||||
self.run_hook("post_test")
|
||||
|
||||
self.run_hook("pre_build")
|
||||
print("Building...")
|
||||
success += self.run_with_poetry("build")
|
||||
self.run_hook("post_build")
|
||||
|
||||
return success
|
||||
|
|
|
|||
|
|
@ -10,6 +10,32 @@ from rich.logging import RichHandler
|
|||
from poetry_slam.build_tool import BuildTool
|
||||
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_state = dict()
|
||||
|
||||
|
|
@ -64,6 +90,16 @@ def init():
|
|||
logging.debug(f"Renamed {new} 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.")
|
||||
app_state["build_tool"].run_with_poetry("add", "-G", "dev", "pytest", "pytest-cov")
|
||||
|
||||
|
|
|
|||
|
|
@ -45,3 +45,89 @@ def test_install(monkeypatch):
|
|||
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
|
||||
|
||||
|
||||
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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user