Skip to content

Preflight

#!/usr/bin/env python3
"""Static lint for generated Panel/panel-material-ui app code.

Greps the mechanical anti-patterns already documented in reviewing-panel-apps.md
and troubleshooting-panel-apps.md, before the first `panel serve`. Cheap and deterministic —
run this before spending a screenshot on a visual check.

Usage:
    python preflight.py app.py [more_files.py ...]

Exits 0 with no output if clean. Exits 1 and prints one line per violation:
    file:line: [CHECK_ID] message (see reviewing-panel-apps.md#anchor)

All checks are AST-based (no source-text regexes), so they don't fire on
docstrings/comments/strings and aren't sensitive to line breaks inside a call.
Checks that walk a single function's body use `iter_own_scope`, which — unlike
`ast.walk` — does not descend into nested `def`/`lambda`/`class`, so a construct
inside a nested closure isn't misattributed to the enclosing method.

Known false-positive class: MISSING_HOLD has no cross-function call-graph
analysis, so a helper method only ever called from inside a caller's own
`with pn.io.hold():` block is still flagged (hold is reentrant, so that's fine
as written) — read the flagged line before editing.
"""

from __future__ import annotations

import ast
import sys
from dataclasses import dataclass

LAYOUT_CONSTRUCTOR_SUFFIXES = (
    "Column",
    "Row",
    "Paper",
    "Grid",
    "Container",
    "Card",
    "Tabs",
    "Accordion",
    "GridSpec",
    "GridBox",
    "FlexBox",
)
RADIO_GROUP_NAMES = ("RadioBoxGroup", "RadioButtonGroup")
SLIDER_SUFFIX = "Slider"
SCOPE_NODES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)
WITH_NODES = (ast.With, ast.AsyncWith)
BRANCHING_NODES = (ast.If, ast.For, ast.AsyncFor, ast.While, ast.Try, ast.TryStar)
MIN_UNGROUPED_WRITES = 3  # below this, batching under pn.io.hold() isn't worth it
FuncDef = ast.FunctionDef | ast.AsyncFunctionDef
CONTAINER_LITERAL_NODES = ast.List | ast.Tuple | ast.Set | ast.ListComp | ast.SetComp


@dataclass
class Violation:
    line: int
    check: str
    message: str
    doc: str

    def format(self, path: str) -> str:
        return f"{path}:{self.line}: [{self.check}] {self.message} (see {self.doc})"


# ---------------------------------------------------------------------------
# AST helpers
# ---------------------------------------------------------------------------


def iter_own_scope(node: ast.AST):
    """DFS over *node*'s descendants, without crossing into a nested scope.

    Unlike `ast.walk`, this stops descending at a nested FunctionDef/
    AsyncFunctionDef/Lambda/ClassDef, so a `return` or a call inside a nested
    helper is never attributed to the enclosing function.
    """
    stack = list(ast.iter_child_nodes(node))
    while stack:
        child = stack.pop()
        yield child
        if isinstance(child, SCOPE_NODES):
            continue
        stack.extend(ast.iter_child_nodes(child))


def _call_name(node: ast.AST) -> str | None:
    """Trailing name of a Call's func, e.g. `pmui.Row(...)` -> 'Row'."""
    if not isinstance(node, ast.Call):
        return None
    func = node.func
    if isinstance(func, ast.Attribute):
        return func.attr
    if isinstance(func, ast.Name):
        return func.id
    return None


def _is_self_attr(node: ast.AST, name: str | None = None) -> bool:
    """True if *node* is `self.<attr>` (optionally a specific `name`)."""
    if not (isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name)):
        return False
    if node.value.id != "self":
        return False
    return name is None or node.attr == name


def _decorator_depends_call(dec: ast.AST) -> ast.Call | None:
    """The Call node if *dec* is a call to `depends`.

    Matched on the trailing attribute/name only, so `@param.depends(...)`,
    `@pn.depends(...)` (Panel's re-export), and a bare `@depends(...)` from
    `from param import depends` all qualify.
    """
    if isinstance(dec, ast.Call) and _call_name(dec) == "depends":
        return dec
    return None


def _kwarg_is(call: ast.Call, kwarg: str, value) -> bool:
    return any(
        kw.arg == kwarg and isinstance(kw.value, ast.Constant) and kw.value.value is value
        for kw in call.keywords
    )


