Configuration¶
Configuration lives under [tool.cleanporter] in your pyproject.toml. There
is no separate config file format and no other location.
Where the config is found¶
cleanporter takes the first path argument of the run, resolves it, and
walks upward from there looking for the nearest pyproject.toml. The
directory holding that file becomes the project root, and every relative
path in the configuration — exclude patterns, source_roots entries — is
interpreted against it.
If no pyproject.toml is found anywhere above the first path argument,
cleanporter runs entirely on defaults, with the project root set to that path
(or its parent, if it is a file).
An unknown key inside [tool.cleanporter], a value of the wrong type, or a
scope outside the allowed set is a hard error: the run stops and exits 2
rather than silently ignoring the mistake.
Full example¶
[tool.cleanporter]
exclude = ["tests/", "src/generated_*.py"]
scope = "all" # "all" (default) or "first-party"
source_roots = ["src"] # [] = infer from the paths given
treat_unresolved_as_error = false
exempt_modules = ["six.moves"] # extends the built-in defaults
exempt_names = ["THING"]
# python = "/path/to/target/venv/bin/python" # omit = current interpreter
skip = [
{ decorator = 'field_operator|scan_operator|program', reason = "GT4Py re-parses these bodies" },
{ file = '.*conftest\.py', reason = "pytest collects fixtures from this namespace" },
]
Reference¶
| Key | Default | Description |
|---|---|---|
exclude |
[] |
Glob patterns (list of strings). Each is matched against the project-relative POSIX path of a candidate file or directory, and also against its absolute POSIX path. A pattern containing no glob metacharacter (*, ?, [) additionally matches a directory prefix, so "tests/" excludes tests/ and everything under it. |
scope |
"all" |
"all" reports violations everywhere, including stdlib and third-party imports. "first-party" reports only imports whose top-level package is one of your own analysis roots. |
source_roots |
[] |
Explicit first-party import roots — directories that are on sys.path for the code being analysed — relative to the pyproject.toml directory. Combined with, not substituted for, whatever the analysed paths themselves imply. A declared root outranks an inferred one. |
treat_unresolved_as_error |
false |
When true, CP002 (unresolved) findings count toward the failure exit code, so a run that could not classify something exits 1. |
exempt_modules |
["typing", "typing_extensions", "collections.abc", "__future__"] |
from MODULE import X is allowed when MODULE — or any ancestor of it — is in this set. Configured values are added to the built-in defaults; they never replace them. |
exempt_names |
[] |
Individual bound names that are always allowed, whatever module they came from. Checked before the module is even looked at. |
python |
absent (the current interpreter) | Path to the interpreter used for the stdlib/third-party classification probe. Must be a string; omit the key to use the interpreter running cleanporter. |
skip |
[] |
Regions of your code the tool must not analyse or rewrite, as a list of rule tables. See skip rules below. |
exempt_modules matches ancestors
The default entry typing covers from typing import TYPE_CHECKING, and
it also covers anything under it. Adding "six.moves" exempts
from six.moves import urllib as well as from six.moves.urllib.parse
import urlencode, because six.moves is an ancestor of the latter.
Default exemptions¶
Four modules are exempt out of the box:
| Module | Why |
|---|---|
typing |
The Google style guide explicitly blesses importing members of typing — from typing import TYPE_CHECKING, Any, cast. |
typing_extensions |
The backport of the same surface; treating it differently from typing would be arbitrary. |
collections.abc |
from collections.abc import Mapping, Sequence is the idiomatic spelling; collections.abc.Sequence at every use site is noise. |
__future__ |
from __future__ import annotations is the only legal spelling — there is no compliant alternative to rewrite it into. |
These are built in and cannot be switched off through configuration.
exempt_modules and --exempt only ever add to them.
Note that six.moves is not exempt by default, even though the style
guide mentions it. Add it explicitly if your codebase needs it.
How CLI flags layer on top of config¶
Flags do not replace configured values; with two exceptions they extend or strengthen them. This means a developer can tighten a run locally without having to restate what the project already declares.
| Flag | Effect on the loaded config |
|---|---|
--exempt MODULE |
Added to exempt_modules (which already contains the built-in defaults). |
--root PATH |
Appended to source_roots. Relative values resolve against the project root, i.e. the pyproject.toml directory. |
--strict |
OR-ed into treat_unresolved_as_error. --strict can turn it on; it can never turn it off. |
--python PATH |
Overrides the python key, but only when the flag is actually given. |
There is no flag that removes an exemption, drops a source root, or relaxes
treat_unresolved_as_error back to false. If you need that, change the
config file.
What gets scanned¶
Always-skipped directories¶
When cleanporter walks a directory you gave it, these are never descended
into, regardless of exclude:
- any directory whose name starts with a dot —
.git,.venv,.tox,.mypy_cache, and so on __pycache__node_modulesbuilddistsite-packages
Within the directories it does walk, cleanporter picks up files with a .py
suffix.
An explicitly named path bypasses every filter¶
Naming a file on the command line is taken as deliberate. Such a path skips
the exclude patterns and the always-skipped list entirely:
# `exclude = ["tests/"]` in pyproject.toml -- but this still runs:
cleanporter tests/test_thing.py
# so does this, despite `.venv` being an always-skipped directory name:
cleanporter .venv/lib/python3.12/site-packages/somepkg/mod.py
This only applies to paths that name a file. A directory on the command line is walked, and the walk applies both filters normally.
Scope: "all" versus "first-party"¶
With the default scope = "all", from collections import OrderedDict in
your code is a CP001 just like from mypkg.helpers import Widget is.
With scope = "first-party", only imports whose top-level package is one of
your analysis roots are considered; stdlib and third-party imports are passed
over without being resolved at all. This is a useful staging step on a large
legacy codebase: fix your own modules first, then widen to "all".
The first-party test looks at the top-level component only. If mypkg is
first-party then mypkg.anything.at.all is treated as first-party, whether or
not that dotted path exists. This fails safe — a name that does not exist is
still reported when it cannot be resolved; it is just never mistaken for a
third-party import.
Excluding files¶
exclude patterns are ordinary fnmatch globs, matched against the path as
seen from the project root:
[tool.cleanporter]
exclude = [
"tests/", # directory prefix: no metacharacters, so it and
# everything under it is excluded
"src/generated_*.py", # a glob, matched against the relative path
"docs/examples/*",
]
Two details worth knowing:
- A pattern with no glob metacharacter (
*,?,[) matches a directory prefix as well as an exact path — that is what makes"tests/"exclude the whole tree. A pattern that does contain one is matched only as a glob. - The globs are
fnmatchpatterns, not shell orpathlibones, so*matches/too."docs/examples/*"therefore covers nested files, and a pattern like"tests*"is broader than it looks. - Each pattern is tried against the project-relative path first and against the absolute POSIX path second, so an absolute pattern also works.
skip rules¶
Some bindings are load-bearing for a consumer that no analysis of the file can
see. A function body under @gtx.field_operator is re-parsed by GT4Py's own
frontend, which rejects a module-qualified call outright. A conftest.py
namespace is pytest's fixture registry. In both cases the resolver is right,
the rewrite is legal Python, and the result does not run — and the only thing
that knows is you. skip is where you say so.
[tool.cleanporter]
skip = [
{ decorator = 'field_operator|scan_operator|program' },
{ file = '.*conftest\.py' },
{ file = 'src/legacy/.*', symbol = 'load_.*' },
]
TOML's array-of-tables spelling is identical in effect, and reads better when a
rule carries a reason:
[[tool.cleanporter.skip]]
decorator = 'field_operator|scan_operator|program'
reason = "GT4Py re-parses these bodies; a module-qualified call is a DSLError"
Use TOML literal strings (single quotes) for patterns. A regex is full of
backslashes and a literal string passes them through untouched, so
'.*conftest\.py' means what it looks like — in a double-quoted string you
would have to write ".*conftest\\.py".
Keys¶
Within one table every key must match the same definition (AND). Across the list, any rule matching is enough (OR).
| Key | Selects | Matched against |
|---|---|---|
file |
A whole file when it is the only matcher; otherwise narrows the rest of the rule to that file | The path relative to the project root, POSIX-spelled. A file outside the root has no relative spelling, so it offers its absolute path instead. |
function |
A def/async def that is not directly inside a class body |
The bare name; the qualified name within the module (outer.inner); the module:qualname address (pkg.mod:outer.inner) |
method |
A def/async def directly inside a class body |
as above (Cache.get, pkg.mod:Cache.get) |
class |
A class statement |
as above |
symbol |
Any of the three above | as above |
decorator |
Any definition carrying a matching decorator | The decorator as a dotted name with any call stripped (@gtx.program(backend=...) → gtx.program), and that name's last component (program) |
reason |
Not a matcher. Free text, echoed in the CP004 finding |
— |
Every pattern is matched with re.fullmatch against each candidate for its
key; the key matches if any candidate does. Two things follow:
{ decorator = 'field_operator' }covers@field_operator,@gtx.field_operatorand@gt4py.next.field_operatoralike, because the last component is one of the candidates. Write{ decorator = 'gtx\.field_operator' }to pin one spelling.{ method = 'Cache\.get' }needs no second key: the qualified name is already a candidate. Nesting goes in the pattern.
The module:qualname candidate is the spelling
pkgutil.resolve_name
takes, and it is the precise way to name one symbol when a filename repeats
across a package tree.
Setting more than one of function / method / class / symbol in a single
table is an error: they select mutually exclusive node kinds, so the rule
could never match, and a rule that silently never fires is the worst thing this
feature could do. So are an unknown key, a non-string value, an uncompilable
pattern, and an empty table {} — which constrains nothing and would therefore
take your entire project.
What a rule skips¶
A file-only rule takes the whole file. Any other rule takes, per matching
definition, everything from its first decorator line through its last line,
nested definitions included.
Two things are then skipped, and the second is what makes the feature work:
- an import inside a skipped region, and
- any binding whose name appears anywhere inside a skipped region — pinned, wherever its import statement actually sits.
Pinning is the point. It is what keeps a module-level
from gt4py.next import broadcast intact when the only use is inside a field
operator two hundred lines below; without it the import would be rewritten and
the skipped region left holding a name that no longer exists.
The pin test asks only whether the identifier appears inside a skipped region, not whether it resolves to that import — so it pins more than you might expect, all of it deliberately:
- a local variable, parameter or keyword argument of the same name;
- the leaf of an attribute access —
mod.go()inside a region pinsgo; - a name a string in the region could be referring to, read the same way
the string guard
reads one. This is what keeps a lazy annotation safe: under
from __future__ import annotations,def op(a: "Field")is the region's only mention ofField, and it is not an identifier node at all.
Over-pinning can only ever cost you a rewrite, never cause a wrong one. But a
decorator rule over a large body does swallow a lot — check what with
--show-skipped.
The rest of the file is untouched by all this. A module with fifteen field operators still gets its plain-Python helpers rewritten.
skip is not exclude¶
They read alike and do different jobs.
exclude |
skip |
|
|---|---|---|
| Syntax | fnmatch globs | regular expressions |
| Stage | Discovery — the file is never read | Analysis — the file is read and parsed |
| Evidence | Contributes none | Still contributes re-export and package evidence |
| Granularity | Whole files | Whole files, or definitions inside them |
| Reported as | Nothing at all | CP004 |
That evidence difference is why skip is the right tool for your own code you
do not want touched. Drop a conftest.py at discovery with exclude and it
stops counting as an importer of the fixtures it pulls in — which can unblock
an unsafe rewrite in the file those fixtures live in. skip keeps reading it
and only suppresses the findings.
Use exclude for code that is not yours to fix: vendored trees, generated
files, build output.