pre-commit¶
The repository publishes two hooks for pre-commit
and prek, in its
.pre-commit-hooks.yaml:
| Hook id | Runs | Fails the commit when |
|---|---|---|
cleanporter |
cleanporter --whole-project FILES |
a changed file has a CP001 or CP003 (or a CP002 under --strict), or cannot be read or parsed. |
cleanporter-fix |
cleanporter --whole-project --fix FILES |
it rewrote a file (the rewrite is left in your working tree to review and stage), or something it could not rewrite remains. |
With a baseline
configured, cleanporter is the hook that gates only new findings;
cleanporter-fix does not apply the baseline (a baseline describes check
runs), so it rewrites and reports the backlog in every file it is handed.
Add one of them to your project's .pre-commit-config.yaml:
repos:
- repo: https://github.com/grAItools/cleanporter
rev: v0.5.0 # a release tag
hooks:
- id: cleanporter
# or, to rewrite what is provably safe:
# - id: cleanporter-fix
rev is a release tag. The hooks first ship in 0.5.0;
pre-commit autoupdate (prek autoupdate) moves rev to the latest one.
Both hooks run on *.py files only (types: [python] plus files: \.py$:
an extensionless script with a python shebang is left out, because the
project walk only collects *.py and would never check it), configure
themselves from [tool.cleanporter] in your pyproject.toml like any other
run, and take extra flags through args — args: [--strict], say. The
hooks' own flags are part of their entry, not their args, so setting
args adds to them rather than replacing --whole-project. pre-commit lets
you override entry on a remote hook too, so entry: cleanporter drops the
flag without a repo: local hook.
Why the whole project is read¶
pre-commit hands a hook only the files that changed. A run over those alone judges them on a partial tree, and some of what cleanporter decides is cross-file:
- Uses in other files. Before rewriting a module's own
from P import name, the fixer checks whether another file importsnamefrom that module — a re-export — since the rewrite deletes the attribute (the load-bearing guard, see Safety). A file the run does not read is not evidence, so a re-export used only by an unchanged file looks unused: check mode reportsCP001instead ofCP003, and--fixrewrites it and breaks the unchanged file at import time. - Which packages are first-party. Import roots are
inferred from the files a run is given. Given only
tests/test_app.py, a package undersrc/is not first-party at all, so its imports go to the interpreter probe, which answers from whatever is installed under that name — nothing (CP002), or an older, non-editable copy of your own package. The same inference is what tells a PEP 420 namespace directory from an import root; without the files that import it, a file under one can be given the wrong module name.
--whole-project closes both. The run reads the whole project — the
directory of the pyproject.toml the listed files sit under, walked as
cleanporter . run from that directory would walk it — so every file's
imports are evidence and every package is on the map, and then fixes,
reports and counts only the files it was given. A rewrite is judged on the
evidence of a full run over the pyproject.toml root. The exit code is
decided by the given files alone: a violation in a file you did not touch
does not block your commit, and a file elsewhere that cannot be parsed is a
warning (its imports are then missing from the evidence, which the warning
says).
Consequences worth knowing:
- One
pyproject.toml, and every file inside it. The project is thepyproject.tomlthe listed files sit under. When no listed file has one there is no telling where the project starts, and when they sit under different ones there is no single project to judge them on: either way the run exits2rather than pick one, whatever order pre-commit lists files in. Every listed file (and every file of a listed directory) must also lie inside the project's directory, both as written and with symlinks resolved. A file under nopyproject.toml(ascripts/directory beside the project), or a symlink into another project, would be judged on evidence that is not its own tree's — its neighbours never read, so a fix could delete what they import — so it is refused: it is reported asfile not processed: outside the project root …, the run exits2, and nothing at all is analysed or written. Keep such files out of the hook withfiles:orexclude:. Override the hook'sentry(entry: cleanporter) if your project has nopyproject.toml. excludeis honoured for the files pre-commit passes. A changed file thatexclude(or a skipped directory such asbuild/) leaves out of the walk is not reported, as it would not be bycleanporter .run from the project root. Without the flag, a file named on the command line is always checked.- Paths are compared as the filesystem spells them. A listed file is matched to the walked tree by its resolved path, and on a case-insensitive filesystem (macOS by default) a path spelled in a different case from the one on disk is not matched, and so not reported. pre-commit passes the paths the repository records, which match.
- Every commit reads the whole tree. That is why both hooks are
require_serial: split into parallel batches, each process would read it again. Unstaged changes are stashed by pre-commit while hooks run, so the tree read is what you are committing, plus any untracked files. pyproject.tomlnot at the repository root? Add afiles:pattern. pre-commit passes every changed Python file in the repository, and any outside the project stops the run (above; for another project, below). Scope the hook to the project,files: ^myproject/.*\.py$.
Several projects in one repository¶
A commit that touches files of two projects — a monorepo, or a nested
examples/ or benchmarks/ directory with its own pyproject.toml inside
your project — makes a --whole-project run exit 2 with the projects
named: one run judges one project. Split the hook so each entry sees one
project's files:
repos:
- repo: https://github.com/grAItools/cleanporter
rev: v0.5.0
hooks:
# The outer project, without the nested one.
- id: cleanporter
exclude: ^examples/ex/
# The nested project, on its own (drop this entry to not check it).
- id: cleanporter
name: cleanporter (examples/ex)
files: ^examples/ex/.*\.py$
The files: given here replaces the hook's own \.py$, so keep the
suffix in the pattern.
Evidence stops at the nearest pyproject.toml¶
The whole project is one pyproject.toml's directory, and nothing outside it
is read. In a repository of nested projects or a uv workspace, a consumer in
another project is invisible, as it is to any run. Say
libs/foo/pyproject.toml and apps/web/pyproject.toml, with
libs/foo/src/foo/api.py re-exporting helper (from foo.core import
helper) and only apps/web/app.py importing it from there (from foo.api
import helper). A commit touching libs/foo runs on libs/foo's project;
apps/web is not evidence, so the re-export looks unused, and
cleanporter-fix rewrites it and breaks apps/web at import time.
--whole-project judges a changed file on a full run over its own project,
not over the repository. Where projects import each other, prefer the
cleanporter check hook, and run your whole test suite after any fix.
Exit codes and cleanporter-fix¶
The exit codes are the usual ones. --fix re-checks
each file after writing it, so cleanporter-fix exits 0 when it rewrote
everything the changed files needed and 1 when something remains
(CP001 it could not reach, CP003 it declined). pre-commit fails a hook
that modified files whatever it exits with, so a commit with fixable
violations fails once, with the fixes in your working tree; review them,
stage them and commit again. A literal dotted path in another file of the
project (a monkeypatch.setattr("pkg.mod.name", ...) string) keeps the import
it names, but a dynamic string, a getattr(module, "name") or a reference
from outside the project is invisible, so run your tests before that second
commit.
Which interpreter the probe uses¶
Stdlib and third-party names are classified by importing them in an
interpreter (see the probe interpreter).
pre-commit runs the hook inside its own virtual environment and sets
$VIRTUAL_ENV to it, and that environment holds cleanporter and libcst but
none of your dependencies. Detection prefers the project's own environment
to $VIRTUAL_ENV, so:
- With a
.venvat the project root (or$UV_PROJECT_ENVIRONMENT, or a uv workspace root's.venv), detection finds it and third-party imports are classified there. Nothing to configure; the note on stderr names the environment and the hook environment it passed over. - Otherwise the hook's own environment is the one left, and every
third-party import is
CP002— reported, never rewritten, and not a failure unless--strict. To classify them, either name the interpreter that has them, withpython =in[tool.cleanporter]orargs: [--python, /path/to/python], or install them into the hook's environment withadditional_dependencies: