Skip to content

Commit 2a2bef0

Browse files
authored
Added removed import handling (#5)
* Recognize if import other than `typing` was removed from main import block * Add test cases * Add `--concurrent-files` option
1 parent 3df9620 commit 2a2bef0

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+681
-24
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ Always print verbose logging.
6666
Max number of files that should be changed. No performance improvements,
6767
since the limit is only applied **after** all files have been processed.
6868

69+
**`--concurrent-files`**
70+
Number of files to process concurrently during initial load.
71+
6972
**`--full-reorder`**
7073
Use additional options from [python-reorder-imports][pri] to rewrite
7174
- `--py38-plus` (default): Imports from `mypy_extensions` and `typing_extensions` when possible.

python_typing_update/__main__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,21 @@
1313
logger = logging.getLogger(__name__)
1414

1515

16+
class CustomHelpFormatter(argparse.HelpFormatter):
17+
def __init__(
18+
self, prog: str, indent_increment: int = 2,
19+
max_help_position: int = 24, width: int | None = None,
20+
) -> None:
21+
max_help_position = 40
22+
super().__init__(
23+
prog, indent_increment=indent_increment,
24+
max_help_position=max_help_position, width=width)
25+
26+
1627
async def async_main(argv: list[str] | None = None) -> int:
1728
parser = argparse.ArgumentParser(
1829
description="Tool to update Python typing syntax.",
30+
formatter_class=CustomHelpFormatter,
1931
)
2032
parser.add_argument(
2133
'-v', '--verbose',
@@ -30,6 +42,10 @@ async def async_main(argv: list[str] | None = None) -> int:
3042
'--limit', type=int, default=0,
3143
help="Max number of files that should be changed. No performance improvement!",
3244
)
45+
parser.add_argument(
46+
'--concurrent-files', metavar="NUM", type=int, default=100,
47+
help="Number of files to process concurrently during initial load. (default: %(default)s)"
48+
)
3349
parser.add_argument(
3450
'--full-reorder',
3551
action='store_true',

python_typing_update/const.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
# ---------------------------------------------------------------------------
22
# Licensed under the MIT License. See LICENSE file for license information.
33
# ---------------------------------------------------------------------------
4+
from __future__ import annotations
5+
46
from enum import Flag, auto
7+
from typing import NamedTuple
58

69
version = (0, 2, 0)
710
dev_version = 1
@@ -11,6 +14,11 @@
1114
version_str += f'-dev{dev_version}'
1215

1316

17+
class FileAttributes(NamedTuple):
18+
status: FileStatus
19+
imports: set[str]
20+
21+
1422
class FileStatus(Flag):
1523
CLEAR = 0
1624
COMMENT = auto()

python_typing_update/main.py

Lines changed: 61 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,22 @@
66
import argparse
77
import asyncio
88
import builtins
9+
import io
910
import logging
11+
from collections.abc import Iterable
1012
from functools import partial
1113
from io import StringIO
1214

15+
import aiofiles
1316
import reorder_python_imports
1417
from autoflake import _main as autoflake_main
1518
from isort.main import main as isort_main
1619
from pyupgrade._main import main as pyupgrade_main
1720

18-
from .const import FileStatus
21+
from .const import FileAttributes, FileStatus
1922
from .utils import (
2023
async_check_uncommitted_changes, async_restore_files,
21-
check_comment_between_imports, check_files_exist)
24+
check_comment_between_imports, check_files_exist, extract_imports)
2225

2326
logger = logging.getLogger("typing-update")
2427

@@ -107,6 +110,32 @@ async def typing_update(
107110
return 0, filename
108111

109112

113+
async def async_load_files(
114+
args: argparse.Namespace,
115+
filenames: Iterable[str], *,
116+
check_comments: bool,
117+
) -> dict[str, FileAttributes]:
118+
"""Process files from file list."""
119+
active_tasks: int = 0
120+
121+
async def async_load_file(filename: str) -> tuple[str, FileAttributes]:
122+
"""Load file into memory and perform token analysis."""
123+
nonlocal active_tasks
124+
while active_tasks > args.concurrent_files:
125+
await asyncio.sleep(0)
126+
active_tasks += 1
127+
async with aiofiles.open(filename, encoding="utf-8") as fp:
128+
data = await fp.read()
129+
file_status = check_comment_between_imports(io.StringIO(data)) \
130+
if check_comments is True else FileStatus.CLEAR
131+
imports_set = extract_imports(io.StringIO(data))
132+
active_tasks -= 1
133+
return filename, FileAttributes(file_status, imports_set)
134+
135+
results = await asyncio.gather(*[async_load_file(file_) for file_ in filenames])
136+
return dict(results)
137+
138+
110139
async def async_run(args: argparse.Namespace) -> int:
111140
"""Update Python typing syntax.
112141
@@ -129,15 +158,11 @@ async def async_run(args: argparse.Namespace) -> int:
129158
print("Abort! Commit all changes to '.py' files before running again.")
130159
return 11
131160

132-
filenames: dict[str, FileStatus] = {}
133-
for filename in args.filenames:
134-
with open(filename) as fp:
135-
result = check_comment_between_imports(fp)
136-
filenames[filename] = result
161+
filenames: dict[str, FileAttributes] = await async_load_files(args, args.filenames, check_comments=True)
137162

138163
if args.only_force:
139-
filenames = {filename: file_status for filename, file_status in filenames.items()
140-
if file_status != FileStatus.CLEAR}
164+
filenames = {filename: attrs for filename, attrs in filenames.items()
165+
if attrs.status != FileStatus.CLEAR}
141166

142167
loop = asyncio.get_running_loop()
143168
files_updated: list[str] = []
@@ -148,7 +173,7 @@ async def async_run(args: argparse.Namespace) -> int:
148173
builtins.print = lambda *args, **kwargs: None
149174

150175
return_values = await asyncio.gather(
151-
*[typing_update(loop, filename, args, file_status) for filename, file_status in filenames.items()])
176+
*[typing_update(loop, filename, args, attrs.status) for filename, attrs in filenames.items()])
152177
for status, filename in return_values:
153178
if status == 0:
154179
files_updated.append(filename)
@@ -176,35 +201,53 @@ async def async_run(args: argparse.Namespace) -> int:
176201

177202
files_updated_set: set[str] = set(files_updated)
178203
files_with_comments = sorted([
179-
filename for filename, file_status in filenames.items()
180-
if FileStatus.COMMENT in file_status and filename in files_updated_set
204+
filename for filename, attrs in filenames.items()
205+
if FileStatus.COMMENT in attrs.status and filename in files_updated_set
181206
])
182-
if files_with_comments:
207+
files_imports_changed: list[str] = []
208+
for file_, attrs in (await async_load_files(args, files_updated_set, check_comments=False)).items():
209+
import_diff = filenames[file_].imports.difference(attrs.imports)
210+
for import_ in import_diff:
211+
if not import_.startswith('typing'):
212+
files_imports_changed.append(file_)
213+
break
214+
files_imports_changed = sorted(files_imports_changed)
215+
files_no_automatic_update = set(files_with_comments + files_imports_changed)
216+
217+
if files_no_automatic_update:
183218
if args.force or args.only_force:
184219
print("Force mode selected!")
185220
print("Make sure to double check:")
186221
for file_ in files_with_comments:
187222
print(f" - {file_}")
223+
if files_with_comments and files_imports_changed:
224+
print(" --")
225+
for file_ in files_imports_changed:
226+
print(f" - {file_}")
188227
else:
189228
print("Could not update all files, check:")
190229
for file_ in files_with_comments:
191230
print(f" - {file_}")
192-
await async_restore_files(files_with_comments)
231+
if files_with_comments and files_imports_changed:
232+
print(" --")
233+
for file_ in files_imports_changed:
234+
print(f" - {file_}")
235+
await async_restore_files(files_no_automatic_update)
193236

194237
print("---")
195238
print(f"All files: {len(filenames)}")
196239
print(f"No changes: {len(files_no_changes)}")
197-
print(f"Files updated: {len(files_updated) - len(files_with_comments)}")
198-
print(f"Files (no automatic update): {len(files_with_comments)}")
240+
print(f"Files updated: {len(files_updated) - len(files_no_automatic_update)}")
241+
print(f"Files (no automatic update): {len(files_no_automatic_update)}")
199242

200243
if (
201-
not files_with_comments
244+
not files_no_automatic_update
202245
and not args.force
203246
and not args.only_force
204247
and args.verbose == 0
205248
):
206249
return 0
207-
if files_with_comments:
250+
if files_no_automatic_update:
208251
return 2
209252
if args.verbose > 0:
210253
return 12

python_typing_update/utils.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,58 @@ def check_comment_between_imports(fp: TextIO) -> FileStatus:
113113
# Report all comments in the main import block
114114
return FileStatus.COMMENT
115115
return FileStatus.CLEAR
116+
117+
118+
def extract_imports(fp: TextIO) -> set[str]:
119+
"""Create set of all imports in main import block."""
120+
flag_in_import_block: bool = False
121+
flag_relative_import: bool | None = None
122+
flag_imports: bool = False
123+
flag_last_token_name: bool = False
124+
current_package: str = ''
125+
imports: set[str] = set()
126+
127+
tokens = tokenize.generate_tokens(fp.readline)
128+
while True:
129+
try:
130+
t = next(tokens)
131+
if flag_in_import_block is True:
132+
if t.type == token.NEWLINE:
133+
if flag_relative_import is False:
134+
imports.add(current_package)
135+
flag_in_import_block = False
136+
flag_relative_import = None
137+
flag_imports = False
138+
flag_last_token_name = False
139+
current_package = ''
140+
elif t.type == token.NAME and t.string == 'import':
141+
flag_imports = True
142+
elif t.type == token.NAME and flag_last_token_name is False:
143+
if (
144+
flag_relative_import is False
145+
or flag_relative_import is True
146+
and flag_imports is False
147+
):
148+
current_package += t.string
149+
elif flag_relative_import is True and flag_imports is True:
150+
imports.add(f"{current_package}.{t.string}")
151+
elif t.type == token.OP and t.string == '.':
152+
current_package += '.'
153+
elif t.type == token.OP and t.string == ',' and flag_relative_import is False:
154+
imports.add(current_package)
155+
current_package = ''
156+
157+
flag_last_token_name = (t.type == token.NAME and t.string != 'import')
158+
continue
159+
if t.type == token.NAME:
160+
if t.string == 'import':
161+
flag_in_import_block = True
162+
flag_relative_import = False
163+
elif t.string == 'from':
164+
flag_in_import_block = True
165+
flag_relative_import = True
166+
else:
167+
break
168+
except StopIteration:
169+
break
170+
return imports

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
aiofiles==0.6.0
12
autoflake==1.4
23
black==20.8b1
34
isort==5.7.0

requirements_test.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
-r requirements_test_pre_commit.txt
2-
aiofiles==0.6.0
32
mypy==0.812
43
pre-commit==2.11.1
54
pylint==2.7.2

tests/fixtures/unused_import_1.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Test unused import retention."""
2+
from typing import Any, List
3+
4+
import const
5+
6+
var1: List[str]
7+
var2: Any
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""Test unused import retention."""
2+
from __future__ import annotations
3+
4+
from typing import Any
5+
6+
import const
7+
8+
var1: list[str]
9+
var2: Any

tests/fixtures/unused_import_2.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Test unused import retention."""
2+
from typing import Any, List
3+
4+
from const import xyz
5+
6+
var1: List[str]
7+
var2: Any

0 commit comments

Comments
 (0)