Skip to content

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.
  • repos entries merge by repository URL, keeping the earlier order and appending each fragment's new repositories. repo: local collapses 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 omit rev: to inherit the pin, and a rev: 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
@dataclass
class Block:
    """A run of comment lines followed by the body lines they document.

    Attributes:
        comments: Comment lines preceding the body, verbatim.
        body: The body lines, verbatim. Empty for a comment-only block, which is how
            a commented-out hook survives the merge.
        spaced: 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.
    """

    comments: list[str] = field(default_factory=list)
    body: list[str] = field(default_factory=list)
    spaced: bool = False

    def render(self) -> list[str]:
        """Return the block's lines, comments first.

        Returns:
            The comment lines followed by the body lines.
        """
        return [*self.comments, *self.body]

render()

Return the block's lines, comments first.

Returns:

Type Description
list[str]

The comment lines followed by the body lines.

Source code in rhiza_hooks/render_precommit.py
def render(self) -> list[str]:
    """Return the block's lines, comments first.

    Returns:
        The comment lines followed by the body lines.
    """
    return [*self.comments, *self.body]

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 extends entries.

preamble dict[str, Block]

Top-level blocks other than repos and the meta keys, keyed by key name so a later fragment can override one.

repos dict[str, Repo]

The repos: entries, keyed by URL, in source order.

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
@dataclass
class Fragment:
    """A parsed fragment.

    Attributes:
        path: Where the fragment was read from, used for error messages and to
            resolve its relative ``extends`` entries.
        preamble: Top-level blocks other than ``repos`` and the meta keys, keyed by
            key name so a later fragment can override one.
        repos: The ``repos:`` entries, keyed by URL, in source order.
        extends: Fragment references merged before this one.
        output: Where this fragment's rendered config is deployed, or None if it is
            a mixin.
        remove_hooks: Hook ids to drop from the merged result.
        remove_repos: Repository URLs to drop from the merged result.
    """

    path: Path
    preamble: dict[str, Block] = field(default_factory=dict)
    repos: dict[str, Repo] = field(default_factory=dict)
    extends: list[str] = field(default_factory=list)
    output: str | None = None
    remove_hooks: set[str] = field(default_factory=set)
    remove_repos: set[str] = field(default_factory=set)

FragmentError

Bases: RuntimeError

A fragment could not be parsed, or two fragments disagree irreconcilably.

Source code in rhiza_hooks/render_precommit.py
class FragmentError(RuntimeError):
    """A fragment could not be parsed, or two fragments disagree irreconcilably."""

Hook dataclass

One - id: <name> item inside a repository's hooks: list.

Attributes:

Name Type Description
hook_id str

The hook's id -- the key the merge deduplicates on.

block Block

The hook's comments and body lines.

Source code in rhiza_hooks/render_precommit.py
@dataclass
class Hook:
    """One ``- id: <name>`` item inside a repository's ``hooks:`` list.

    Attributes:
        hook_id: The hook's ``id`` -- the key the merge deduplicates on.
        block: The hook's comments and body lines.
    """

    hook_id: str
    block: Block

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. output: paths resolve against it.

fragment_dir Path

Directory holding the fragments.