def _returns_layout_constructor(func: FuncDef) -> ast.Return | None:
    for node in iter_own_scope(func):
        if isinstance(node, ast.Return) and node.value is not None:
            if _call_name(node.value) in LAYOUT_CONSTRUCTOR_SUFFIXES:
                return node
    return None


def _returns_nonnull_value(func: FuncDef) -> ast.Return | None:
    for node in iter_own_scope(func):
        if isinstance(node, ast.Return) and node.value is not None:
            if not (isinstance(node.value, ast.Constant) and node.value.value is None):
                return node
    return None


def _find_super_init_line(func: FuncDef) -> int | None:
    for node in iter_own_scope(func):
        if (
            isinstance(node, ast.Call)
            and isinstance(node.func, ast.Attribute)
            and node.func.attr == "__init__"
            and isinstance(node.func.value, ast.Call)
            and isinstance(node.func.value.func, ast.Name)
            and node.func.value.func.id == "super"
        ):
            return node.lineno
    return None


def _from_param_lines_before(func: FuncDef, boundary_line: int) -> list[int]:
    return [
        node.lineno
        for node in iter_own_scope(func)
        if isinstance(node, ast.Call)
        and isinstance(node.func, ast.Attribute)
        and node.func.attr == "from_param"
        and node.lineno < boundary_line
    ]


def _is_hold_context(node: ast.With | ast.AsyncWith) -> bool:
    return any(
        isinstance(item.context_expr, ast.Call) and _call_name(item.context_expr) == "hold"
        for item in node.items
    )


def _sub_bodies(stmt: ast.stmt) -> list[list[ast.stmt]]:
    """Statement lists nested directly inside a branching statement."""
    bodies = [
        body for attr in ("body", "orelse", "finalbody") if (body := getattr(stmt, attr, None))
    ]
    return bodies + [handler.body for handler in getattr(stmt, "handlers", [])]


def _is_redraw_write(stmt: ast.stmt) -> bool:
    """True if *stmt* is a property write Panel would redraw on.

    Either `x.y = ...` where `x` is itself an attribute (e.g.
    `self._widget.value =`), or `.update(...)` on such an attribute (the
    Placeholder/pane idiom, e.g. `self._content.update(step)`) — a method call
    rather than an assignment, but the same redraw-per-call cost `pn.io.hold()`
    exists to batch.
    """
    if isinstance(stmt, ast.Assign):
        return any(
            isinstance(t, ast.Attribute) and isinstance(t.value, ast.Attribute)
            for t in stmt.targets
        )
    if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
        func = stmt.value.func
        return (
            isinstance(func, ast.Attribute)
            and func.attr == "update"
            and isinstance(func.value, ast.Attribute)
        )
    return False


def _ungrouped_redraw_writes(func: FuncDef) -> list[ast.stmt]:
    """Redraw-triggering writes in *func* that aren't inside a `pn.io.hold()`."""
    hits: list[ast.stmt] = []

    def walk(body: list[ast.stmt], inside_hold: bool) -> None:
        for stmt in body:
            if isinstance(stmt, WITH_NODES):
                walk(stmt.body, inside_hold or _is_hold_context(stmt))
            elif isinstance(stmt, BRANCHING_NODES):
                for sub in _sub_bodies(stmt):
                    walk(sub, inside_hold)
            elif not inside_hold and _is_redraw_write(stmt):
                hits.append(stmt)

    walk(func.body, False)
    return hits


# ---------------------------------------------------------------------------
# Checks — each takes the parsed module and returns its violations
# ---------------------------------------------------------------------------


def _check_depends(func: FuncDef) -> list[Violation]:
    depends = next(
        (call for dec in func.decorator_list if (call := _decorator_depends_call(dec))), None
    )
    if depends is None:
        return []

    if _kwarg_is(depends, "watch", True):
        ret = _returns_nonnull_value(func)
        if ret is None:
            return []
        return [
            Violation(
                ret.lineno,
                "WATCH_RETURNS_VALUE",
                f"`{func.name}` is @param.depends(..., watch=True) but returns a value — "
                "watch=True is for side effects (won't be displayed anywhere); split into "
                "a watch=True updater plus a plain @param.depends view method.",
                "reviewing-panel-apps.md#watch-vs-depends-misuse",
            )
        ]

    ret = _returns_layout_constructor(func)
    if ret is None:
        return []
    return [
        Violation(
            ret.lineno,
            "FLICKER_RECREATED_LAYOUT",
            f"`{func.name}` is @param.depends (no watch=True) and returns a layout/widget "
            "constructor — recreates it on every dependency change, causing flicker. Create "
            "once in __init__, update properties or use pn.pane.Placeholder instead.",
            "reviewing-panel-apps.md#flickering-from-recreated-components",
        )
    ]


