Skip to content

style

style

Matcher dataclass

Matcher(
    test: Predicate,
    label: str = "matcher",
    structural: bool = False,
)

A composable, immutable AST matcher: a node predicate that is also a tree selector.

A Matcher wraps a single (node, parents) -> bool test. Node-local matchers (e.g. calls) ignore parents; structural matchers (e.g. under) consult it. Compose with the boolean algebra & (intersection), | (union), and ~ (negation), refine with where, and finish with a terminal — over, violations, or exists.

The module-level vocabulary (M.imports, M.control_flow, ...) and factories (M.kind, M.calls, ...) are the whole surface — author a new rule by combining them, not by reaching for a framework helper.

Example

M.imports & M.child_of(M.control_flow) & ~M.under(M.type_checking)

where
where(predicate: Callable[[AST], bool]) -> Matcher

Refine with a node-local one-off predicate — the escape hatch for bespoke conditions.

over
over(tree: AST) -> Iterator[ast.AST]

Yield every node in tree that matches.

violations
violations(
    tree: AST,
    label: str | Callable[[AST], str] | None = None,
) -> Iterator[Violation]

Yield a Violation for each match, located at its line.

diff
diff(
    pre: AST,
    post: AST,
    key: Callable[[AST], Hashable] = ast.unparse,
    label: str | Callable[[AST], str] | None = None,
) -> Iterator[Violation]

Yield violations for matches in post whose key was absent from pre.

exists
exists(tree: AST) -> bool

Return whether any node in tree matches.

matches
matches(node: AST) -> bool

Test a single node (node-local matchers only; raises for structural matchers).

StyleDiffRule

Bases: StyleRule

Base class for a diff rule: flags constructs newly introduced by the change.

Like StyleRule, but it compares the pre-edit and post-edit trees. The declarative form flags nodes matching match in the new tree that were absent from the old tree (by unparsed source); override check when the "newly introduced" identity needs custom logic.

Example
from captain_hook.style import matchers as M

class NoNewWildcardImport(StyleDiffRule):
    """Wildcard import added by this edit: {violations}"""

    match = M.imports.where(lambda n: any(a.name == "*" for a in n.names))

StyleRule

Bases: ABC

Base class for a single-tree AST style rule applied to Python edits and writes.

Subclass it and write the rule's message as the class docstring ({violations} is substituted at fire time). Declare the rule as data by setting match to a Matcher (and optionally label); override check only for logic a matcher can't express. The class name is the rule's identity — NoNestedImports becomes "no-nested-imports".

Example
from captain_hook.style import matchers as M

class NoNestedImports(StyleRule):
    """Lazy imports belong at the top of the function body: {violations}"""

    match = M.imports & M.child_of(M.control_flow) & ~M.under(M.type_checking)

Violation dataclass

Violation(line: int, label: str)

A single style violation, located by line so the runner can scope it to the edit.

Attributes:

Name Type Description
line int

1-based line number of the offending construct in the post-edit file.

label str

Short human-readable description, rendered as "{label} (line {line})".

styleguide

styleguide(
    *rules: type[StyleRule],
    block: bool = False,
    only_if: Sequence[TCondition] = (),
    skip_if: Sequence[TCondition] = (),
    events: Event | None = None,
    max_shown: int = 5,
) -> None

Register one change-scoped hook applying the given style rules to Python edits and writes.

Each rule is a StyleRule (or StyleDiffRule) subclass whose docstring is its message. The single registered hook parses the edited file once, runs every rule against the post-edit tree, scopes each violation to the changed lines, and emits one aggregated warning (or block, when block is set). Call again with different only_if / skip_if / events / block to register a separately scoped hook.

Parameters:

Name Type Description Default
rules type[StyleRule]

StyleRule / StyleDiffRule subclasses to apply.

()
block bool

Block the tool call instead of warning.

False
only_if Sequence[TCondition]

Extra conditions ANDed onto the built-in Edit|Write + *.py guards.

()
skip_if Sequence[TCondition]

Extra conditions ORed onto the built-in test-file skip.

()
events Event | None

Override the default PostToolUse targeting.

None
max_shown int

Maximum violations shown per rule.

5
Example

styleguide(NoPrint, NoBareExcept) styleguide(NoSqlInjection, block=True, only_if=[FilePath("api/*/.py")])