Source code in rhiza_hooks/render_precommit.py
@dataclass(frozen=True)
class Layout:
    """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:
        repo_root: Repository root. ``output:`` paths resolve against it.
        fragment_dir: Directory holding the fragments.
    """

    repo_root: Path
    fragment_dir: Path

    @classmethod
    def discover(cls, repo_root: Path | None = None, fragment_dir: str = DEFAULT_FRAGMENT_DIR) -> Layout:
        """Build a layout, locating the repository root when it is not given.

        Args:
            repo_root: Repository root; discovered from the working directory when None.
            fragment_dir: Fragment directory, absolute or relative to the root.

        Returns:
            The resolved layout.
        """
        root = (repo_root or find_repo_root()).resolve()
        directory = Path(fragment_dir)
        return cls(repo_root=root, fragment_dir=directory if directory.is_absolute() else root / directory)

    def display(self, path: Path) -> str:
        """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.

        Args:
            path: The path to shorten.

        Returns:
            The repo-relative POSIX path, or the full POSIX path if it lies outside
            the repository.
        """
        relative = path.relative_to(self.repo_root) if path.is_relative_to(self.repo_root) else path
        return relative.as_posix()

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
@classmethod
def discover(cls, repo_root: Path | None = None, fragment_dir: str = DEFAULT_FRAGMENT_DIR) -> Layout:
    """Build a layout, locating the repository root when it is not given.

    Args:
        repo_root: Repository root; discovered from the working directory when None.
        fragment_dir: Fragment directory, absolute or relative to the root.

    Returns:
        The resolved layout.
    """
    root = (repo_root or find_repo_root()).resolve()
    directory = Path(fragment_dir)
    return cls(repo_root=root, fragment_dir=directory if directory.is_absolute() else root / directory)

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
def display(self, path: Path) -> str:
    """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.

    Args:
        path: The path to shorten.

    Returns:
        The repo-relative POSIX path, or the full POSIX path if it lies outside
        the repository.
    """
    relative = path.relative_to(self.repo_root) if path.is_relative_to(self.repo_root) else path
    return relative.as_posix()

Repo dataclass

One - repo: <url> entry of the repos: list.

Attributes:

Name Type Description
url str

The repository URL, or local. The key the merge deduplicates on.

block Block

The entry's leading comments and the - repo: line itself.

meta list[str]

The lines between - repo: and hooks: -- rev: and any comment attached to it. Empty for repo: local.

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
@dataclass
class Repo:
    """One ``- repo: <url>`` entry of the ``repos:`` list.

    Attributes:
        url: The repository URL, or ``local``. The key the merge deduplicates on.
        block: The entry's leading comments and the ``- repo:`` line itself.
        meta: The lines between ``- repo:`` and ``hooks:`` -- ``rev:`` and any comment
            attached to it. Empty for ``repo: local``.
        hooks: The repository's hooks, in merge order.
        trailing: Comment-only blocks after the last hook, e.g. a hook deliberately
            commented out with the reasoning kept beside it.
    """

    url: str
    block: Block
    meta: list[str] = field(default_factory=list)
    hooks: list[Hook] = field(default_factory=list)
    trailing: list[Block] = field(default_factory=list)

    @property
    def rev(self) -> str | None:
        """The entry's pinned rev, ignoring any trailing comment on it.

        Returns:
            The rev, or None for ``repo: local`` and any entry that omits it.
        """
        for line in self.meta:
            if line.startswith(REV_KEY):
                return line.split(":", 1)[1].split("#", 1)[0].strip()
        return None

    def copy(self) -> Repo:
        """Copy the entry so merging never mutates a parsed fragment.

        Returns:
            A copy whose mutable members are independent of the original.
        """
        return Repo(self.url, self.block, list(self.meta), list(self.hooks), list(self.trailing))

    def render(self) -> list[str]:
        """Return the entry's lines, ready to append to a ``repos:`` list.

        Returns:
            The comments, ``- repo:`` line, ``rev:`` lines, ``hooks:`` key and every
            hook block, blank-separated exactly as the fragments were.
        """
        lines = [*self.block.render(), *self.meta]
        if not self.hooks and not self.trailing:
            return lines
        lines.append(HOOKS_KEY)
        for index, block in enumerate([hook.block for hook in self.hooks] + self.trailing):
            if block.spaced and index:
                lines.append("")
            lines.extend(block.render())
        return lines

rev property

The entry's pinned rev, ignoring any trailing comment on it.

Returns:

Type Description
str | None

The rev, or None for repo: local and any entry that omits it.

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
def copy(self) -> Repo:
    """Copy the entry so merging never mutates a parsed fragment.

    Returns:
        A copy whose mutable members are independent of the original.
    """
    return Repo(self.url, self.block, list(self.meta), list(self.hooks), list(self.trailing))

