render_precommit¶
rhiza_hooks.render_precommit
¶
Render a .pre-commit-config.yaml by concatenating and deduplicating fragments.
pre-commit hard-codes the config filename, so a template repository that ships several
language layers (python-core, rust-core, go-core) has them all claim the
same path: the layers are alternatives rather than files that coexist. The neutral
hooks -- markdownlint, actionlint, schema validation, secret scanning, the rhiza hooks
-- are therefore duplicated across every layer, and that duplication is maintained by
hand.
This module inverts it. One fragment holds the neutral hooks once, each other fragment holds only what it adds, and a deployed config is rendered from a chain of fragments. A rev bump in the base reaches every layer through one edit.
It lives in rhiza-hooks rather than in the template repository so that the
capability travels: the fragment format supports a project keeping its own fragment
anywhere and extending the shipped ones, which is worth nothing if the renderer is only
present in the template's own checkout. Installed here it is versioned, pinnable, and
reachable from any consuming repo.
Composition is declared by the fragments themselves -- there is no separate manifest, and nothing about the set of fragments is hard-coded here. A fragment may set three meta keys, all stripped from the rendered output (none of them is a real pre-commit key, so a fragment stays readable as a config):
extends
Fragment names or paths merged before this one, resolved recursively and
deduplicated. A bare name resolves inside the fragment directory, so a project can
keep its own fragment anywhere and still extend the shipped ones.
output
Where the rendered config is written, relative to the repository root. A fragment
with no output is a mixin: it is never rendered on its own, only pulled in by
something that extends it. That is what makes a base fragment a base.
remove
hooks: and/or repos: lists naming what to drop from what came before --
how a project keeps the shared base but opts out of one of its hooks. Removals
apply after the whole chain has merged, so a fragment can remove a hook that a
later fragment in the chain would otherwise reintroduce.
Merge rules, in the order the merge applies them:
- Top-level keys other than
repos(e.g.default_language_version) merge by key name; the later fragment's value wins. reposentries merge by repository URL, keeping the earlier order and appending each fragment's new repositories.repo: localcollapses to a single entry.- Within a shared repository, hooks merge by
id; a later hook with an id an earlier fragment also defines replaces it, which is how a fragment narrows a neutral hook. A later entry may omitrev:to inherit the pin, and arev:that contradicts an earlier one is an error rather than a silent pick -- shipping the wrong pin is worse than failing.
The merge is textual, splicing comment-plus-body blocks rather than round-tripping through a YAML parser, because the explanatory comments on these hooks carry as much of the reasoning as the hooks do and no YAML emitter preserves them faithfully. The rendered result is parsed and checked for duplicate hook ids before it is written.
This is a console script, not a pre-commit hook, and deliberately so. pre-commit
reads .pre-commit-config.yaml once, before any hook executes, so a hook that
rendered it could only ever affect the next invocation while changing the file under
the current one. Rendering belongs in the build step that runs ahead of pre-commit —
which is where the ordering can actually be guaranteed.
Checking is the default; rendering requires --write. A tool invoked in a
repository should not rewrite tracked files unless it was asked to: the check mode is
the one safe to run anywhere, including from a CI drift guard, and --write is the
explicit request. It also means a bare invocation in the wrong directory reports rather
than edits.
Exit codes
0 - every rendered config matches what is on disk (or was written, with --write) 1 - drift found under the default check, or a fragment could not be merged
Block
dataclass
¶
A run of comment lines followed by the body lines they document.
Attributes:
| Name | Type | Description |
|---|---|---|
comments |
list[str]
|
Comment lines preceding the body, verbatim. |
body |
list[str]
|
The body lines, verbatim. Empty for a comment-only block, which is how a commented-out hook survives the merge. |
spaced |
bool
|
Whether the source separated this block from the previous one by a blank line. Preserved so the rendered file keeps the fragments' own grouping instead of a uniform spacing this repo does not use. |
Source code in rhiza_hooks/render_precommit.py
render()
¶
Return the block's lines, comments first.
Returns:
| Type | Description |
|---|---|
list[str]
|
The comment lines followed by the body lines. |
Fragment
dataclass
¶
A parsed fragment.
Attributes:
| Name | Type | Description |
|---|---|---|
path |
Path
|
Where the fragment was read from, used for error messages and to
resolve its relative |
preamble |
dict[str, Block]
|
Top-level blocks other than |
repos |
dict[str, Repo]
|
The |
extends |
list[str]
|
Fragment references merged before this one. |
output |
str | None
|
Where this fragment's rendered config is deployed, or None if it is a mixin. |
remove_hooks |
set[str]
|
Hook ids to drop from the merged result. |
remove_repos |
set[str]
|
Repository URLs to drop from the merged result. |
Source code in rhiza_hooks/render_precommit.py
FragmentError
¶
Hook
dataclass
¶
One - id: <name> item inside a repository's hooks: list.
Attributes:
| Name | Type | Description |
|---|---|---|
hook_id |
str
|
The hook's |
block |
Block
|
The hook's comments and body lines. |
Source code in rhiza_hooks/render_precommit.py
Layout
dataclass
¶
Where a run reads fragments from and writes rendered configs to.
Bundling the two paths keeps them out of module-level state. The renderer used to
derive both from its own __file__, which is right for a script vendored into
the repository it renders and wrong for an installed console script, where
__file__ points into site-packages.
Attributes:
| Name | Type | Description |
|---|---|---|
repo_root |
Path
|
Repository root. |
fragment_dir |
Path
|
Directory holding the fragments. |
Source code in rhiza_hooks/render_precommit.py
discover(repo_root=None, fragment_dir=DEFAULT_FRAGMENT_DIR)
classmethod
¶
Build a layout, locating the repository root when it is not given.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
repo_root
|
Path | None
|
Repository root; discovered from the working directory when None. |
None
|
fragment_dir
|
str
|
Fragment directory, absolute or relative to the root. |
DEFAULT_FRAGMENT_DIR
|
Returns:
| Type | Description |
|---|---|
Layout
|
The resolved layout. |
Source code in rhiza_hooks/render_precommit.py
display(path)
¶
Return a path for display, repo-relative where possible, always POSIX.
Forward slashes are not cosmetic here. :func:header embeds these strings in
the rendered file, so returning the platform form would make the same
fragments render different bytes on Windows and on Linux -- and the drift check
would then fail on whichever platform did not render last. Same reasoning as
pinning newline="" on the write: output must be a function of its input
alone.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
Path
|
The path to shorten. |
required |
Returns:
| Type | Description |
|---|---|
str
|
The repo-relative POSIX path, or the full POSIX path if it lies outside |
str
|
the repository. |
Source code in rhiza_hooks/render_precommit.py
Repo
dataclass
¶
One - repo: <url> entry of the repos: list.
Attributes:
| Name | Type | Description |
|---|---|---|
url |
str
|
The repository URL, or |
block |
Block
|
The entry's leading comments and the |
meta |
list[str]
|
The lines between |
hooks |
list[Hook]
|
The repository's hooks, in merge order. |
trailing |
list[Block]
|
Comment-only blocks after the last hook, e.g. a hook deliberately commented out with the reasoning kept beside it. |
Source code in rhiza_hooks/render_precommit.py
rev
property
¶
The entry's pinned rev, ignoring any trailing comment on it.
Returns:
| Type | Description |
|---|---|
str | None
|
The rev, or None for |
copy()
¶
Copy the entry so merging never mutates a parsed fragment.
Returns:
| Type | Description |
|---|---|
Repo
|
A copy whose mutable members are independent of the original. |
Source code in rhiza_hooks/render_precommit.py
render()
¶
Return the entry's lines, ready to append to a repos: list.
Returns:
| Type | Description |
|---|---|
list[str]
|
The comments, |
list[str]
|
hook block, blank-separated exactly as the fragments were. |
Source code in rhiza_hooks/render_precommit.py
chain(references, relative_to, layout, seen=None)
¶
Expand fragment references into the flat, deduplicated list to merge.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
references
|
list[str]
|
The fragment references, in merge order. |
required |
relative_to
|
Path
|
The directory references are resolved against first. |
required |
layout
|
Layout
|
Where the repository root and fragment directory are. |
required |
seen
|
list[Path] | None
|
Paths already in the chain, threaded through the recursion to deduplicate a shared base and to catch a cycle. |
None
|
Returns:
| Type | Description |
|---|---|
list[Fragment]
|
The fragments in merge order, each appearing once, dependencies first. |
Raises:
| Type | Description |
|---|---|
FragmentError
|
If a reference cannot be resolved or |
Source code in rhiza_hooks/render_precommit.py
deployable(layout)
¶
List the fragments that declare an output, i.e. those rendered by default.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
layout
|
Layout
|
Where the fragment directory is. |
required |
Returns:
| Type | Description |
|---|---|
list[Path]
|
The fragment paths, sorted. A fragment with no |
list[Path]
|
skipped -- it is only ever pulled in via |
Raises:
| Type | Description |
|---|---|
FragmentError
|
If the fragment directory is missing. |
Source code in rhiza_hooks/render_precommit.py
header(fragments, layout)
¶
Build the rendered file's header comment.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
fragments
|
list[Fragment]
|
The chain the file was rendered from, in merge order. |
required |
layout
|
Layout
|
Where the repository root is, so fragments are named relative to it. |
required |
Returns:
| Type | Description |
|---|---|
list[str]
|
The header lines, naming the fragments so whoever opens the deployed config |
list[str]
|
is sent to the right place to edit it. |
Source code in rhiza_hooks/render_precommit.py
main(argv=None)
¶
Render the requested configs, or check the deployed files for drift.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
argv
|
list[str] | None
|
Command-line arguments, defaulting to |
None
|
Returns:
| Type | Description |
|---|---|
int
|
A process exit status: 0 on success, 1 if a check found drift or a fragment |
int
|
could not be merged. |
Source code in rhiza_hooks/render_precommit.py
merge(fragments, extra_hooks, extra_repos)
¶
Merge a chain of fragments into one config's blocks.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
fragments
|
list[Fragment]
|
The fragments, in merge order. |
required |
extra_hooks
|
set[str]
|
Hook ids to remove on top of what the fragments declare. |
required |
extra_repos
|
set[str]
|
Repository URLs to remove on top of what the fragments declare. |
required |
Returns:
| Type | Description |
|---|---|
dict[str, Block]
|
A |
dict[str, Repo]
|
every removal applied. |
Raises:
| Type | Description |
|---|---|
FragmentError
|
If two fragments pin one repository to different revs. |
Source code in rhiza_hooks/render_precommit.py
parse_fragment(path)
¶
Parse a fragment file into its meta keys, preamble blocks and repositories.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
Path
|
The fragment to read. |
required |
Returns:
| Type | Description |
|---|---|
Fragment
|
The parsed fragment. |
Raises:
| Type | Description |
|---|---|
FragmentError
|
If the file has no top-level keys, or a meta key has the wrong shape. |
Source code in rhiza_hooks/render_precommit.py
plan(targets, out, hooks, repos, layout)
¶
Render every target chain, resolving where each result is deployed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
targets
|
list[list[str]]
|
Fragment reference lists, one per config to render. |
required |
out
|
str | None
|
An explicit destination overriding the chain's |
required |
hooks
|
set[str]
|
Hook ids to remove on top of what the fragments declare. |
required |
repos
|
set[str]
|
Repository URLs to remove on top of what the fragments declare. |
required |
layout
|
Layout
|
Where the repository root and fragment directory are. |
required |
Returns:
| Type | Description |
|---|---|
list[tuple[Path, str]]
|
The |
Raises:
| Type | Description |
|---|---|
FragmentError
|
If a chain cannot be resolved or merged, if it declares no destination and none was given, or if the destination is one of its own inputs. |
Source code in rhiza_hooks/render_precommit.py
render(fragments, layout, extra_hooks=frozenset(), extra_repos=frozenset())
¶
Render a chain of fragments into a complete .pre-commit-config.yaml.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
fragments
|
list[Fragment]
|
The fragments, in merge order. |
required |
layout
|
Layout
|
Where the repository root is. |
required |
extra_hooks
|
Set[str]
|
Hook ids to remove on top of what the fragments declare. |
frozenset()
|
extra_repos
|
Set[str]
|
Repository URLs to remove on top of what the fragments declare. |
frozenset()
|
Returns:
| Type | Description |
|---|---|
str
|
The file's full text, newline-terminated. |
Raises:
| Type | Description |
|---|---|
FragmentError
|
If the merge conflicts, or the result is not a valid config. |
Source code in rhiza_hooks/render_precommit.py
resolve(reference, relative_to, layout)
¶
Resolve a fragment reference to a path.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
reference
|
str
|
A bare name ( |
required |
relative_to
|
Path
|
The directory of the fragment doing the referring. |
required |
layout
|
Layout
|
Where the repository root and fragment directory are. |
required |
Returns:
| Type | Description |
|---|---|
Path
|
The resolved path. |
Raises:
| Type | Description |
|---|---|
FragmentError
|
If no candidate exists. |