Skip to content

_makefile

rhiza_hooks._makefile

Read the targets a repository's makefiles define.

Two hooks need this: check_makefile_targets asks whether the recommended targets are present, and check_workflow_make_targets asks whether every target CI invokes exists. Both questions start from the same parse, so the parser lives here rather than in either hook.

Both are really the same question — "is this name defined?" — and since rhiza v1.4.0 neither can be answered from a list of names alone. The root Makefile is a shim whose only real rule is help; everything else is served by a catch-all pattern rule that forwards the goal to the rhiza-task CLI::

%: $(UVX) FORCE
    @$(UVX) $(RHIZA_TASK) $(RHIZA_TASK_GOAL)

make test works there, and no test: rule exists. So the parse yields :class:MakefileTargets, which carries the catch-all flag alongside the names and answers the question itself in :meth:MakefileTargets.defines. Leaving each hook to apply the flag would be leaving each hook to forget it.

The placement is deliberate. A console script's public surface is its main(); when one entrypoint imports a helper out of another, the dependency between the two CLIs is invisible from either one's interface, and a change made for the sake of one hook's command line silently reaches the other. A _-prefixed leaf module states the shared part explicitly and imports nothing from the package.

MakefileTargets dataclass

What a set of makefiles can build: the names they define, and whether any name goes.

The two facts have to travel together. A repo whose Makefile is nothing but a catch-all rule defines almost no names and can still build every one a caller asks for, so a bare set of names is not enough to answer "is this target defined?" — and both hooks ask exactly that question. :meth:defines is where the answer lives, so the two cannot drift apart.

Source code in rhiza_hooks/_makefile.py
@dataclass(frozen=True)
class MakefileTargets:
    """What a set of makefiles can build: the names they define, and whether any name goes.

    The two facts have to travel together. A repo whose Makefile is nothing but a
    catch-all rule defines almost no names and can still build every one a caller
    asks for, so a bare set of names is not enough to answer "is this target
    defined?" — and both hooks ask exactly that question. :meth:`defines` is where
    the answer lives, so the two cannot drift apart.
    """

    names: frozenset[str]
    catch_all: bool

    def defines(self, target: str) -> bool:
        """Report whether *target* can be built.

        A named rule defines it:

        >>> MakefileTargets(frozenset({"test"}), catch_all=False).defines("test")
        True
        >>> MakefileTargets(frozenset({"test"}), catch_all=False).defines("fmt")
        False

        So does a catch-all rule, whatever the name:

        >>> MakefileTargets(frozenset(), catch_all=True).defines("anything")
        True
        """
        return self.catch_all or target in self.names

    def __or__(self, other: MakefileTargets) -> MakefileTargets:
        """Merge what two makefiles define, so an ``include`` contributes both halves."""
        return MakefileTargets(self.names | other.names, self.catch_all or other.catch_all)

    def __bool__(self) -> bool:
        """Report whether anything was found — a repo with no makefile at all is falsy.

        Both hooks decline to report against nothing: with no rules to compare, every
        invocation looks undefined, which is noise rather than signal. A lone
        catch-all rule *is* something, so it is truthy.
        """
        return bool(self.names) or self.catch_all

__bool__()

Report whether anything was found — a repo with no makefile at all is falsy.

Both hooks decline to report against nothing: with no rules to compare, every invocation looks undefined, which is noise rather than signal. A lone catch-all rule is something, so it is truthy.

Source code in rhiza_hooks/_makefile.py
def __bool__(self) -> bool:
    """Report whether anything was found — a repo with no makefile at all is falsy.

    Both hooks decline to report against nothing: with no rules to compare, every
    invocation looks undefined, which is noise rather than signal. A lone
    catch-all rule *is* something, so it is truthy.
    """
    return bool(self.names) or self.catch_all

__or__(other)

Merge what two makefiles define, so an include contributes both halves.

Source code in rhiza_hooks/_makefile.py
def __or__(self, other: MakefileTargets) -> MakefileTargets:
    """Merge what two makefiles define, so an ``include`` contributes both halves."""
    return MakefileTargets(self.names | other.names, self.catch_all or other.catch_all)

defines(target)

Report whether target can be built.

A named rule defines it:

MakefileTargets(frozenset({"test"}), catch_all=False).defines("test") True MakefileTargets(frozenset({"test"}), catch_all=False).defines("fmt") False

So does a catch-all rule, whatever the name:

MakefileTargets(frozenset(), catch_all=True).defines("anything") True

Source code in rhiza_hooks/_makefile.py
def defines(self, target: str) -> bool:
    """Report whether *target* can be built.

    A named rule defines it:

    >>> MakefileTargets(frozenset({"test"}), catch_all=False).defines("test")
    True
    >>> MakefileTargets(frozenset({"test"}), catch_all=False).defines("fmt")
    False

    So does a catch-all rule, whatever the name:

    >>> MakefileTargets(frozenset(), catch_all=True).defines("anything")
    True
    """
    return self.catch_all or target in self.names

collect_targets(repo_root)

Return everything the root Makefile and its includes define, transitively.

Parameters:

Name Type Description Default
repo_root Path

Root directory of the repository.

required

Returns:

Type Description
MakefileTargets

The named targets and the catch-all flag, merged across every makefile

MakefileTargets

reached. Falsy when there is no Makefile at all.

Source code in rhiza_hooks/_makefile.py
def collect_targets(repo_root: Path) -> MakefileTargets:
    """Return everything the root Makefile and its includes define, transitively.

    Args:
        repo_root: Root directory of the repository.

    Returns:
        The named targets and the catch-all flag, merged across every makefile
        reached. Falsy when there is no Makefile at all.
    """
    targets = MakefileTargets(frozenset(), catch_all=False)
    seen: set[Path] = set()
    queue = [repo_root / "Makefile"]

    while queue:
        path = queue.pop()
        resolved = path.resolve()
        if resolved in seen or not path.is_file():
            continue
        seen.add(resolved)
        try:
            content = path.read_text(encoding="utf-8")
        except (OSError, UnicodeDecodeError):
            continue
        targets |= extract_targets(content)
        queue.extend(_include_paths(content, repo_root))

    return targets

extract_targets(content)

Extract what one makefile's content defines.

Parameters:

Name Type Description Default
content str

Contents of a Makefile

required

Returns:

Type Description
MakefileTargets

The named targets found, and whether a catch-all rule is present.

Source code in rhiza_hooks/_makefile.py
def extract_targets(content: str) -> MakefileTargets:
    """Extract what one makefile's content defines.

    Args:
        content: Contents of a Makefile

    Returns:
        The named targets found, and whether a catch-all rule is present.
    """
    return MakefileTargets(
        names=frozenset(TARGET_PATTERN.findall(content)),
        catch_all=CATCH_ALL_PATTERN.search(content) is not None,
    )