Style Rules¶
styleguide() turns AST-based style checks into a hook. It is a substrate: captain-hook
ships no rules of its own — you author them as StyleRule
subclasses and register them. The framework owns the plumbing — parsing, change-scoping,
message formatting, and test wiring — so a rule is usually just data: a docstring and a
matcher built from the matchers module.
Your first rule¶
A rule is a subclass. Write the message as the class docstring ({violations} is
substituted at fire time), set match to a Matcher, and
hand the class to styleguide():
from captain_hook import Allow, Input, Warn
from captain_hook.style import StyleRule, matchers as M, styleguide
class ZipStrict(StyleRule):
"""
zip() without strict=True can silently drop items:
- {violations}
Pass strict=True so length mismatches raise.
"""
tests = {
Input(file="app.py", content="zip(a, b)\n"): Warn(),
Input(file="app.py", content="zip(a, b, strict=True)\n"): Allow(),
}
match = M.calls("zip") & ~M.kwarg("strict")
label = "zip()"
styleguide(ZipStrict)
- The class name is the identity —
ZipStrictbecomeszip-strict(kebab-case). - The docstring is the message. Open it with a newline after
"""; the runner normalizes it withinspect.cleandoc, so your indentation never leaks into the output.{violations}is replaced with the rule's findings joined bysep(default a bulleted list). matchselects the offending nodes; each becomes aViolationrendered aslabel (line N).labelmay be a fixed string (as here), anode -> strcallable, or omitted — the default labels a node by its bound name, falling back toast.unparse.
Change scoping¶
A rule sees the whole post-edit file (so a check never fails to parse a partial edit
fragment) but reports only violations on the lines your edit changed. Editing one function
does not surface a pre-existing zip() in another function of the same file. A Write (whole
new file) counts as fully changed, so every violation is reported. You get full-module context
for correctness and edit-scoped reporting for signal.
Diff rules¶
When a rule must compare before and after — "did this edit introduce something?" —
subclass StyleDiffRule. It flags nodes matching match in
the new tree that were absent from the old tree (by unparsed source):
from captain_hook.style import StyleDiffRule, 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))
The pre-edit tree is reconstructed from the edit, so the rule fires only on a newly added
wildcard, not one already there. Override check() when ast.unparse isn't the right notion
of "the same construct" (e.g. comparing annotated slots by name).
The matchers module¶
Import matchers as M. A
Matcher is one composable thing: a node predicate that
is also a tree selector. Build rules by combining matchers, not by reaching for a framework
helper. Compose with a boolean algebra and refine with .where(...):
from captain_hook.style import matchers as M
M.imports & M.child_of(M.control_flow) & ~M.under(M.type_checking)
M.cls & M.private # private-named class
M.kind(ast.Lambda) # a node type with no shipped name
M.call.where(lambda n: len(n.args) > 5) # bespoke one-off predicate
| Group | Members |
|---|---|
| Operators | & (both), \| (either), ~ (negate) |
| Node categories | M.module, M.cls, M.func, M.definition (cls \| func), M.imports, M.call, M.assignment, M.control_flow, M.type_checking |
| Predicates | M.calls(name), M.kwarg(name), M.ref(name), M.named(pattern), M.kind(*types) |
| Name conventions | M.private (leading single underscore), M.dunder, M.constant (SCREAMING_SNAKE) |
| Annotations | M.annotated(inner=None) (annotated var/param/return), M.forward_ref (quoted type ref), M.future_annotations (a module with from __future__ import annotations) |
| Structure | M.under(m) (any ancestor), M.child_of(m) (immediate parent), M.following(m) (sibling after the first match) |
| Terminals | .over(tree), .violations(tree, label=None), .exists(tree), .matches(node), .diff(pre, post, key=ast.unparse, label=None) |
~M.under(x) is "not inside x" — a single negation operator, not a separate method.
The name-convention matchers mean rule files never re-declare UPPER_SNAKE / underscore
regexes; reach for M.named(r"...") only for a one-off pattern. Annotations compose like
everything else — a "no quoted annotations under PEP 563" rule is just
M.forward_ref & M.under(M.future_annotations).
Custom logic with check()¶
When a rule's logic genuinely can't be expressed as a matcher — cross-node aggregation, body
normalization, anything stateful — override check() instead of setting match. It receives
the post-edit ast.Module (or both trees for a StyleDiffRule) and yields Violations. A
matcher is still useful inside it as a selector via .over(tree):
class NoStructuralOnlyTests(StyleRule):
"""Tests that only assert with builtins exercise nothing: {violations}"""
def check(self, tree):
for fn in M.func.over(tree):
if fn.name.startswith("test_") and only_builtin_calls(fn):
yield Violation(fn.lineno, fn.name)
Scope and severity — one hook per call¶
Each styleguide(...) call registers exactly one hook, scoped by that call. Every axis —
including block-vs-warn — is per call, so split concerns into separate calls:
styleguide(NoPrint, NoBareExcept) # warn, all *.py
styleguide(NoSqlInjection, block=True, only_if=[FilePath("api/**/*.py")]) # block, api only
The built-in Tool("Edit|Write") and FilePath("*.py") guards always apply (and test files
are skipped); only_if / skip_if narrow from there. In block=True, the single hook
returns one block listing every violation at once.
| Parameter | Description |
|---|---|
*rules |
StyleRule / StyleDiffRule subclasses to apply |
block |
Block the tool call instead of warning |
only_if / skip_if |
Extra conditions, ANDed/ORed onto the built-in guards |
events |
Override the default PostToolUse targeting |
max_shown |
Maximum violations shown per rule (default 5) |
Testing rules¶
Attach inline tests to each rule and run them with capt-hook test:
tests = {
Input(file="app.py", content="zip(a, b)\n"): Warn(),
Input(file="app.py", content="zip(a, b, strict=True)\n"): Allow(),
}
Tests for every rule in a call are merged onto its hook and each Input runs through the whole
styleguide, so keep inputs minimal — a single construct that trips exactly one rule. Use
Warn(pattern=...) or Block(pattern=...) to assert against the message text.