Skip to content
Fred Souza edited this page Aug 3, 2026 · 1 revision

lanekeep checks .py and .pyi files. Rules are TypeScript programs matching a Python syntax tree — see Home for why.

Install

pip install lanekeep
lanekeep check

Four platform wheels, no launcher — a wheel names its platform in its own filename and pip picks by that tag. Nothing is pulled in as a dependency, and Python is not required to run it: the wheel carries a static binary.

Works the same with uv, poetry or pipx:

uv add --dev lanekeep
poetry add --group dev lanekeep
pipx install lanekeep

The Linux wheels are built against glibc 2.17, so they install and run on anything from RHEL 7 onwards.

Configure

lanekeep init in a directory with a pyproject.toml writes this for you:

{
  "$schema": "https://raw.githubusercontent.com/fmsouza/lanekeep/main/schema/lanekeep.schema.json",

  "include": ["**/*.py"],
  "exclude": ["**/test_*.py"],

  "rules": [
    "lanekeep/no-broad-except",
    "./lanekeep/rules/no-print.ts"
  ]
}

Virtual environments and build output are worth excluding explicitly:

{ "exclude": ["**/test_*.py", ".venv/**", "**/migrations/**", "build/**"] }

.pyi stubs are the same language with the bodies removed, so a rule about imports or signatures applies there unchanged.

Built-in rules

lanekeep/no-broad-except

# bad
try:
    parse(raw)
except Exception:
    return None

# good
try:
    parse(raw)
except ValueError:
    return None

A bare except: also catches KeyboardInterrupt and SystemExit, so it swallows Ctrl-C. except Exception: still catches every bug in the block — a typo'd attribute, a None where an object was expected — and reports whatever the handler decided the failure was.

A project defining or importing its own Exception is not reported: bindingKind says the name is local, so it is not the builtin.

lanekeep/no-mutable-default-argument

# bad
def add(item, into=[]):
    into.append(item)

# good
def add(item, into=None):
    into = [] if into is None else into

The default is evaluated once, at definition, so every call without the argument shares one list. A project defining its own list, dict or set is not reported.

Writing a Python rule

import { defineRule } from 'lanekeep'

export default defineRule({
  id: 'local/no-requests-in-handlers',
  language: 'python',          // ← required; the default is TypeScript
  severity: 'error',

  card: {
    message: 'requests called directly from a handler',
    remediation: 'go through the client in services/http.py, which carries timeouts and retries',
    examples: {
      bad: 'requests.get(url)',
      good: 'http_client.get(url)',
    },
  },

  gates: { fileContains: ['requests'] },

  query: `
    (call
      function: (attribute
        object: (identifier) @pkg
        attribute: (identifier) @method)) @call
  `,

  check(ctx, m) {
    if (ctx.text(m.pkg) !== 'requests') return

    // A local name that happens to read `requests` is not the library.
    if (ctx.bindingKind(m.pkg) !== 'import') return

    ctx.report(m.call)
  },
})

language: 'python' is not optional. The default is ['typescript', 'tsx'], and a rule does not run on a file whose language it does not name — so omitting it means the rule silently never fires.

What resolution knows about Python

ctx.bindingKind understands:

Form Kind
import os, from a import b import
x = 1, x += 1, x := 1 assignment
def f(), class C function, class
parameters param
for x in xs loop
with open(p) as f context-manager
except E as e catch-param
[x for x in xs] comprehension

Two Python-specific behaviors worth knowing:

There is no block scope. A name bound anywhere in a function body is bound throughout it, so resolution walks a scope's whole body rather than its direct children.

A class body is opaque to functions nested inside it. def m(self): return LIMIT does not see a class-level LIMIT, and resolution reflects that rather than reporting a binding Python would not find.

Comprehension targets are scoped to the comprehension. The x in [x for x in xs] does not leak, so an outer x is still what an outer use resolves to.

Import shapes. import a.b.c binds a — the first segment. from a.b import c as d binds d, and resolves to module a.b, name c. ctx.resolvesToImport(node, { module, name }) handles the aliasing for you.

Clone this wiki locally