render()

Return the entry's lines, ready to append to a repos: list.

Returns:

Type Description
list[str]

The comments, - repo: line, rev: lines, hooks: key and every

list[str]

hook block, blank-separated exactly as the fragments were.

Source code in rhiza_hooks/render_precommit.py
def render(self) -> list[str]:
    """Return the entry's lines, ready to append to a ``repos:`` list.

    Returns:
        The comments, ``- repo:`` line, ``rev:`` lines, ``hooks:`` key and every
        hook block, blank-separated exactly as the fragments were.
    """
    lines = [*self.block.render(), *self.meta]
    if not self.hooks and not self.trailing:
        return lines
    lines.append(HOOKS_KEY)
    for index, block in enumerate([hook.block for hook in self.hooks] + self.trailing):
        if block.spaced and index:
            lines.append("")
        lines.extend(block.render())
    return lines

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 extends forms a cycle.

Source code in rhiza_hooks/render_precommit.py
def chain(references: list[str], relative_to: Path, layout: Layout, seen: list[Path] | None = None) -> list[Fragment]:
    """Expand fragment references into the flat, deduplicated list to merge.

    Args:
        references: The fragment references, in merge order.
        relative_to: The directory references are resolved against first.
        layout: Where the repository root and fragment directory are.
        seen: Paths already in the chain, threaded through the recursion to
            deduplicate a shared base and to catch a cycle.

    Returns:
        The fragments in merge order, each appearing once, dependencies first.

    Raises:
        FragmentError: If a reference cannot be resolved or ``extends`` forms a cycle.
    """
    seen = [] if seen is None else seen
    result: list[Fragment] = []
    for reference in references:
        path = resolve(reference, relative_to, layout)
        if path in seen:
            continue
        seen.append(path)
        fragment = parse_fragment(path)
        result.extend(chain(fragment.extends, path.parent, layout, seen))
        result.append(fragment)
    return result

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 output is a mixin and is

list[Path]

skipped -- it is only ever pulled in via extends.

Raises:

Type Description
FragmentError

If the fragment directory is missing.

