diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 4f90347..7adef4d 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -12,6 +12,7 @@ nav: - 'Building a Recipe': guides/building-a-recipe.md - 'Working with Namespaces': guides/namespaces.md - 'Platform Integration': guides/platform-integration.md + - 'Hooks': guides/hooks.md - 'Field Providers': guides/field-providers.md - 'Architecture': - architecture/index.md diff --git a/docs/source/guides/hooks.md b/docs/source/guides/hooks.md new file mode 100644 index 0000000..a508219 --- /dev/null +++ b/docs/source/guides/hooks.md @@ -0,0 +1,188 @@ +# Hooks + +Hooks are pre/post-processing steps that run during recipe creation. They execute before template rendering (pre-hooks) or after file writing (post-hooks), and can modify the output path, template context, or even the recipe's contents. + +## How Hooks Work + +A hook is a Pydantic model that implements the `call()` method: + +```python +from pathlib import Path +from typing import Any, Optional + +from nskit.mixer.components.hook import Hook + + +class MyHook(Hook): + """A custom hook.""" + + def call(self, recipe_path: Path, context: dict[str, Any], **kwargs) -> Optional[tuple[Path, dict]]: + # Do something + return (recipe_path, context) # or None to keep unchanged +``` + +When `Recipe.create()` runs, it calls each hook in sequence: + +``` +pre_hooks → template rendering → post_hooks +``` + +Each hook receives: + +- `recipe_path` — where the recipe will be (pre) or was (post) written +- `context` — the template rendering context +- `recipe` (via kwargs) — the Recipe instance itself + +Return `None` to keep path/context unchanged, or `(recipe_path, context)` to modify them. + +## The `recipe` Kwarg + +Hooks receive the recipe instance as `recipe=self` when called from `Recipe.create()`. This enables hooks to introspect or mutate the recipe — for example, adding files to `contents` before rendering. + +```python +class InjectFileHook(Hook): + """Add a file to the recipe before rendering.""" + + filename: str = "GENERATED.md" + content: str = "# Auto-generated\n" + + def call(self, recipe_path: Path, context: dict[str, Any], recipe=None): + if recipe is not None: + from nskit.mixer.components.file import File + recipe.contents.append( + File(id_="generated", name=self.filename, content=self.content) + ) + return (recipe_path, context) +``` + +Use this in a pre-hook to dynamically add or modify recipe contents based on runtime conditions. + +## Backwards Compatibility + +Existing hooks that only accept `(recipe_path, context)` continue to work. The `Hook.__call__` method inspects the `call()` signature and only forwards kwargs that the method accepts: + +```python +# Old-style — still works, recipe kwarg is silently dropped +class LegacyHook(Hook): + def call(self, recipe_path, context): + return None + +# New-style — receives the recipe instance +class ModernHook(Hook): + def call(self, recipe_path: Path, context: dict[str, Any], **kwargs): + recipe = kwargs.get("recipe") + return None + +# Explicit param — also works, only 'recipe' is forwarded +class ExplicitHook(Hook): + def call(self, recipe_path: Path, context: dict[str, Any], recipe=None): + return None +``` + +All three styles can coexist in the same recipe's hook list. + +## Built-in Hooks + +### GitInit + +Initialises a git repository in the generated project directory. + +```python +from nskit.mixer.hooks.git import GitInit + +class MyRecipe(Recipe): + post_hooks = [GitInit()] +``` + +Respects `context["git"]["initial_branch_name"]` for the initial branch (defaults to `main`). Handles git versions before and after 2.28.0 (which introduced `--initial-branch`). + +### PrecommitInstall + +Installs pre-commit hooks if a `.pre-commit-config.yaml` exists. + +```python +from nskit.mixer.hooks.pre_commit import PrecommitInstall + +class MyRecipe(Recipe): + post_hooks = [GitInit(), PrecommitInstall()] +``` + +Tries `pip install pre-commit` first, falls back to `uv pip install pre-commit`. Skips gracefully if neither is available. + +### CleanupHook + +Removes empty files and/or empty directories after rendering. Useful when conditional templates render to nothing. + +```python +from nskit.mixer.hooks.cleanup import CleanupHook + +class MyRecipe(Recipe): + post_hooks = [CleanupHook()] +``` + +Configuration options: + +| Field | Default | Description | +|-------|---------|-------------| +| `remove_empty_files` | `True` | Remove 0-byte and whitespace-only files | +| `remove_empty_dirs` | `True` | Remove empty directories | +| `skip_gitkeep` | `True` | Preserve empty `.gitkeep` files | + +Individual hooks are also available: + +```python +from nskit.mixer.hooks.cleanup import RemoveEmptyFilesHook, RemoveEmptyDirectoriesHook +``` + +## Writing a Custom Hook + +Hooks are Pydantic models, so you can declare configurable fields: + +```python +class LoggingHook(Hook): + """Post-hook that logs the generated file tree.""" + + log_file: str = "generation.log" + + def call(self, recipe_path: Path, context: dict[str, Any], **kwargs): + files = list(recipe_path.rglob("*")) + log_path = recipe_path / self.log_file + log_path.write_text("\n".join(str(f.relative_to(recipe_path)) for f in files)) + return None +``` + +```python +class MyRecipe(Recipe): + post_hooks = [LoggingHook(log_file="manifest.txt")] +``` + +## Pre-hook vs Post-hook + +| Aspect | Pre-hook | Post-hook | +|--------|----------|-----------| +| Runs | Before template rendering | After files are written | +| `recipe_path` | Target directory (may not exist yet) | Actual written directory | +| Can modify contents | Yes (via `recipe.contents`) | No (files already written) | +| Use cases | Context injection, path rewriting, conditional content | Git init, pre-commit, cleanup, validation | + +## Ordering + +Hooks execute in list order. Each hook sees the result of the previous one: + +```python +class MyRecipe(Recipe): + pre_hooks = [ContextSetupHook(), ContentInjectionHook()] + post_hooks = [CleanupHook(), GitInit(), PrecommitInstall()] +``` + +## Using in CodeRecipe + +The `CodeRecipe` base class (for git-tracked code repos) defaults to `post_hooks=[GitInit()]`: + +```python +from nskit.mixer.repo import CodeRecipe + +class MyCodeRecipe(CodeRecipe): + # GitInit is already included. Add more: + post_hooks = [GitInit(), CleanupHook(), PrecommitInstall()] +``` diff --git a/src/nskit/mixer/components/hook.py b/src/nskit/mixer/components/hook.py index 3cf9cee..d15d3d0 100644 --- a/src/nskit/mixer/components/hook.py +++ b/src/nskit/mixer/components/hook.py @@ -1,5 +1,6 @@ """Hook component.""" +import inspect from abc import ABC, abstractmethod from pathlib import Path from typing import Any, Optional @@ -8,16 +9,52 @@ class Hook(ABC, BaseModel): - """Hook component.""" + """Hook component. + + Hooks receive the recipe path and context, and optionally the recipe + instance itself. The ``recipe`` kwarg enables pre-write hooks to mutate + the recipe's ``contents`` before rendering. + + Backwards-compatible: existing hooks that define + ``call(self, recipe_path, context)`` without **kwargs continue to work — + the recipe kwarg is only forwarded if the hook's ``call`` signature accepts it. + """ @abstractmethod - def call(self, recipe_path: Path, context: dict[str, Any]) -> Optional[tuple[str, Path, dict]]: - """Return None or tuple (recipe_path, context).""" + def call(self, recipe_path: Path, context: dict[str, Any], **kwargs) -> Optional[tuple[Path, dict]]: + """Execute the hook logic. + + Args: + recipe_path: Path where the recipe will be (pre) or was (post) written. + context: Template rendering context (all recipe fields + properties). + **kwargs: Additional keyword arguments. Currently passes ``recipe`` + (the Recipe instance) when called from ``Recipe.create()``. + Hooks that don't need the recipe can ignore it via **kwargs. + + Returns: + None to keep path/context unchanged, or ``(recipe_path, context)`` tuple. + """ raise NotImplementedError() - def __call__(self, recipe_path: Path, context: dict[str, Any]) -> tuple[str, Path, dict]: - """Call the hook and return tuple (recipe_path, context).""" - hook_result = self.call(recipe_path, context) + def __call__(self, recipe_path: Path, context: dict[str, Any], **kwargs) -> tuple[Path, dict]: + """Call the hook and return tuple (recipe_path, context). + + Inspects the ``call`` method signature to determine whether to forward + kwargs (like ``recipe``). This ensures backwards compatibility with + existing hooks that only accept ``(recipe_path, context)``. + """ + sig = inspect.signature(self.call) + params = sig.parameters + + # Forward kwargs only if call() accepts **kwargs or explicitly declares the kwarg names + accepts_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()) + if accepts_kwargs: + hook_result = self.call(recipe_path, context, **kwargs) + else: + # Check which specific kwargs the method accepts + forward = {k: v for k, v in kwargs.items() if k in params} + hook_result = self.call(recipe_path, context, **forward) + if hook_result: recipe_path, context = hook_result return recipe_path, context diff --git a/src/nskit/mixer/components/recipe.py b/src/nskit/mixer/components/recipe.py index 0ba73fc..b59d842 100644 --- a/src/nskit/mixer/components/recipe.py +++ b/src/nskit/mixer/components/recipe.py @@ -143,13 +143,13 @@ def create(self, base_path: Optional[Path] = None, override_path: Optional[Path] context.update(additional_context) recipe_path = self.get_path(base_path, context, override_path=override_path) for hook in self.pre_hooks: - recipe_path, context = hook(recipe_path, context) + recipe_path, context = hook(recipe_path, context, recipe=self) content = self.write(recipe_path.parent, context, override_path=recipe_path.name) - recipe_path = list(content.keys())[0] + recipe_path = next(iter(content.keys())) for hook in self.post_hooks: - recipe_path, context = hook(recipe_path, context) + recipe_path, context = hook(recipe_path, context, recipe=self) self._write_batch(Path(recipe_path)) - return {Path(recipe_path): list(content.values())[0]} + return {Path(recipe_path): next(iter(content.values()))} def _write_batch(self, folder_path: Path): """Write out the parameters used. diff --git a/tests/functional/test_hooks_recipe_integration.py b/tests/functional/test_hooks_recipe_integration.py new file mode 100644 index 0000000..58f4a24 --- /dev/null +++ b/tests/functional/test_hooks_recipe_integration.py @@ -0,0 +1,370 @@ +"""Functional tests for hooks integration with Recipe.create(). + +Verifies that Recipe.create() passes the recipe instance to hooks via the +``recipe`` kwarg, and that hooks can use it to mutate recipe state (e.g. +contents) before/after rendering. +""" + +from __future__ import annotations + +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Any, Optional, Union + +from nskit.common.contextmanagers import ChDir +from nskit.mixer.components.file import File +from nskit.mixer.components.folder import Folder +from nskit.mixer.components.hook import Hook +from nskit.mixer.components.recipe import Recipe + +# --------------------------------------------------------------------------- +# Module-level recording lists (avoid Pydantic model attribute issues) +# --------------------------------------------------------------------------- + +_recording_calls: list = [] +_introspection_names: list = [] +_introspection_classes: list = [] +_old_style_called: list = [] + + +# --------------------------------------------------------------------------- +# Test hooks +# --------------------------------------------------------------------------- + + +class RecordingHook(Hook): + """Hook that records what it was called with.""" + + def call(self, recipe_path: Path, context: dict[str, Any], **kwargs): + _recording_calls.append( + { + "recipe_path": recipe_path, + "context": context, + "kwargs": kwargs, + } + ) + return None + + +class ContextInjectingPreHook(Hook): + """Pre-hook that injects a value into context.""" + + inject_key: str = "hook_injected" + inject_value: str = "from_hook" + + def call(self, recipe_path: Path, context: dict[str, Any], recipe=None): + context[self.inject_key] = self.inject_value + return (recipe_path, context) + + +class PathModifyingPreHook(Hook): + """Pre-hook that modifies the recipe path.""" + + suffix: str = "-modified" + + def call(self, recipe_path: Path, context: dict[str, Any], **kwargs): + new_path = recipe_path.parent / (recipe_path.name + self.suffix) + return (new_path, context) + + +class RecipeIntrospectingHook(Hook): + """Hook that inspects the recipe instance.""" + + def call(self, recipe_path: Path, context: dict[str, Any], recipe=None): + if recipe is not None: + _introspection_names.append(recipe.name) + _introspection_classes.append(type(recipe).__name__) + return None + + +class ContentMutatingPreHook(Hook): + """Pre-hook that adds a file to the recipe's contents before rendering.""" + + filename: str = "INJECTED.md" + content: str = "# Injected by hook\n" + + def call(self, recipe_path: Path, context: dict[str, Any], recipe=None): + if recipe is not None: + recipe.contents.append(File(id_="injected", name=self.filename, content=self.content)) + return (recipe_path, context) + + +class OldStylePostHook(Hook): + """Old-style hook that only accepts (recipe_path, context) — no kwargs.""" + + def call(self, recipe_path, context): + _old_style_called.append(True) + return None + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestRecipePassesRecipeToPreHooks(unittest.TestCase): + """Recipe.create() passes recipe=self to pre_hooks.""" + + def setUp(self): + _recording_calls.clear() + + def test_pre_hook_receives_recipe_kwarg(self): + """Pre-hooks receive the recipe instance via kwargs.""" + hook = RecordingHook() + + recipe = Recipe( + name="test-project", + pre_hooks=[hook], + contents=[File(id_="readme", name="README.md", content="# Test\n")], + ) + + with ChDir(): + recipe.create(Path.cwd()) + + self.assertEqual(len(_recording_calls), 1) + call = _recording_calls[0] + self.assertIn("recipe", call["kwargs"]) + self.assertIs(call["kwargs"]["recipe"], recipe) + + +class TestRecipePassesRecipeToPostHooks(unittest.TestCase): + """Recipe.create() passes recipe=self to post_hooks.""" + + def setUp(self): + _recording_calls.clear() + + def test_post_hook_receives_recipe_kwarg(self): + """Post-hooks receive the recipe instance via kwargs.""" + hook = RecordingHook() + + recipe = Recipe( + name="test-project", + post_hooks=[hook], + contents=[File(id_="readme", name="README.md", content="# Test\n")], + ) + + with ChDir(): + recipe.create(Path.cwd()) + + self.assertEqual(len(_recording_calls), 1) + call = _recording_calls[0] + self.assertIn("recipe", call["kwargs"]) + self.assertIs(call["kwargs"]["recipe"], recipe) + + +class TestPreHookContextInjection(unittest.TestCase): + """Pre-hooks can inject values into the template context.""" + + def test_injected_context_available_in_template(self): + """Values injected by pre-hooks are available during template rendering.""" + hook = ContextInjectingPreHook(inject_key="greeting", inject_value="hello") + + recipe = Recipe( + name="ctx-test", + pre_hooks=[hook], + contents=[File(id_="out", name="output.txt", content="{{greeting}}")], + ) + + with ChDir(): + recipe.create(Path.cwd()) + content = Path("ctx-test/output.txt").read_text() + self.assertEqual(content, "hello") + + +class TestPreHookPathModification(unittest.TestCase): + """Pre-hooks can modify the output path.""" + + def test_path_modified_by_pre_hook(self): + """Pre-hook can change where the recipe is written.""" + hook = PathModifyingPreHook(suffix="-custom") + + recipe = Recipe( + name="original", + pre_hooks=[hook], + contents=[File(id_="readme", name="README.md", content="# Modified path\n")], + ) + + with ChDir(): + result = recipe.create(Path.cwd()) + result_path = list(result.keys())[0] + self.assertTrue(str(result_path).endswith("original-custom")) + + +class TestPreHookMutatesRecipeContents(unittest.TestCase): + """Pre-hooks with recipe access can mutate contents before rendering.""" + + def test_file_added_by_pre_hook_is_rendered(self): + """A file added to recipe.contents by a pre-hook gets written.""" + hook = ContentMutatingPreHook(filename="ADDED.md", content="# Added\n") + + recipe = Recipe( + name="mutate-test", + pre_hooks=[hook], + contents=[File(id_="readme", name="README.md", content="# Original\n")], + ) + + with ChDir(): + recipe.create(Path.cwd()) + # Original file exists + self.assertTrue(Path("mutate-test/README.md").exists()) + # Injected file also exists + self.assertTrue(Path("mutate-test/ADDED.md").exists()) + self.assertEqual(Path("mutate-test/ADDED.md").read_text(), "# Added\n") + + +class TestRecipeIntrospection(unittest.TestCase): + """Hooks can introspect the recipe instance.""" + + def setUp(self): + _introspection_names.clear() + _introspection_classes.clear() + + def test_hook_sees_recipe_name(self): + """Hook can read the recipe's name field.""" + hook = RecipeIntrospectingHook() + + recipe = Recipe( + name="my-project", + post_hooks=[hook], + contents=[File(id_="f", name="f.txt", content="")], + ) + + with ChDir(): + recipe.create(Path.cwd()) + + self.assertEqual(_introspection_names, ["my-project"]) + self.assertEqual(_introspection_classes, ["Recipe"]) + + +class TestBackwardsCompatibility(unittest.TestCase): + """Old-style hooks without kwargs still work in Recipe.create().""" + + def setUp(self): + _old_style_called.clear() + + def test_old_style_hook_works_in_recipe_create(self): + """Old-style hook (no **kwargs) still works when recipe passes recipe kwarg.""" + hook = OldStylePostHook() + + recipe = Recipe( + name="compat-test", + post_hooks=[hook], + contents=[File(id_="f", name="f.txt", content="test")], + ) + + with ChDir(): + recipe.create(Path.cwd()) + + self.assertEqual(len(_old_style_called), 1) + + +class TestMultipleHooksOrdering(unittest.TestCase): + """Multiple hooks execute in order.""" + + def test_pre_hooks_execute_in_order(self): + """Multiple pre-hooks execute sequentially and each sees the previous result.""" + execution_order = [] + + class OrderedHook(Hook): + index: int = 0 + + def call(self, recipe_path: Path, context: dict[str, Any], **kwargs): + execution_order.append(self.index) + context[f"hook_{self.index}"] = True + return (recipe_path, context) + + hooks = [OrderedHook(index=1), OrderedHook(index=2), OrderedHook(index=3)] + + recipe = Recipe( + name="order-test", + pre_hooks=hooks, + contents=[File(id_="f", name="f.txt", content="")], + ) + + with ChDir(): + recipe.create(Path.cwd()) + + self.assertEqual(execution_order, [1, 2, 3]) + + def test_post_hooks_execute_in_order(self): + """Multiple post-hooks execute sequentially.""" + execution_order = [] + + class OrderedHook(Hook): + index: int = 0 + + def call(self, recipe_path: Path, context: dict[str, Any], **kwargs): + execution_order.append(self.index) + return None + + hooks = [OrderedHook(index=10), OrderedHook(index=20)] + + recipe = Recipe( + name="order-test", + post_hooks=hooks, + contents=[File(id_="f", name="f.txt", content="")], + ) + + with ChDir(): + recipe.create(Path.cwd()) + + self.assertEqual(execution_order, [10, 20]) + + +class TestMixedHookStyles(unittest.TestCase): + """Mixing old-style and new-style hooks in the same recipe.""" + + def test_mixed_pre_hooks(self): + """Old-style and new-style hooks can coexist in pre_hooks.""" + results = [] + + class OldHook(Hook): + def call(self, recipe_path, context): + results.append("old") + return None + + class NewHook(Hook): + def call(self, recipe_path: Path, context: dict[str, Any], **kwargs): + results.append(f"new:recipe={'recipe' in kwargs}") + return None + + recipe = Recipe( + name="mixed", + pre_hooks=[OldHook(), NewHook()], + contents=[File(id_="f", name="f.txt", content="")], + ) + + with ChDir(): + recipe.create(Path.cwd()) + + self.assertEqual(results, ["old", "new:recipe=True"]) + + def test_mixed_post_hooks(self): + """Old-style and new-style hooks can coexist in post_hooks.""" + results = [] + + class OldHook(Hook): + def call(self, recipe_path, context): + results.append("old") + return None + + class NewHook(Hook): + def call(self, recipe_path: Path, context: dict[str, Any], recipe=None): + results.append(f"new:recipe={recipe is not None}") + return None + + recipe = Recipe( + name="mixed", + post_hooks=[OldHook(), NewHook()], + contents=[File(id_="f", name="f.txt", content="")], + ) + + with ChDir(): + recipe.create(Path.cwd()) + + self.assertEqual(results, ["old", "new:recipe=True"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/integration/test_hooks_builtin.py b/tests/integration/test_hooks_builtin.py new file mode 100644 index 0000000..b38d580 --- /dev/null +++ b/tests/integration/test_hooks_builtin.py @@ -0,0 +1,235 @@ +"""Integration tests for built-in hooks with kwargs forwarding. + +Verifies that all shipped hooks (GitInit, PrecommitInstall, CleanupHook) +continue to work correctly when called with the recipe kwarg, as happens +in Recipe.create(). +""" + +from __future__ import annotations + +import subprocess +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Any + +from nskit.mixer.components.file import File +from nskit.mixer.components.hook import Hook +from nskit.mixer.components.recipe import Recipe +from nskit.mixer.hooks.cleanup import CleanupHook, RemoveEmptyDirectoriesHook, RemoveEmptyFilesHook +from nskit.mixer.hooks.git import GitInit + + +class TestGitInitWithRecipeKwarg(unittest.TestCase): + """GitInit hook works when called with recipe= kwarg.""" + + def test_git_init_called_with_recipe_kwarg(self): + """GitInit still works when __call__ forwards recipe kwarg.""" + hook = GitInit() + with TemporaryDirectory() as tmp: + path = Path(tmp) / "project" + path.mkdir() + + # Call with recipe kwarg (as Recipe.create() now does) + result_path, result_ctx = hook(path, {}, recipe="mock_recipe") + + self.assertEqual(result_path, path) + # Git should be initialised + self.assertTrue((path / ".git").exists()) + + def test_git_init_respects_context_branch_name(self): + """GitInit uses git.initial_branch_name from context when provided.""" + hook = GitInit() + with TemporaryDirectory() as tmp: + path = Path(tmp) / "project" + path.mkdir() + + context = {"git": {"initial_branch_name": "develop"}} + hook(path, context, recipe="mock_recipe") + + # Check the current branch name + result = subprocess.run( + ["git", "branch", "--show-current"], + cwd=path, + capture_output=True, + text=True, + ) + self.assertEqual(result.stdout.strip(), "develop") + + def test_git_init_default_branch_main(self): + """GitInit defaults to 'main' branch.""" + hook = GitInit() + with TemporaryDirectory() as tmp: + path = Path(tmp) / "project" + path.mkdir() + + hook(path, {}, recipe=None) + + result = subprocess.run( + ["git", "branch", "--show-current"], + cwd=path, + capture_output=True, + text=True, + ) + # Should be 'main' or whatever the system default is + branch = result.stdout.strip() + self.assertTrue(len(branch) > 0) + + def test_git_init_rejects_malicious_branch_name(self): + """GitInit sanitises branch names that start with '-'.""" + hook = GitInit() + with TemporaryDirectory() as tmp: + path = Path(tmp) / "project" + path.mkdir() + + # Attempt injection via branch name + context = {"git": {"initial_branch_name": "--exec=malicious"}} + hook(path, context) + + # Should fall back to 'main' + result = subprocess.run( + ["git", "branch", "--show-current"], + cwd=path, + capture_output=True, + text=True, + ) + self.assertEqual(result.stdout.strip(), "main") + + +class TestCleanupHooksWithRecipeKwarg(unittest.TestCase): + """Cleanup hooks work when called with recipe= kwarg.""" + + def test_remove_empty_files_with_recipe_kwarg(self): + """RemoveEmptyFilesHook works when called via __call__ with recipe kwarg.""" + hook = RemoveEmptyFilesHook() + with TemporaryDirectory() as tmp: + path = Path(tmp) + (path / "empty.txt").write_text("") + (path / "notempty.txt").write_text("content") + + result_path, result_ctx = hook(path, {}, recipe="mock") + + self.assertEqual(result_path, path) + self.assertFalse((path / "empty.txt").exists()) + self.assertTrue((path / "notempty.txt").exists()) + + def test_remove_empty_dirs_with_recipe_kwarg(self): + """RemoveEmptyDirectoriesHook works when called via __call__ with recipe kwarg.""" + hook = RemoveEmptyDirectoriesHook() + with TemporaryDirectory() as tmp: + path = Path(tmp) + (path / "empty_dir").mkdir() + (path / "full_dir").mkdir() + (path / "full_dir" / "file.txt").write_text("content") + + result_path, result_ctx = hook(path, {}, recipe="mock") + + self.assertEqual(result_path, path) + self.assertFalse((path / "empty_dir").exists()) + self.assertTrue((path / "full_dir").exists()) + + def test_cleanup_hook_with_recipe_kwarg(self): + """CleanupHook (combined) works when called via __call__ with recipe kwarg.""" + hook = CleanupHook() + with TemporaryDirectory() as tmp: + path = Path(tmp) + (path / "empty.txt").write_text("") + (path / "empty_dir").mkdir() + (path / "keep.txt").write_text("content") + + result_path, result_ctx = hook(path, {}, recipe="mock") + + self.assertFalse((path / "empty.txt").exists()) + self.assertFalse((path / "empty_dir").exists()) + self.assertTrue((path / "keep.txt").exists()) + + +class TestGitInitInRecipeCreate(unittest.TestCase): + """GitInit hook works end-to-end within Recipe.create().""" + + def test_recipe_with_git_init_creates_git_repo(self): + """A recipe using GitInit as post_hook creates a git repo.""" + recipe = Recipe( + name="git-test", + post_hooks=[GitInit()], + contents=[File(id_="readme", name="README.md", content="# Hello\n")], + ) + + with TemporaryDirectory() as tmp: + result = recipe.create(Path(tmp)) + project_path = list(result.keys())[0] + + self.assertTrue((project_path / ".git").exists()) + self.assertTrue((project_path / "README.md").exists()) + self.assertEqual((project_path / "README.md").read_text(), "# Hello\n") + + +class TestCleanupHookInRecipeCreate(unittest.TestCase): + """CleanupHook works end-to-end within Recipe.create().""" + + def test_recipe_with_cleanup_removes_empty_files(self): + """Cleanup post-hook removes empty rendered files.""" + recipe = Recipe( + name="cleanup-test", + post_hooks=[CleanupHook()], + contents=[ + File(id_="readme", name="README.md", content="# cleanup-test\n"), + # This file renders to empty content + File(id_="feature", name="feature.txt", content=""), + ], + ) + + with TemporaryDirectory() as tmp: + result = recipe.create(Path(tmp)) + project_path = list(result.keys())[0] + + self.assertTrue((project_path / "README.md").exists()) + # Empty file should be cleaned up + self.assertFalse((project_path / "feature.txt").exists()) + + def test_recipe_with_cleanup_keeps_non_empty(self): + """Cleanup post-hook keeps non-empty rendered files.""" + recipe = Recipe( + name="cleanup-test", + post_hooks=[CleanupHook()], + contents=[ + File(id_="readme", name="README.md", content="# cleanup-test\n"), + File(id_="feature", name="feature.txt", content="feature content"), + ], + ) + + with TemporaryDirectory() as tmp: + result = recipe.create(Path(tmp)) + project_path = list(result.keys())[0] + + self.assertTrue((project_path / "README.md").exists()) + self.assertTrue((project_path / "feature.txt").exists()) + self.assertEqual((project_path / "feature.txt").read_text(), "feature content") + + +class TestHookChainWithGitAndCleanup(unittest.TestCase): + """Multiple built-in hooks can be chained together.""" + + def test_cleanup_then_git_init(self): + """Cleanup runs first, then git init — empty files not committed.""" + recipe = Recipe( + name="chain-test", + post_hooks=[CleanupHook(), GitInit()], + contents=[ + File(id_="readme", name="README.md", content="# chain-test\n"), + File(id_="empty", name="empty.txt", content=""), + ], + ) + + with TemporaryDirectory() as tmp: + result = recipe.create(Path(tmp)) + project_path = list(result.keys())[0] + + # Empty file cleaned + self.assertFalse((project_path / "empty.txt").exists()) + # Git initialised + self.assertTrue((project_path / ".git").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_mixer/test_components/test_hook.py b/tests/unit/test_mixer/test_components/test_hook.py index 63365af..1974adb 100644 --- a/tests/unit/test_mixer/test_components/test_hook.py +++ b/tests/unit/test_mixer/test_components/test_hook.py @@ -1,4 +1,8 @@ +"""Tests for Hook component including kwargs forwarding logic.""" + import unittest +from pathlib import Path +from typing import Any, Optional from unittest.mock import patch from nskit.mixer.components.hook import Hook @@ -18,7 +22,7 @@ def test_call(self): def test__call__no_result(self): class TestHook(Hook): - def call(sef, recipe_path, context): + def call(self, recipe_path, context): return None t = TestHook() @@ -26,8 +30,209 @@ def call(sef, recipe_path, context): def test__call__result(self): class TestHook(Hook): - def call(sef, recipe_path, context): + def call(self, recipe_path, context): return (3, 4) t = TestHook() self.assertEqual(t(1, 2), (3, 4)) + + +class HookKwargsForwardingTestCase(unittest.TestCase): + """Tests for kwargs forwarding behaviour in Hook.__call__.""" + + def test_kwargs_forwarded_when_call_accepts_var_keyword(self): + """Hook with **kwargs in call() receives all forwarded kwargs.""" + + class KwargsHook(Hook): + def call(self, recipe_path: Path, context: dict[str, Any], **kwargs): + return (recipe_path, {**context, "received_kwargs": kwargs}) + + hook = KwargsHook() + path = Path("/tmp/test") + ctx = {"key": "value"} + result_path, result_ctx = hook(path, ctx, recipe="mock_recipe", extra="data") + + self.assertEqual(result_path, path) + self.assertEqual(result_ctx["received_kwargs"]["recipe"], "mock_recipe") + self.assertEqual(result_ctx["received_kwargs"]["extra"], "data") + + def test_kwargs_not_forwarded_when_call_has_no_var_keyword(self): + """Hook without **kwargs in call() does not receive unknown kwargs.""" + received_args = {} + + class SimpleHook(Hook): + def call(self, recipe_path: Path, context: dict[str, Any]): + received_args["path"] = recipe_path + received_args["context"] = context + return None + + hook = SimpleHook() + path = Path("/tmp/test") + ctx = {"key": "value"} + result_path, result_ctx = hook(path, ctx, recipe="mock_recipe") + + self.assertEqual(result_path, path) + self.assertEqual(result_ctx, ctx) + # Confirm only the positional args were received + self.assertEqual(received_args["path"], path) + self.assertEqual(received_args["context"], ctx) + + def test_specific_kwarg_forwarded_when_explicitly_declared(self): + """Hook with explicitly declared 'recipe' param receives it without **kwargs.""" + received = {} + + class RecipeAwareHook(Hook): + def call(self, recipe_path: Path, context: dict[str, Any], recipe=None): + received["recipe"] = recipe + return (recipe_path, context) + + hook = RecipeAwareHook() + path = Path("/tmp/test") + ctx = {} + hook(path, ctx, recipe="my_recipe", other="ignored") + + self.assertEqual(received["recipe"], "my_recipe") + + def test_only_matching_kwargs_forwarded(self): + """Only kwargs matching declared parameters are forwarded.""" + received = {} + + class SelectiveHook(Hook): + def call(self, recipe_path: Path, context: dict[str, Any], recipe=None): + received["recipe"] = recipe + return None + + hook = SelectiveHook() + path = Path("/tmp/test") + ctx = {"a": 1} + # 'other' should be silently dropped since call() doesn't declare it + result_path, result_ctx = hook(path, ctx, recipe="the_recipe", other="dropped") + + self.assertEqual(result_path, path) + self.assertEqual(result_ctx, ctx) + self.assertEqual(received["recipe"], "the_recipe") + + def test_no_kwargs_passed_when_none_provided(self): + """Hook works normally when called without any extra kwargs.""" + + class BasicHook(Hook): + def call(self, recipe_path: Path, context: dict[str, Any], **kwargs): + return (recipe_path, {**context, "kwargs_count": len(kwargs)}) + + hook = BasicHook() + path = Path("/tmp/test") + ctx = {"x": 1} + result_path, result_ctx = hook(path, ctx) + + self.assertEqual(result_ctx["kwargs_count"], 0) + + def test_hook_return_none_preserves_original_values(self): + """When call() returns None, original path and context are returned.""" + + class NoOpHook(Hook): + def call(self, recipe_path: Path, context: dict[str, Any], **kwargs): + return None + + hook = NoOpHook() + path = Path("/tmp/original") + ctx = {"original": True} + result_path, result_ctx = hook(path, ctx, recipe="something") + + self.assertEqual(result_path, path) + self.assertEqual(result_ctx, ctx) + + def test_hook_can_modify_path(self): + """Hook can return a modified path.""" + + class PathModifyingHook(Hook): + def call(self, recipe_path: Path, context: dict[str, Any], **kwargs): + return (recipe_path / "subdir", context) + + hook = PathModifyingHook() + path = Path("/tmp/test") + ctx = {"key": "value"} + result_path, result_ctx = hook(path, ctx) + + self.assertEqual(result_path, Path("/tmp/test/subdir")) + self.assertEqual(result_ctx, ctx) + + def test_hook_can_modify_context(self): + """Hook can return a modified context.""" + + class ContextModifyingHook(Hook): + def call(self, recipe_path: Path, context: dict[str, Any], **kwargs): + new_ctx = {**context, "injected": True} + return (recipe_path, new_ctx) + + hook = ContextModifyingHook() + path = Path("/tmp/test") + ctx = {"original": True} + result_path, result_ctx = hook(path, ctx) + + self.assertEqual(result_ctx, {"original": True, "injected": True}) + + def test_hook_with_recipe_kwarg_can_mutate_recipe(self): + """A hook receiving recipe kwarg can interact with the recipe instance.""" + mutations = [] + + class MutatingHook(Hook): + def call(self, recipe_path: Path, context: dict[str, Any], recipe=None): + if recipe is not None: + mutations.append(recipe) + return None + + hook = MutatingHook() + mock_recipe = object() + hook(Path("/tmp"), {}, recipe=mock_recipe) + + self.assertEqual(len(mutations), 1) + self.assertIs(mutations[0], mock_recipe) + + def test_backwards_compat_old_style_hook(self): + """Old-style hooks with only (recipe_path, context) still work.""" + + class OldStyleHook(Hook): + def call(self, recipe_path, context): + return (recipe_path, {**context, "old_style": True}) + + hook = OldStyleHook() + result_path, result_ctx = hook(Path("/tmp"), {"a": 1}, recipe="ignored") + + self.assertEqual(result_ctx, {"a": 1, "old_style": True}) + + def test_hook_is_pydantic_model(self): + """Hook subclasses are valid Pydantic models with configurable fields.""" + + class ConfigurableHook(Hook): + multiplier: int = 2 + + def call(self, recipe_path: Path, context: dict[str, Any], **kwargs): + return (recipe_path, {**context, "multiplied": self.multiplier * 3}) + + hook = ConfigurableHook(multiplier=5) + _, result_ctx = hook(Path("/tmp"), {}) + + self.assertEqual(result_ctx["multiplied"], 15) + + def test_hook_with_optional_return_type(self): + """Hook call() with Optional return type annotation works.""" + + class TypedHook(Hook): + def call(self, recipe_path: Path, context: dict[str, Any], **kwargs) -> Optional[tuple[Path, dict]]: + if context.get("skip"): + return None + return (recipe_path, {**context, "processed": True}) + + hook = TypedHook() + + # When returning None + path, ctx = hook(Path("/tmp"), {"skip": True}) + self.assertNotIn("processed", ctx) + + # When returning a tuple + path, ctx = hook(Path("/tmp"), {"skip": False}) + self.assertTrue(ctx["processed"]) + + +if __name__ == "__main__": + unittest.main()