def _check_from_param_before_super(func: FuncDef) -> list[Violation]:
    boundary = _find_super_init_line(func)
    if boundary is None:
        return []
    return [
        Violation(
            line,
            "FROM_PARAM_BEFORE_SUPER",
            ".from_param() widget created before super().__init__() — its watchers/"
            "@param.depends will silently never fire. Move it after super().__init__().",
            "reviewing-panel-apps.md#from_param-widgets-created-before-super",
        )
        for line in _from_param_lines_before(func, boundary)
    ]


def _check_missing_hold(func: FuncDef) -> list[Violation]:
    hits = _ungrouped_redraw_writes(func)
    if len(hits) < MIN_UNGROUPED_WRITES:
        return []
    return [
        Violation(
            hits[0].lineno,
            "MISSING_HOLD",
            f"`{func.name}` makes {len(hits)} ungrouped widget/pane property assignments — "
            "wrap in `with pn.io.hold(): ...` to batch into one redraw. Known false-positive "
            "case: if every call site already wraps the call to this method in "
            "`pn.io.hold()`, this is fine as-is (hold is reentrant) — this check has no "
            "cross-function call-site analysis, so verify the callers before editing.",
            "panel/SKILL.md#performance",
        )
    ]


def check_function_level(tree: ast.Module) -> list[Violation]:
    violations: list[Violation] = []
    for node in ast.walk(tree):
        if not isinstance(node, FuncDef):
            continue
        violations += _check_depends(node)
        if node.name == "__init__":
            violations += _check_from_param_before_super(node)
        else:
            violations += _check_missing_hold(node)
    return violations


def check_dict_selector_objects(tree: ast.Module) -> list[Violation]:
    return [
        Violation(
            node.lineno,
            "DICT_SELECTOR_OBJECTS",
            "A dict assigned to a Selector's .objects — renders blank. Use a list, or a dict "
            "passed to the Selector constructor's `objects=` kwarg instead.",
            "troubleshooting-panel-apps.md#select-renders-blank-after-setting-objects",
        )
        for node in ast.walk(tree)
        if isinstance(node, ast.Assign) and isinstance(node.value, ast.Dict)
        for target in node.targets
        if isinstance(target, ast.Attribute) and target.attr == "objects"
    ]


def check_radio_default_none(tree: ast.Module) -> list[Violation]:
    return [
        Violation(
            node.lineno,
            "RADIO_DEFAULT_NONE",
            f"{_call_name(node)} constructed with a None default — the first option can't be "
            "selected. Set an explicit non-None default.",
            "troubleshooting-panel-apps.md"
            "#first-option-cant-be-selected-selection-widget-with-defaultnone",
        )
        for node in ast.walk(tree)
        if isinstance(node, ast.Call)
        and _call_name(node) in RADIO_GROUP_NAMES
        and (_kwarg_is(node, "value", None) or _kwarg_is(node, "default", None))
    ]


