Skip to content

Commit 1b8b089

Browse files
Fix subpackage import incorrectly removed when sibling subpackage is used (#180) (#322)
* Fix subpackage import incorrectly removed when sibling subpackage is used (#180) `import a.b` binds `a` in the namespace, so any `a.x.y` reference relies on that binding. `is_match_sub_packages` was comparing the import's root package against the full name string instead of just its first component, causing attribute usages like `urllib.parse.urlparse` to not match `import urllib.request`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Prefer more specific import over sub-package match When multiple imports share the same root package (e.g. import urllib.request and import urllib.parse), is_match_sub_packages was matching both to any urllib.* name usage, causing false negatives. Added _has_more_specific_import to check if another import provides a more direct path match for the name, so the sub-package fallback only applies when no specific import exists. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Apply black formatting and add issue fix checklist to CLAUDE.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add docs for subpackage imports and deferred execution behaviors Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f6cf9c5 commit 1b8b089

18 files changed

+216
-3
lines changed

CLAUDE.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,18 @@ Tests in `tests/cases/` use a **three-directory convention**:
103103
refactoring. To add a new test case, create matching files in all three directories.
104104

105105
The `# unimport: skip_file` comment in source files tells unimport to skip analysis.
106+
107+
## Issue Fix Checklist
108+
109+
When fixing a bug or implementing a feature from a GitHub issue, follow these steps in
110+
order:
111+
112+
1. **Understand the issue** — read the issue, reproduce the problem, identify root cause
113+
2. **Create a branch**`git checkout -b fix/<short-description>`
114+
3. **Implement the fix** — make the minimal code change needed
115+
4. **Write test cases** — create matching files in all three test directories
116+
(`source/`, `analyzer/`, `refactor/`). Cover edge cases
117+
5. **Run tests**`pytest tests -x -v --disable-warnings`
118+
6. **Run pre-commit**`pre-commit run --all-files` (black, isort, mypy, docformatter)
119+
7. **Fix any linting issues** — re-run tests after fixes
120+
8. **Commit and push** — commit with descriptive message, push to remote

docs/tutorial/supported-behaviors.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,47 @@ explicit import, star imports won't produce a duplicate for that name.
188188

189189
---
190190

191+
## Subpackage Imports
192+
193+
`import a.b` binds `a` in the namespace, so any reference to `a.*` relies on that
194+
import. Unimport recognizes this and won't remove a subpackage import when its root
195+
package is used in the code.
196+
197+
```python
198+
import urllib.request
199+
200+
def parse_url(url):
201+
return urllib.parse.urlparse(url)
202+
```
203+
204+
In this example, `import urllib.request` is kept because removing it would remove
205+
`urllib` from the namespace, breaking the `urllib.parse.urlparse()` call.
206+
207+
However, when a more specific import exists, unimport correctly identifies the redundant
208+
one:
209+
210+
**input**
211+
212+
```python
213+
import urllib.request
214+
import urllib.parse
215+
216+
urllib.parse.urlparse('http://example.com')
217+
```
218+
219+
**output**
220+
221+
```python
222+
import urllib.parse
223+
224+
urllib.parse.urlparse('http://example.com')
225+
```
226+
227+
Here `import urllib.parse` directly provides the needed namespace, so
228+
`import urllib.request` is correctly flagged as unused.
229+
230+
---
231+
191232
## Scope
192233

193234
Unimport tries to better understand whether the import is unused by performing scope
@@ -250,3 +291,35 @@ class Klass:
250291

251292
x
252293
```
294+
295+
### Deferred Execution
296+
297+
Function and async function bodies are deferred — they only execute when called. So a
298+
module-level import that appears textually after a function definition is still
299+
available when the function runs. Unimport understands this and won't remove such
300+
imports.
301+
302+
```python
303+
def bob():
304+
print(sys.path)
305+
306+
import sys # kept: sys is available when bob() is called
307+
```
308+
309+
This applies to regular functions, async functions, nested functions, and methods:
310+
311+
```python
312+
async def fetch():
313+
return aiohttp.get(url)
314+
315+
import aiohttp # kept
316+
```
317+
318+
Class bodies execute immediately (not deferred), so the lineno check still applies:
319+
320+
```python
321+
class Foo:
322+
x = sys.platform
323+
324+
import sys # removed: class body runs before this import
325+
```

src/unimport/statement.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def __len__(self) -> int:
2323
return len(self.name.split("."))
2424

2525
def is_match_sub_packages(self, name_name: str) -> bool:
26-
return self.name.split(".")[0] == name_name
26+
return self.name.split(".")[0] == name_name.split(".")[0]
2727

2828
@property
2929
def scope(self):
@@ -149,13 +149,25 @@ def _is_deferred_usage(self, imp: Import | ImportFrom) -> bool:
149149
scope = scope.parent
150150
return False
151151

152+
def _has_more_specific_import(self, imp: Import | ImportFrom) -> bool:
153+
name_parts = self.name.split(".")
154+
for other_imp in Import.imports:
155+
if other_imp is imp:
156+
continue
157+
other_parts = other_imp.name.split(".")
158+
if len(other_parts) <= len(name_parts) and name_parts[: len(other_parts)] == other_parts:
159+
return True
160+
return False
161+
152162
def match_2(self, imp: Import | ImportFrom) -> bool:
153163
if self.is_all:
154164
is_match = self.name == imp.name
155165
elif self.is_attribute:
156-
is_match = (imp.lineno < self.lineno or self._is_deferred_usage(imp)) and (
157-
".".join(self.name.split(".")[: len(imp)]) == imp.name or imp.is_match_sub_packages(self.name)
166+
primary_match = ".".join(self.name.split(".")[: len(imp)]) == imp.name
167+
sub_match = (
168+
not primary_match and imp.is_match_sub_packages(self.name) and not self._has_more_specific_import(imp)
158169
)
170+
is_match = (imp.lineno < self.lineno or self._is_deferred_usage(imp)) and (primary_match or sub_match)
159171
else:
160172
is_match = (imp.lineno < self.lineno or self._is_deferred_usage(imp)) and (
161173
self.name == imp.name or imp.is_match_sub_packages(self.name)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
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="urllib.parse.urlparse", is_all=False),
10+
Name(lineno=4, name="urllib.parse", is_all=False),
11+
Name(lineno=4, name="url", is_all=False),
12+
]
13+
IMPORTS: list[Union[Import, ImportFrom]] = [
14+
Import(lineno=1, column=1, name="urllib.request", package="urllib.request"),
15+
]
16+
UNUSED_IMPORTS: list[Union[Import, ImportFrom]] = []
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
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=3, name="print", is_all=False),
10+
Name(lineno=3, name="os.path.join", is_all=False),
11+
Name(lineno=3, name="os.path", is_all=False),
12+
]
13+
IMPORTS: list[Union[Import, ImportFrom]] = [
14+
Import(lineno=1, column=1, name="os.path", package="os.path"),
15+
]
16+
UNUSED_IMPORTS: list[Union[Import, ImportFrom]] = []
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
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=3, name="print", is_all=False),
10+
Name(lineno=3, name="os.getcwd", is_all=False),
11+
]
12+
IMPORTS: list[Union[Import, ImportFrom]] = [
13+
Import(lineno=1, column=1, name="os.path", package="os.path"),
14+
]
15+
UNUSED_IMPORTS: list[Union[Import, ImportFrom]] = []
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
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=3, name="print", is_all=False),
10+
]
11+
IMPORTS: list[Union[Import, ImportFrom]] = [
12+
Import(lineno=1, column=1, name="urllib.request", package="urllib.request"),
13+
]
14+
UNUSED_IMPORTS: list[Union[Import, ImportFrom]] = [
15+
Import(lineno=1, column=1, name="urllib.request", package="urllib.request"),
16+
]
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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="urllib.parse.urlparse", is_all=False),
10+
Name(lineno=4, name="urllib.parse", is_all=False),
11+
]
12+
IMPORTS: list[Union[Import, ImportFrom]] = [
13+
Import(lineno=1, column=1, name="urllib.request", package="urllib.request"),
14+
Import(lineno=2, column=1, name="urllib.parse", package="urllib.parse"),
15+
]
16+
UNUSED_IMPORTS: list[Union[Import, ImportFrom]] = [
17+
Import(lineno=1, column=1, name="urllib.request", package="urllib.request"),
18+
]
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import urllib.request
2+
3+
def parse_url(url):
4+
return urllib.parse.urlparse(url)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import os.path
2+
3+
print(os.path.join("a", "b"))

0 commit comments

Comments
 (0)