diff --git a/pyproject.toml b/pyproject.toml index 2f5d2c1..fb0e1bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/poetry_slam/build_tool.py b/src/poetry_slam/build_tool.py index efb9614..edaf5d8 100644 --- a/src/poetry_slam/build_tool.py +++ b/src/poetry_slam/build_tool.py @@ -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 diff --git a/src/poetry_slam/cli.py b/src/poetry_slam/cli.py index 538c446..b4cb3b7 100644 --- a/src/poetry_slam/cli.py +++ b/src/poetry_slam/cli.py @@ -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") diff --git a/test/test_slam.py b/test/test_slam.py index 657b196..eac8f4d 100644 --- a/test/test_slam.py +++ b/test/test_slam.py @@ -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"