Skip to content

Commit 2d2c200

Browse files
hakancelikdevclaude
andcommitted
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>
1 parent f729032 commit 2d2c200

File tree

5 files changed

+174
-0
lines changed

5 files changed

+174
-0
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: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,28 @@ 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 imports.
59+
60+
When multiple star imports export the same name, the last one wins
61+
(matching Python's shadowing semantics).
62+
"""
63+
seen: set[str] = set()
64+
for imp in reversed(Import.imports):
65+
if isinstance(imp, ImportFrom) and imp.star:
66+
imp.suggestions = [s for s in imp.suggestions if s not in seen]
67+
seen.update(imp.suggestions)
68+
5469
@staticmethod
5570
def clear():
5671
Name.clear()
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: 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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from _collections import *
2+
from collections import *
3+
4+
print(defaultdict)

0 commit comments

Comments
 (0)