bare-imports
LintReports an unaliased bare import reached through at most max-attributes distinct attributes, which a from x import … would replace.
prune-inert-imports removes an import that binds a name nothing references, under drop-unreferenced, and a second import that rebinds a name an earlier import already bound, under drop-duplicates. Both facets read the binding table inlinable-bindings reads, and where removing an import could change what another module imports from this one, drop-unreferenced reports the binding rather than removing it.
The module imports os twice and imports Any and Final from typing, and only os.getcwd() and label: Final read a name. The second import os is removed as a repeat of the first, and Any is removed from its line as unread, leaving the first import os and from typing import Final.
import os
from typing import Final
path: str = os.getcwd()
label: Final = "x"
The reference count runs per bound name, so one member drops off a shared from import while its siblings stay.
Nothing reads Any or Final, and the value assignment makes this an ordinary module rather than a shim holding names for other files. The whole from typing import Any, Final line goes rather than being left as an empty from typing import, because both of its names are unread.
value = 1
Whether a name is re-exported is a fact about other files, and Prose formats one file at a time, so it never sees the sibling doing from shim import name. Two shapes in the file itself say that removing an import could break such a sibling, and in both the rule reports the binding rather than removing it.
The first is a module that writes no __all__ and binds no name of its own, counting a def, a class, a type alias, and every assignment shape from a plain name through an unpack, a for target, and a with binding. A try or an if is guarding an import rather than defining the module, so neither counts whatever its body binds, and a dunder such as __version__ stays out alongside a bare annotation such as version: str, which binds nothing when the module runs. A file whose entire content is imports and the branches guarding them is not using those names, it is carrying them, which is what a compatibility shim is.
Nothing reads shutil or sys, and the module writes no __all__ and binds no name of its own, which is the shape a compatibility shim takes. Both imports stay and each is reported, because a module that defines nothing is there to carry names for its siblings, and Prose cannot see the sibling that imports them.
import shutil
import sys
try:
import ssl
except ImportError:
ssl = None
drop-duplicates is unaffected, because removing a repeat cannot change what the module re-exports while the first import still binds the name.
Nothing reads json, and the module binds no name of its own, so one facet removes a line and the other only reports. The second line is removed and the first is reported, because removing a repeat cannot change what the module re-exports where the first import still binds the name, whereas removing that first import could.
import json
try:
import ssl
except ImportError:
ssl = None
The second is a package __init__.py, whose bindings are the package's public API whatever its __all__ says.
Everywhere else the removal stands. A module that writes __all__ has stated its public surface, so an unreferenced import drops there even where that surface is empty.
__all__ = [] names an empty public surface, which is still a write rather than an absence. dumps is removed, because the module states what it re-exports and dumps is not part of it.
__all__ = []
A module that binds names of its own drops one too, whatever its __all__. That leaves one case no signal inside the file reaches, a shim carrying a single helper function beside its re-exports, which reads as an ordinary module and loses them. Writing one of the markers below is what settles it.
An import carrying a re-export marker holds its line under both facets, so a repeated self-alias survives drop-duplicates:
__all__.from x import y as y.noqa comment trailing the import, either bare or naming F401, which keeps every name that statement binds. The marker has to open a comment rather than appear inside its text, so a stacked # type: ignore # noqa: F401 counts whereas a sentence mentioning the word does not, and a statement spanning several rows carries it on the row it opens or the row it closes.from _ssl import OPENSSL_VERSION), which is how a public module re-exports its implementation. A dunder module such as __future__ is excluded, because its names carry compiler meaning rather than a public API.# ruff: noqa: F401, # flake8: noqa: F401, or # pyright: reportUnusedImport=false, which holds every unreferenced import in the module at once. Each is what the tool naming it already reads as "the unused imports here are deliberate", so Prose reads it the same way rather than defining a spelling of its own, matching each head exactly as its own tool matches it. The spacing around a : or an = is free everywhere, flake8 reads its own name in any casing whereas ruff and pyright read only the lower-case spelling, the noqa word is read in any casing, and a pyright rule counts anywhere in its comma-separated list. Pyright sets a rule to report nothing on either false or none and reads both in any casing, whereas a severity such as error or warning leaves the rule reporting, so a pragma carrying one of those holds no import. An indented pragma sits inside a block rather than over the file, so it holds nothing. A head naming no code (a bare # ruff: noqa) is not read either, because it silences every rule its tool carries and so says nothing about re-exports in particular.Two of those markers are written in a comment rather than in code, which is where the wider ecosystem records a re-export no static read can see. band-constants reads a noqa naming E402 as pinning an import to the row its author gave it. Those readings are the whole set, so neither a noqa nor a file-level pragma exempts anything from any other rewrite or lint in Prose.
from json import loads as loads uses the PEP 484 redundant-alias form, and nothing else in the module reads loads. The import stays, because the self-alias marks the name as a re-export.
from json import loads as loads
value = 1
An __all__ built from anything other than a list or tuple of string literals, written below module scope, or changed after its assignment keeps every import in that module, as does a from … import *. A change means an append, an extend, or a write through a subscript such as __all__[:] = sorted(__all__).
Two reads the reference count misses keep an import too. A del of the bound name needs that binding to exist, and a name read only inside a quoted type expression sits in a string literal rather than in the tree the table reads, so the rule parses each one for the names it reads. A quoted type sits in one of these positions:
type Handle = "Node" or under a TypeAlias annotation, and the bound or the default of a type parameter (def f[T: "Node"]()).Optional["Node"], list["Node"]). A subscript on anything else is an ordinary lookup, so config["Node"] reads nothing.cast, assert_type, NamedTuple, NewType, ParamSpec, TypeAliasType, TypeVar, TypeVarTuple, and TypedDict. cast carries its type in the first argument and every other construct in the arguments after the name it opens on, with a keyword read the same way, so TypeVar(bound="Node") and cast(typ="Node") both count.A construct renamed on the way in still reads as the construct it names, so from typing import cast as c leaves c("Node", handle) holding what it reads.
Nothing reads Sequence, List, or IO outside a string literal, the first inside a quoted annotation, the second inside the subscript of a type alias, and the third inside the type cast takes. All three imports stay, because the rule parses every quoted type expression for the names it reads, wherever the type sits.
from typing import IO, List, Optional, Sequence, cast
Rows = Optional["List[int]"]
head: "Sequence[int]" = []
def read(stream):
return cast("IO[str]", stream)
An import binding __all__ itself sets the whole export surface, so it stays too, as does a name a second import rebinds from another source, which keeps the fallback in a try: from _speedups import loads shim in place.
A repeat of a name nothing reads takes the first binding with it, because both facets resolve in the one pass rather than one per run.
import os appears twice, and nothing reads os. Both lines are removed in one pass, because the repeat is a removal under drop-duplicates, so the rebinding it recorded no longer keeps the first import, which then drops under drop-unreferenced.
x = 1
y = 2
An own-line comment directly above an import keeps the whole statement, because removing the line would leave the comment above whatever statement follows. Where reflow-imports will merge the statement into a same-module sibling, the drop happens instead on the merged line the comment then leads.
Nothing reads the multiprocessing binding, and the comment directly above import multiprocessing.connection says the module is imported for its side effect. The import stays, because removing the line would leave the comment above whatever statement came next.
# Imported so the submodule loads and mp.connection resolves later
import multiprocessing.connection
value = 1
__future__ Directive from __future__ import annotations is removed wherever the directive changes nothing at runtime:
target-version is 3.14 or higher, where PEP 749 defers evaluation.A del of an annotated name leaves that name unresolved whatever else binds it, because removing the directive makes the annotation evaluate against the namespace the del left rather than against a string.
An annotation at module scope keeps the directive whatever its names resolve to, because the directive decides whether Python stores that annotation in the module's __annotations__ as a string or evaluates it at import time, so removing it changes what the module presents. An annotation on a def or inside a class body is stored on that object instead, and the directive can be removed once every name the annotation reads is bound ahead of it.
Where alphabetize-siblings sorts definitions in the same pipeline, a name a module-level class or function binds counts as unresolved whichever side of the annotation it sits on, since the sort moves definitions after this rule has run. A directive covering such a reference therefore stays in whichever order the sort writes. Where band-constants runs in the same pipeline, a binding it hoists above the annotation naming it counts as written before that annotation, whether the hoist moves a constant into the leading band or an import into the import run, because the rule reads the module as the band places it once the directive is gone.
convert names Alias in its annotations while Alias = int is written below it, under from __future__ import annotations. band-constants moves the assignment into the leading band, and prune-inert-imports removes the directive, because it counts the hoisted binding as written ahead of the annotations that name it.
Alias = int
def convert(value: Alias) -> Alias:
return value
Every other __future__ feature stays, because division and its siblings change how the module compiles rather than binding a name.
from __future__ import division changes how 1 / 2 compiles rather than binding a name anything reads. The line is left as written, because the rule removes only the annotations feature and every other __future__ directive stays.
from __future__ import division
x = 1 / 2
The version-gated branch does not run, so the directive goes only where the module carries no annotation or every annotation resolves against an earlier module-scope binding.
| Key | Type | Default | Meaning |
|---|---|---|---|
enabled | bool | true | Turns the rule on or off. |
drop-duplicates | bool | true | Drops an import rebinding a name an earlier import already bound to the same source. false keeps every repeat. |
drop-unreferenced | bool | true | Drops an import binding a name nothing references, unless the binding is marked for re-export or read by a del or a quoted annotation. A package __init__.py, and a module that writes no __all__ and binds no name of its own, each report an unreferenced binding rather than dropping it. false keeps every unreferenced import and reports none. |
The target-version field from the top-level Configuration gates the __future__ branch per project.
Each facet removes one kind of inert import on its own, so switching one off leaves the other running.
drop-duplicates drop-duplicates removes an import rebinding a name that an earlier import already bound to the same source, so a repeated import os keeps one line. The match reads the path as well as the name, which is why import os beside import os.path is two imports rather than a repeat, and false keeps every repeat.
drop-unreferenced drop-unreferenced removes an import binding a name nothing references, holding the line where a re-export marker or a read the count misses claims that name, and reporting rather than removing where the file reads as a compatibility shim or a package __init__.py. Setting it to false keeps every unreferenced import and reports none.
The module imports os twice and imports Any and Final from typing, and only os.getcwd() and label: Final read a name. The second import os is removed as a repeat of the first, and Any is removed from its line as unread, leaving the first import os and from typing import Final.
import os
from typing import Final
path: str = os.getcwd()
label: Final = "x"
Nothing reads dumps, loads, pack, or Any, and each of the three import lines carries a noqa comment. The json and struct lines stay and Any is removed, because # noqa: F401 names the code an unread import is reported under and a bare # noqa covers every code. The # noqa: E501 on the typing line names an unrelated code, so it marks nothing.
Nothing reads Any or Final, and the value assignment makes this an ordinary module rather than a shim holding names for other files. The whole from typing import Any, Final line goes rather than being left as an empty from typing import, because both of its names are unread.
Neither dumps nor loads is read in the module, and __all__ = ["dumps"] lists only dumps. dumps stays on the import line and loads is removed from it, leaving from json import dumps, because the export list marks one name a re-export and not the other.
Nothing reads shutil or sys, and the module writes no __all__ and binds no name of its own, which is the shape a compatibility shim takes. Both imports stay and each is reported, because a module that defines nothing is there to carry names for its siblings, and Prose cannot see the sibling that imports them.
Nothing reads OPENSSL_VERSION, _DEFAULT_CIPHERS, or namedtuple, and the file opens with a from __future__ import annotations no annotation needs. The two _ssl imports stay, whereas namedtuple and the directive are removed, because a public module taking names from its underscore-named implementation is how the standard library re-exports. __future__ stays out of that marker despite its underscores, since its names carry compiler meaning rather than a public API.
Every name here comes from pkg._impl, thing is imported twice and read once by print(thing), and nothing reads unread. The second thing import is removed and unread stays, because the private-module marker exempts an unread name and not a repeated statement.
Nothing reads json, and the module binds no name of its own, so one facet removes a line and the other only reports. The second line is removed and the first is reported, because removing a repeat cannot change what the module re-exports where the first import still binds the name, whereas removing that first import could.
import os appears twice, and nothing reads os. Both lines are removed in one pass, because the repeat is a removal under drop-duplicates, so the rebinding it recorded no longer keeps the first import, which then drops under drop-unreferenced.
import json binds only json, and nothing in the module reads it. The whole line is removed, and the blank line below it stays as the file's first line.
from __future__ import annotations as legacy turns on the same annotations feature whatever name the as clause binds, and the body below it is x = 1 with no annotation anywhere. The rule matches the directive through the as form and removes the line.
import numpy as np binds np rather than numpy, and the body below it is value = 1, which reads neither name. Nothing reads np, so the line is removed, because the rule counts references against the name an import binds rather than against the module path.
__all__ = [] names an empty public surface, which is still a write rather than an absence. dumps is removed, because the module states what it re-exports and dumps is not part of it.
import json binds the json namespace, which nothing reads, whereas from json import loads binds loads, which loads("{}") reads. import json is removed and the from line stays, because the rule counts each bound name on its own, and the file is not a package __init__.py, where an unread import would be reported instead of removed.
annotations is the first of two names on from __future__ import annotations, division, and the file carries no annotation. Only annotations and the , after it are removed, leaving from __future__ import division on the line.
annotations is the second of two names on from __future__ import division, annotations, and the file carries no annotation. annotations and the , before it are removed, leaving from __future__ import division as written.
import os, sys binds two names in one statement, and nothing in the module reads either. The whole line is removed, because every name on it is unread.
from __future__ import annotations sits between # fmt: off and # fmt: on, and the body below carries no annotation. The line stays, because the pipeline drops every edit whose range overlaps a suppressed block, so the rule's removal never reaches the file.
Nothing reads json, and a bare # prose: skip trails its import line. The import stays exactly as written, because the directive exempts the line from every rewriting rule, prune-inert-imports included.
from json import loads as loads uses the PEP 484 redundant-alias form, and nothing else in the module reads loads. The import stays, because the self-alias marks the name as a re-export.
Nothing reads shutil, and name = "compat" means the module binds a name of its own, so the import would otherwise be removed. The import stays and nothing is reported, because # ruff: noqa: F401 at the head of a file is what the wider ecosystem already writes to say its unused imports are deliberate.
Nothing reads the multiprocessing binding, and the comment directly above import multiprocessing.connection says the module is imported for its side effect. The import stays, because removing the line would leave the comment above whatever statement came next.
from json import loads appears twice, and __all__ = ["loads"] lists the name. Both lines stay, because drop-duplicates reads the same re-export markers drop-unreferenced does, and a listed name is exempt from both.
Nothing reads os after import os, and del os unbinds it. The import stays, because the del needs the binding to exist and would raise NameError without it, so the rule counts a del of the name as a use.
loads is imported from _pyjson, and a try block rebinds it from _speedups with except ImportError: pass. The first import stays, because a name a second import rebinds from another source keeps its first binding, which is the fallback the except branch leaves in place when the second import fails.
Nothing in the body reads loads, and __all__ = ["loads"] lists it. The import stays and no diagnostic is reported, because a name in __all__ is a re-export the rule leaves in place.
Nothing reads Sequence, List, or IO outside a string literal, the first inside a quoted annotation, the second inside the subscript of a type alias, and the third inside the type cast takes. All three imports stay, because the rule parses every quoted type expression for the names it reads, wherever the type sits.
from os.path import * brings in a set of names the rule has no way to list, so it cannot count what reads them. The import stays and nothing is reported, because a name whose references cannot be counted gives no grounds for removal.
__all__ = ["dumps"] sits inside an if TYPE_CHECKING: block rather than at module scope, nothing else reads dumps, and the empty __all__ below would drop the import on its own. from json import dumps stays, because the rule reads an export list only at module scope, and an __all__ it cannot read keeps every import in the module.
from io import SEEK_CUR, __all__ binds __all__ itself, and position = SEEK_CUR reads the other name. The __all__ member stays on the line, because an import binding __all__ sets the module's export list the same as a written one, and SEEK_CUR stays because it is read.
The module writes __all__ twice, first ["dumps"] and then ["loads"], and imports both names from json. Both imports stay, because the rule reads every __all__ write and takes the union rather than only the last one, even though only the second list is what the module publishes at runtime.
from .pgen2 import token is unread, and it is the line the # Local imports comment sits above, with from ..pgen2 import driver below it. band-constants sorts the import band and moves the comment onto whichever import ends up first, so when prune-inert-imports removes token, from ..pgen2 import driver moves up onto the vacated line with the heading still above it, and the comment is never left over a gap.
visit names Node in its annotations and sits above class Node, under from __future__ import annotations. alphabetize-siblings moves the class above visit, and the output reads as if the directive were no longer needed. from __future__ import annotations stays, because prune-inert-imports runs before the sort, reads Node as a binding the sort is free to move either way, and keeps the directive rather than depending on where the sort puts the class.
from pkg import a is unread and sits directly under the # Local imports comment, with a blank line between it and from pkg import b. reflow-imports merges the two statements across that blank line, so when prune-inert-imports removes a, from pkg import b moves up onto the line a had, the blank line goes with b's old position, and the comment ends up heading the import that survives.
from .pgen2 import token is the first line under the # Local imports comment, and nothing in the module reads it. reflow-imports merges that line into its .pgen2 sibling, so once it goes the comment heads from .pgen2 import driver. prune-inert-imports moves the sibling onto the vacated line rather than leaving the move to the merge on a later pass, which keeps a real import under the comment at every step.
The module top carries from __future__ import annotations, three aliased imports out of order, and three blank lines before def add. The directive is removed, collections, numpy, and requests sort, their as keywords pad into one column, and the gap closes to two blank lines before the function.
convert names Alias in its annotations while Alias = int is written below it, under from __future__ import annotations. band-constants moves the assignment into the leading band, and prune-inert-imports removes the directive, because it counts the hoisted binding as written ahead of the annotations that name it.
convert names Sequence in its annotations while from collections.abc import Sequence is written below it, the case the directive exists for. band-constants moves the import above the definition, and from __future__ import annotations is removed, because the relocated binding counts as written ahead of the annotations that name it and they resolve without the directive.
from pkg import a and from pkg import b are both unread, and a is the line the # Local imports comment heads. from pkg import b carries no comment and is removed, and with it gone there is no sibling left for reflow-imports to merge a into, so from pkg import a stays under its comment rather than being removed too, because removing it would leave the comment over nothing.
from pkg import a is never read and is the line the # Local imports comment heads, with X = 1 between it and from pkg import b. band-constants moves X = 1 below the imports, which brings the two pkg statements together, and with them adjacent from pkg import b moves up onto the line a vacates as a is removed, leaving the comment heading a real import rather than a gap.
Reports an unaliased bare import reached through at most max-attributes distinct attributes, which a from x import … would replace.
Moves each import in a run into its section, __future__ first, then bare, then external from, then local-package.
Reports a function-local binding written once and read once whose value inlines at its read at no cost.
Rewrites Optional[T], Union[X, Y], and the typing generics to the T | None, X | Y, and builtin forms the target runtime supports.
For the gate semantics, target-version in the Configuration chapter covers how the field is read across version-gated rules.