Source code in rhiza_hooks/render_precommit.py
def deployable(layout: Layout) -> list[Path]:
    """List the fragments that declare an output, i.e. those rendered by default.

    Args:
        layout: Where the fragment directory is.

    Returns:
        The fragment paths, sorted. A fragment with no ``output`` is a mixin and is
        skipped -- it is only ever pulled in via ``extends``.

    Raises:
        FragmentError: If the fragment directory is missing.
    """
    if not layout.fragment_dir.is_dir():
        msg = f"no fragment directory at {layout.display(layout.fragment_dir)}"
        raise FragmentError(msg)
    return sorted(path for path in layout.fragment_dir.glob("*.y*ml") if parse_fragment(path).output)

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
def header(fragments: list[Fragment], layout: Layout) -> list[str]:
    """Build the rendered file's header comment.

    Args:
        fragments: The chain the file was rendered from, in merge order.
        layout: Where the repository root is, so fragments are named relative to it.

    Returns:
        The header lines, naming the fragments so whoever opens the deployed config
        is sent to the right place to edit it.
    """
    names = ", ".join(layout.display(fragment.path) for fragment in fragments)
    return [
        "# GENERATED FILE - do not edit. Rendered by rhiza-hooks (render-precommit)",
        f"# from {names}.",
        "# Edit a fragment and re-render.",
        "#",
        "# pre-commit hard-codes this path, so the language layers that deploy it are",
        "# alternatives rather than files that could coexist.",
    ]

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 sys.argv[1:].

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
def main(argv: list[str] | None = None) -> int:
    """Render the requested configs, or check the deployed files for drift.

    Args:
        argv: Command-line arguments, defaulting to ``sys.argv[1:]``.

    Returns:
        A process exit status: 0 on success, 1 if a check found drift or a fragment
        could not be merged.
    """
    args = _build_parser().parse_args(argv)
    layout = Layout.discover(fragment_dir=args.fragment_dir)

    # A repository that renders nothing is the normal case, and a shared build step or CI
    # job may call this unconditionally across a fleet of them. So an absent *default*
    # fragment directory is "nothing to do", not an error. An explicit --fragment-dir is
    # an assertion that fragments live there, and a missing one still fails below.
    if not args.fragments and args.fragment_dir == DEFAULT_FRAGMENT_DIR and not layout.fragment_dir.is_dir():
        print(f"no {DEFAULT_FRAGMENT_DIR}/ directory - nothing to render")
        return 0

    try:
        targets = [args.fragments] if args.fragments else [[str(path)] for path in deployable(layout)]
        if not targets:
            print(f"error: no fragment in {layout.display(layout.fragment_dir)}/ declares an output:", file=sys.stderr)
            return 1
        rendered = plan(targets, args.out, set(args.exclude_hook), set(args.exclude_repo), layout)
    except (FragmentError, OSError) as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 1

    changed = sum(_apply(path, text, args.write, layout) for path, text in rendered)
    if not args.write and changed:
        print(f"\n{changed} config(s) out of date. Re-render with --write.", file=sys.stderr)
        return 1
    return 0

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 (preamble, repos) pair holding the merged blocks in output order, with

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
def merge(
    fragments: list[Fragment], extra_hooks: set[str], extra_repos: set[str]
) -> tuple[dict[str, Block], dict[str, Repo]]:
    """Merge a chain of fragments into one config's blocks.

    Args:
        fragments: The fragments, in merge order.
        extra_hooks: Hook ids to remove on top of what the fragments declare.
        extra_repos: Repository URLs to remove on top of what the fragments declare.

    Returns:
        A ``(preamble, repos)`` pair holding the merged blocks in output order, with
        every removal applied.

    Raises:
        FragmentError: If two fragments pin one repository to different revs.
    """
    preamble: dict[str, Block] = {}
    repos: dict[str, Repo] = {}
    drop_hooks = set(extra_hooks)
    drop_repos = set(extra_repos)
    for fragment in fragments:
        preamble.update(fragment.preamble)
        drop_hooks |= fragment.remove_hooks
        drop_repos |= fragment.remove_repos
        for url, repo in fragment.repos.items():
            if url in repos:
                _absorb(repos[url], repo, fragment.path)
            else:
                repos[url] = repo.copy()
    for url in drop_repos:
        repos.pop(url, None)
    for repo in repos.values():
        repo.hooks = [hook for hook in repo.hooks if hook.hook_id not in drop_hooks]
    # A repository whose every hook was removed would emit an entry with an empty
    # hooks: list, which pre-commit rejects.
    return preamble, {url: repo for url, repo in repos.items() if repo.hooks}

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
def parse_fragment(path: Path) -> Fragment:
    """Parse a fragment file into its meta keys, preamble blocks and repositories.

    Args:
        path: The fragment to read.

    Returns:
        The parsed fragment.

    Raises:
        FragmentError: If the file has no top-level keys, or a meta key has the wrong
            shape.
    """
    lines = path.read_text(encoding="utf-8").splitlines()
    starts = [index for index, line in enumerate(lines) if line and not line[0].isspace() and not _is_comment(line)]
    if not starts:
        msg = f"{path}: no top-level keys found"
        raise FragmentError(msg)
    fragment = Fragment(path=path)
    for index, start in enumerate(starts):
        end = starts[index + 1] if index + 1 < len(starts) else len(lines)
        block = Block(
            comments=_key_comments(lines, start, starts[index - 1] if index else 0),
            body=_rstrip(lines[start:end]),
        )
        key = lines[start].split(":", 1)[0].strip()
        if key in META_KEYS:
            _apply_meta(fragment, key, _meta(block, path), path)
        elif key == "repos":
            fragment.repos = _parse_repos(block.body[1:], path)
        else:
            fragment.preamble[key] = block
    return fragment

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 output:.

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 (path, text) pairs to write, in target order.

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
def plan(
    targets: list[list[str]], out: str | None, hooks: set[str], repos: set[str], layout: Layout
) -> list[tuple[Path, str]]:
    """Render every target chain, resolving where each result is deployed.

    Args:
        targets: Fragment reference lists, one per config to render.
        out: An explicit destination overriding the chain's ``output:``.
        hooks: Hook ids to remove on top of what the fragments declare.
        repos: Repository URLs to remove on top of what the fragments declare.
        layout: Where the repository root and fragment directory are.

    Returns:
        The ``(path, text)`` pairs to write, in target order.

    Raises:
        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.
    """
    rendered: list[tuple[Path, str]] = []
    for references in targets:
        fragments = chain(references, Path.cwd(), layout)
        destination = out or fragments[-1].output
        if not destination:
            msg = f"{layout.display(fragments[-1].path)} declares no output: -- pass --out"
            raise FragmentError(msg)
        target = (layout.repo_root / destination).resolve()
        _reject_self_reference(target, fragments, layout)
        rendered.append((target, render(fragments, layout, hooks, repos)))
    return rendered

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
def render(
    fragments: list[Fragment],
    layout: Layout,
    # AbstractSet, not set: these are read-only here (merge copies them), and a
    # mutable default would be a shared-state bug waiting to happen.
    extra_hooks: AbstractSet[str] = frozenset(),
    extra_repos: AbstractSet[str] = frozenset(),
) -> str:
    """Render a chain of fragments into a complete ``.pre-commit-config.yaml``.

    Args:
        fragments: The fragments, in merge order.
        layout: Where the repository root is.
        extra_hooks: Hook ids to remove on top of what the fragments declare.
        extra_repos: Repository URLs to remove on top of what the fragments declare.

    Returns:
        The file's full text, newline-terminated.

    Raises:
        FragmentError: If the merge conflicts, or the result is not a valid config.
    """
    preamble, repos = merge(fragments, set(extra_hooks), set(extra_repos))
    lines = header(fragments, layout)
    for block in preamble.values():
        lines.extend(["", *block.render()])
    lines.extend(["", "repos:"])
    for index, repo in enumerate(repos.values()):
        if index:
            lines.append("")
        lines.extend(repo.render())
    text = "\n".join(lines) + "\n"
    _validate(text, fragments[-1].path)
    return text

resolve(reference, relative_to, layout)

Resolve a fragment reference to a path.

Parameters:

Name Type Description Default
reference str

A bare name (python or python.yaml), resolved inside the fragment directory, or a path, resolved against the referring fragment's directory and then the repository root.

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.

Source code in rhiza_hooks/render_precommit.py
def resolve(reference: str, relative_to: Path, layout: Layout) -> Path:
    """Resolve a fragment reference to a path.

    Args:
        reference: A bare name (``python`` or ``python.yaml``), resolved inside the
            fragment directory, or a path, resolved against the referring fragment's
            directory and then the repository root.
        relative_to: The directory of the fragment doing the referring.
        layout: Where the repository root and fragment directory are.

    Returns:
        The resolved path.

    Raises:
        FragmentError: If no candidate exists.
    """
    name = reference if reference.endswith((".yaml", ".yml")) else f"{reference}.yaml"
    candidates = (
        [Path(name)]
        if Path(name).is_absolute()
        else [relative_to / name, layout.repo_root / name, layout.fragment_dir / name]
    )
    for candidate in candidates:
        if candidate.is_file():
            return candidate
    # dict.fromkeys, not set(): the candidates collapse to one entry when the referring
    # fragment sits at the repository root, and the order they were tried in is the
    # useful part of the message.
    tried = ", ".join(dict.fromkeys(layout.display(candidate) for candidate in candidates))
    msg = f"no fragment {reference!r} (tried: {tried})"
    raise FragmentError(msg)