def check_mutating_params(tree: ast.Module) -> list[Violation]:
    """In-place mutation of `self.<name>` where `<name>` has no leading underscore.

    The underscore is this codebase's convention for "private instance state,
    not a watched param" (see panel/SKILL.md naming conventions), so excluding
    it keeps the noise down.

    `dict.update()` — the third in-place form reviewing-panel-apps.md names — is
    deliberately not checked: `.update()` on `self.<attr>` is also the idiom for
    pushing content into a `pn.pane.Placeholder` (see `_is_redraw_write`), so the
    rule would be dominated by false positives from that idiom.
    """
    violations = []
    for node in ast.walk(tree):
        if (
            isinstance(node, ast.Call)
            and isinstance(node.func, ast.Attribute)
            and node.func.attr == "append"
            and _is_self_attr(node.func.value)
            and not node.func.value.attr.startswith("_")
        ):
            attr = node.func.value.attr
            violations.append(
                Violation(
                    node.lineno,
                    "MUTATING_PARAM_LIST",
                    f"In-place `.append()` on `self.{attr}` — won't trigger watchers if this "
                    f"is a param. Reassign the whole list instead: `self.{attr} = "
                    f"self.{attr} + [item]`.",
                    "reviewing-panel-apps.md#mutating-instead-of-reassigning",
                )
            )
        if isinstance(node, ast.Assign | ast.AugAssign):
            targets = node.targets if isinstance(node, ast.Assign) else [node.target]
            for target in targets:
                if (
                    isinstance(target, ast.Subscript)
                    and _is_self_attr(target.value)
                    and not target.value.attr.startswith("_")
                ):
                    violations.append(
                        Violation(
                            node.lineno,
                            "MUTATING_PARAM_ITEM",
                            f"In-place item assignment on `self.{target.value.attr}` — won't "
                            "trigger watchers if this is a param. Reassign the whole object "
                            "instead.",
                            "reviewing-panel-apps.md#mutating-instead-of-reassigning",
                        )
                    )
        # Scoped to a container-literal RHS so ordinary numeric accumulators
        # (`self.counter += 1`) aren't flagged — there's no type info here, so
        # the RHS shape is the only available signal.
        if (
            isinstance(node, ast.AugAssign)
            and isinstance(node.op, ast.Add)
            and isinstance(node.target, ast.Attribute)
            and _is_self_attr(node.target)
            and not node.target.attr.startswith("_")
            and isinstance(node.value, CONTAINER_LITERAL_NODES)
        ):
            attr = node.target.attr
            violations.append(
                Violation(
                    node.lineno,
                    "MUTATING_PARAM_AUGASSIGN",
                    f"`self.{attr} += ...` against a list/tuple/set literal — list.__iadd__ "
                    "mutates in place and rebinds the same object, so the param may not "
                    f"detect a change and skip watchers. Reassign instead: `self.{attr} = "
                    f"self.{attr} + [...]`.",
                    "reviewing-panel-apps.md#mutating-instead-of-reassigning",
                )
            )
    return violations


def check_throttled(tree: ast.Module) -> list[Violation]:
    has_slider = False
    has_throttled = False
    for node in ast.walk(tree):
        if isinstance(node, ast.Call):
            name = _call_name(node)
            if name and name.endswith(SLIDER_SUFFIX):
                has_slider = True
            if name == "extension" and _kwarg_is(node, "throttled", True):
                has_throttled = True
        # pn.config.throttled = True
        if isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant):
            has_throttled = has_throttled or any(
                isinstance(target, ast.Attribute)
                and target.attr == "throttled"
                and isinstance(target.value, ast.Attribute)
                and target.value.attr == "config"
                and node.value.value is True
                for target in node.targets
            )
    if not (has_slider and not has_throttled):
        return []
    return [
        Violation(
            1,
            "MISSING_THROTTLED",
            "Sliders are used but throttled mode isn't set (`pn.extension(throttled=True)` "
            "or `pn.config.throttled = True`) — every drag pixel fires a callback instead "
            "of only on release.",
            "panel/SKILL.md#widgets-and-extensions",
        )
    ]


def lint_file(path: str) -> list[Violation]:
    with open(path, encoding="utf-8") as f:
        source = f.read()
    try:
        tree = ast.parse(source, filename=path)
    except SyntaxError as e:
        return [Violation(e.lineno or 1, "SYNTAX_ERROR", str(e), "-")]
    violations = [
        *check_function_level(tree),
        *check_dict_selector_objects(tree),
        *check_radio_default_none(tree),
        *check_mutating_params(tree),
        *check_throttled(tree),
    ]
    violations.sort(key=lambda v: v.line)
    return violations


def main(argv: list[str]) -> int:
    if not argv:
        print("usage: python preflight.py app.py [more_files.py ...]", file=sys.stderr)
        return 2
    total = 0
    for path in argv:
        violations = lint_file(path)
        for v in violations:
            print(v.format(path))
        total += len(violations)
    if total == 0:
        print(f"preflight: clean ({len(argv)} file(s) checked)")
        return 0
    print(f"\npreflight: {total} violation(s) across {len(argv)} file(s)", file=sys.stderr)
    return 1


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))