Style Guide

Every team has Python conventions a linter can’t express: no print() in committed code, no bare except:, no import *. captain-hook ships no rules of its own — you author them as StyleRule subclasses and hand them to styleguide(), which parses each edited file, runs every rule, and reports only the violations your edit actually introduced.

from __future__ import annotations

import ast

from captain_hook import Allow, Input, Warn
from captain_hook.style import StyleDiffRule, StyleRule, matchers as M, styleguide


class NoPrint(StyleRule):
    """
    print() calls don't belong in committed code:
      - {violations}

    Use a logger (logger.info(...)) instead.
    """

    tests = {
        Input(file="app.py", content="def f():\n    print('debug')\n"): Warn(),
        Input(file="app.py", content="def f():\n    logger.info('ok')\n"): Allow(),
    }
    match = M.calls("print")
    label = "print() call"


class NoBareExcept(StyleRule):
    """
    Bare `except:` swallows every error, including KeyboardInterrupt:
      - {violations}

    Catch a specific exception type instead.
    """

    tests = {
        Input(file="app.py", content="try:\n    f()\nexcept:\n    pass\n"): Warn(),
        Input(file="app.py", content="try:\n    f()\nexcept ValueError:\n    pass\n"): Allow(),
    }
    match = M.kind(ast.ExceptHandler).where(lambda n: n.type is None)
    label = "bare except"


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

    Import the names you use explicitly instead of `import *`.
    """

    tests = {
        Input(file="m.py", old="import os\n", content="from os import *\n"): Warn(),
        Input(file="m.py", old="from os import *\n", content="from os import *\nx = 1\n"): Allow(),
    }
    match = M.imports.where(lambda n: any(alias.name == "*" for alias in n.names))


styleguide(NoPrint, NoBareExcept, NoNewWildcardImport)

What to learn: A rule is a subclass whose docstring is the message{violations} is substituted at fire time, and the docstring doubles as the rule’s API-reference text. The class name is the identity (NoPrintno-print). Each rule is data: set match to a composable matcher built from the matchers module (imported as M, with an optional label) and the base class does the walking — NoPrint is just M.calls("print"), and NoBareExcept refines a node type with a one-off predicate via .where(...). The runner renders label (line N) and, crucially, drops any violation whose line you didn’t touch — so editing one function never lights up a pre-existing print() elsewhere in the file. NoNewWildcardImport subclasses StyleDiffRule instead: it flags nodes matching match in the new tree that weren’t in the old one, so it reports only what the edit added. Reach for an explicit check() method only when a rule’s logic can’t be expressed as a matcher. A single styleguide(...) call registers one hook; pass block=True or scope it with only_if= to register a second, differently-scoped hook.