Skip to content

← Seams, Modules, Packaging and Tooling step 32 of 36

Hard Framework

Auditing a library for import-time side effects

Write the AST fitness function that fails the build when a library takes a decision that belonged to its users.

def audit_library(source: str) -> list[tuple[str, int, str]]:

Parse source with ast and return (code, lineno, detail) for every finding, sorted by line number and then by code.

A statement runs at import time unless it is lexically inside a def, async def or lambda body. Note the two consequences that make this more than a top-level scan:

  • A class body runs at import. class Registry: pool = ThreadPoolExecutor() is an import-time pool.
  • A default argument is evaluated at import, at the def line. def worker(pool: Executor = ThreadPoolExecutor()) -> None: creates the pool when the module loads, not when the function is called. This is ruff’s B008 and it is why the naive “skip anything inside a FunctionDef” rule is wrong.

The rules:

Code Fires on Where
L001 a call whose callee’s final attribute is set_start_method anywhere in the module
L002 a call whose callee’s final attribute is ProcessPoolExecutor, ThreadPoolExecutor, InterpreterPoolExecutor or Pool import time only
L003 a call whose callee’s final attribute is basicConfig import time only
L004 a call whose dotted callee is exactly signal.signal import time only
L005 an assignment (plain, augmented or annotated) whose target subscripts os.environ; or a call whose dotted callee is exactly os.environ.update, os.environ.setdefault or os.putenv import time only

detail is the dotted callee as written ("multiprocessing.set_start_method", "ThreadPoolExecutor"), except for the subscript form of L005, where it is "os.environ".

Why L001 fires anywhere. set_start_method is process-global and may be called once. A library that calls it — at import, in a helper, on the first use, defensively — takes a decision the application cannot take back, and no parameter the application can pass will undo it. There is no correct place for that call inside a library, so there is no scope in which the rule should be silent.

Why the others are scoped to import time. Creating a pool inside a function is exactly right; creating one when somebody types import yourlib forks worker processes inside their test suite, their linter and their documentation build. logging.basicConfig() in a function the application chose to call is a service; at import it silently reconfigures the root logger for the whole process.

Your submission must pass mypy --strict. An ast.NodeVisitor subclass is the natural shape: every visit_* method must be annotated (self, node: ast.X) -> None, and node.func is an ast.expr you must narrow before reading .id or .attr.

Loading visualization…