Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
188 changes: 188 additions & 0 deletions docs/source/guides/hooks.md
Original file line number Diff line number Diff line change
@@ -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()]
```
49 changes: 43 additions & 6 deletions src/nskit/mixer/components/hook.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Hook component."""

import inspect
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any, Optional
Expand All @@ -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
8 changes: 4 additions & 4 deletions src/nskit/mixer/components/recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading