Skip to content

Commit a702ffb

Browse files
Fix/deduplicate clashing star import suggestions (#318)
* Fix duplicate explicit imports from multiple star imports with clashing names (#121) When multiple star imports export the same name (e.g. defaultdict from both _collections and collections), each star import independently suggested that name, producing duplicate explicit imports after refactoring. Add a post-processing deduplication step in MainAnalyzer that iterates star imports in reverse (last-to-first, matching Python's shadowing semantics) and removes already-claimed suggestions from earlier star imports, so a single pass produces correct output. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Extend dedup to handle star+explicit clashes and add edge case tests Extend _deduplicate_star_suggestions() to also consider explicit (non-star) imports in the seen set, preventing star imports from suggesting names that already have an explicit import. This fixes duplicate output like: from json import JSONEncoder (from star expansion) from json import JSONEncoder (from explicit import) Add two new edge case test cases: - partial_overlap_star_imports: reverse order where each star import has both shared and unique names, verifying unique names are preserved - star_import_with_explicit: star import + explicit import of same name, verifying star doesn't duplicate the explicit import Update existing star_imports and 2 test expectations to reflect the fix (json star import no longer suggests JSONEncoder when explicit import exists). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f729032 commit a702ffb

File tree

15 files changed

+290
-6
lines changed

15 files changed

+290
-6
lines changed

CLAUDE.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in
4+
this repository.
5+
6+
## Project Overview
7+
8+
Unimport is a Python linter and formatter that detects and removes unused import
9+
statements. It uses `ast` for analysis and `libcst` for refactoring (preserving
10+
formatting). Supports Python 3.9–3.13.
11+
12+
## Common Commands
13+
14+
```bash
15+
# Install for development
16+
pip install -e ".[test]"
17+
18+
# Run all tests
19+
pytest tests -x -v --disable-warnings
20+
21+
# Run a single test file
22+
pytest tests/cases/test_cases.py -x -v
23+
24+
# Run a single test by name
25+
pytest tests -x -v -k "test_name"
26+
27+
# Run with coverage (used by tox)
28+
pytest -vv --cov unimport
29+
30+
# Run the tool itself
31+
unimport [sources]
32+
```
33+
34+
Linting uses pre-commit (black, isort, mypy, docformatter). Line length is 120
35+
characters.
36+
37+
## Architecture
38+
39+
### Pipeline
40+
41+
The core flow is: **parse source → analyze AST → identify unused imports → refactor with
42+
libcst**.
43+
44+
`Main.run()` in `src/unimport/main.py` orchestrates this:
45+
46+
1. `Config` resolves settings from CLI args + config files (pyproject.toml/setup.cfg)
47+
2. `Config.get_paths()` yields Python files matching include/exclude/gitignore rules
48+
3. For each file, `MainAnalyzer.traverse()` parses and analyzes the AST
49+
4. `Import.get_unused_imports()` returns unused imports from class-level state
50+
5. `refactor_string()` uses libcst to produce the cleaned source
51+
52+
### Statement Module (`src/unimport/statement.py`)
53+
54+
Central data model using **class-level mutable state** (important pattern to
55+
understand):
56+
57+
- `Import.imports` (ClassVar list) — all registered imports for current file
58+
- `Name.names` (ClassVar list) — all registered name usages for current file
59+
- `Scope.scopes` / `Scope.current_scope` (ClassVar lists) — scope tracking
60+
61+
These are populated during analysis and cleared via `MainAnalyzer.clear()` after each
62+
file. The `MainAnalyzer` context manager handles this lifecycle.
63+
64+
### Analyzers (`src/unimport/analyzers/`)
65+
66+
Three AST visitors run in sequence during `MainAnalyzer.traverse()`:
67+
68+
1. **`NameAnalyzer`** — collects all name usages (identifiers, attributes, type
69+
comments, string annotations)
70+
2. **`ImportableNameWithScopeAnalyzer`** — collects names from `__all__` definitions
71+
(for star import suggestions)
72+
3. **`ImportAnalyzer`** — collects import statements, handles `if`/`try` dispatch,
73+
generates star import suggestions
74+
75+
### Refactoring (`src/unimport/refactor.py`)
76+
77+
Uses `libcst` with `_RemoveUnusedImportTransformer` (a `CSTTransformer` with
78+
`PositionProvider` metadata) to surgically remove unused imports while preserving
79+
formatting.
80+
81+
### Commands (`src/unimport/commands/`)
82+
83+
CLI actions: `check` (report), `diff` (show changes), `remove` (apply changes),
84+
`permission` (interactive prompt). The `--remove` and `--permission` options are
85+
mutually exclusive.
86+
87+
### Config (`src/unimport/config.py`)
88+
89+
Auto-discovers `setup.cfg` or `pyproject.toml` (under `[tool.unimport]`). Config keys
90+
support both underscore (`include_star_import`) and hyphen (`include-star-import`)
91+
forms.
92+
93+
## Test Structure
94+
95+
Tests in `tests/cases/` use a **three-directory convention**:
96+
97+
- `tests/cases/source/<category>/<case>.py` — input Python source
98+
- `tests/cases/analyzer/<category>/<case>.py` — expected analysis results (`NAMES`,
99+
`IMPORTS`, `UNUSED_IMPORTS` lists)
100+
- `tests/cases/refactor/<category>/<case>.py` — expected output after refactoring
101+
102+
`test_cases.py` parametrizes over all source files and validates both analysis and
103+
refactoring. To add a new test case, create matching files in all three directories.
104+
105+
The `# unimport: skip_file` comment in source files tells unimport to skip analysis.

src/unimport/analyzers/main.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,31 @@ def traverse(self) -> None:
4444
source=self.source, include_star_import=self.include_star_import, defined_names=get_defined_names(tree)
4545
).traverse(tree)
4646

47+
self._deduplicate_star_suggestions()
48+
4749
Scope.remove_current_scope() # remove global scope
4850

4951
def skip_file(self) -> bool:
5052
SKIP_FILE_REGEX = "#.*(unimport: {0,1}skip_file)"
5153

5254
return bool(re.search(SKIP_FILE_REGEX, self.source, re.IGNORECASE))
5355

56+
@staticmethod
57+
def _deduplicate_star_suggestions() -> None:
58+
"""Remove duplicate suggestions across star and explicit imports.
59+
60+
When multiple imports provide the same name, the last one wins
61+
(matching Python's shadowing semantics). Explicit imports also
62+
claim their name so star imports don't produce duplicates.
63+
"""
64+
seen: set[str] = set()
65+
for imp in reversed(Import.imports):
66+
if isinstance(imp, ImportFrom) and imp.star:
67+
imp.suggestions = [s for s in imp.suggestions if s not in seen]
68+
seen.update(imp.suggestions)
69+
else:
70+
seen.add(imp.name)
71+
5472
@staticmethod
5573
def clear():
5674
Name.clear()

tests/cases/analyzer/star_import/2.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@
9696
name="json",
9797
package="json",
9898
star=True,
99-
suggestions=["JSONEncoder"],
99+
suggestions=[],
100100
),
101101
ImportFrom(
102102
lineno=14,
@@ -114,7 +114,7 @@
114114
name="json",
115115
package="json",
116116
star=True,
117-
suggestions=["JSONEncoder"],
117+
suggestions=[],
118118
),
119119
ImportFrom(
120120
lineno=1,
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from typing import Union
2+
3+
from unimport.statement import Import, ImportFrom, Name
4+
5+
__all__ = ["NAMES", "IMPORTS", "UNUSED_IMPORTS"]
6+
7+
8+
NAMES: list[Name] = [
9+
Name(lineno=4, name="print", is_all=False),
10+
Name(lineno=4, name="defaultdict", is_all=False),
11+
]
12+
IMPORTS: list[Union[Import, ImportFrom]] = [
13+
ImportFrom(
14+
lineno=1,
15+
column=1,
16+
name="_collections",
17+
package="_collections",
18+
star=True,
19+
suggestions=[],
20+
),
21+
ImportFrom(
22+
lineno=2,
23+
column=1,
24+
name="collections",
25+
package="collections",
26+
star=True,
27+
suggestions=["defaultdict"],
28+
),
29+
]
30+
UNUSED_IMPORTS: list[Union[Import, ImportFrom]] = [
31+
ImportFrom(
32+
lineno=2,
33+
column=1,
34+
name="collections",
35+
package="collections",
36+
star=True,
37+
suggestions=["defaultdict"],
38+
),
39+
ImportFrom(
40+
lineno=1,
41+
column=1,
42+
name="_collections",
43+
package="_collections",
44+
star=True,
45+
suggestions=[],
46+
),
47+
]
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from typing import Union
2+
3+
from unimport.statement import Import, ImportFrom, Name
4+
5+
__all__ = ["NAMES", "IMPORTS", "UNUSED_IMPORTS"]
6+
7+
8+
NAMES: list[Name] = [
9+
Name(lineno=4, name="print", is_all=False),
10+
Name(lineno=4, name="Counter", is_all=False),
11+
Name(lineno=5, name="print", is_all=False),
12+
Name(lineno=5, name="defaultdict", is_all=False),
13+
Name(lineno=6, name="print", is_all=False),
14+
Name(lineno=6, name="deque", is_all=False),
15+
]
16+
IMPORTS: list[Union[Import, ImportFrom]] = [
17+
ImportFrom(
18+
lineno=1,
19+
column=1,
20+
name="collections",
21+
package="collections",
22+
star=True,
23+
suggestions=["Counter"],
24+
),
25+
ImportFrom(
26+
lineno=2,
27+
column=1,
28+
name="_collections",
29+
package="_collections",
30+
star=True,
31+
suggestions=["defaultdict", "deque"],
32+
),
33+
]
34+
UNUSED_IMPORTS: list[Union[Import, ImportFrom]] = [
35+
ImportFrom(
36+
lineno=2,
37+
column=1,
38+
name="_collections",
39+
package="_collections",
40+
star=True,
41+
suggestions=["defaultdict", "deque"],
42+
),
43+
ImportFrom(
44+
lineno=1,
45+
column=1,
46+
name="collections",
47+
package="collections",
48+
star=True,
49+
suggestions=["Counter"],
50+
),
51+
]
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from typing import Union
2+
3+
from unimport.statement import Import, ImportFrom, Name
4+
5+
__all__ = ["NAMES", "IMPORTS", "UNUSED_IMPORTS"]
6+
7+
8+
NAMES: list[Name] = [
9+
Name(lineno=4, name="print", is_all=False),
10+
Name(lineno=4, name="defaultdict", is_all=False),
11+
]
12+
IMPORTS: list[Union[Import, ImportFrom]] = [
13+
ImportFrom(
14+
lineno=1,
15+
column=1,
16+
name="_collections",
17+
package="_collections",
18+
star=True,
19+
suggestions=[],
20+
),
21+
ImportFrom(
22+
lineno=2,
23+
column=1,
24+
name="defaultdict",
25+
package="collections",
26+
star=False,
27+
suggestions=[],
28+
),
29+
]
30+
UNUSED_IMPORTS: list[Union[Import, ImportFrom]] = [
31+
ImportFrom(
32+
lineno=1,
33+
column=1,
34+
name="_collections",
35+
package="_collections",
36+
star=True,
37+
suggestions=[],
38+
),
39+
]

tests/cases/analyzer/star_import/star_imports.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
name="json",
5353
package="json",
5454
star=True,
55-
suggestions=["JSONEncoder"],
55+
suggestions=[],
5656
),
5757
ImportFrom(
5858
lineno=6,
@@ -70,7 +70,7 @@
7070
name="json",
7171
package="json",
7272
star=True,
73-
suggestions=["JSONEncoder"],
73+
suggestions=[],
7474
),
7575
ImportFrom(
7676
lineno=4,
Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
from json import JSONEncoder
2-
from json import JSONEncoder
32

43

54
print(JSONEncoder)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from collections import defaultdict
2+
3+
print(defaultdict)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from collections import Counter
2+
from _collections import defaultdict, deque
3+
4+
print(Counter)
5+
print(defaultdict)
6+
print(deque)

0 commit comments

Comments
 (0)