Skip to content

Commit 2942c0c

Browse files
authored
Improve comments check (#2)
* Improve comment detection * Added --limit and --only-force options * Refactored tests
1 parent 96d59dd commit 2942c0c

26 files changed

+810
-99
lines changed

.github/workflows/ci.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ jobs:
142142
run: |
143143
. venv/bin/activate
144144
pip install -e .
145-
pylint ${{ env.LIB_FOLDER }}
145+
pylint ${{ env.LIB_FOLDER }} tests
146146
147147
mypy:
148148
name: Check mypy
@@ -171,7 +171,7 @@ jobs:
171171
- name: Run mypy
172172
run: |
173173
. venv/bin/activate
174-
mypy ${{ env.LIB_FOLDER }}
174+
mypy ${{ env.LIB_FOLDER }} tests
175175
176176
177177
prepare-tests-linux:

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ pre-commit run --hook-stage manual python-typing-update --all-files
6262
**`--verbose`**
6363
Always print verbose logging.
6464

65+
**`--limit`**
66+
Max number of files that should be changed. No performance improvements,
67+
since the limit is only applied **after** all files have been processed.
68+
6569
**`--full-reorder`**
6670
Use additional options from [python-reorder-imports][pri] to rewrite
6771
- `--py38-plus` (default): Imports from `mypy_extensions` and `typing_extensions` when possible.
@@ -77,6 +81,10 @@ Check if files would be modified. Return with exitcode `1` or `0` if not. Useful
7781
Don't revert changes if a modified comment is detected.
7882
Check `git diff` before committing!
7983

84+
**`--only-force`**
85+
Only update files which are likely to require extra work.
86+
Check `git diff` before committing!
87+
8088
**`--py39-plus`**
8189
Set the minimum Python syntax to **3.9**. (Default: **3.8**)
8290

python_typing_update/__main__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ async def async_main(argv: list[str] | None = None) -> int:
2626
'filenames',
2727
nargs='+',
2828
)
29+
parser.add_argument(
30+
'--limit', type=int, default=0,
31+
help="Max number of files that should be changed. No performance improvement!",
32+
)
2933
parser.add_argument(
3034
'--full-reorder',
3135
action='store_true',
@@ -54,6 +58,11 @@ async def async_main(argv: list[str] | None = None) -> int:
5458
action='store_true',
5559
help="Update all files. Double check changes afterwards!",
5660
)
61+
group1.add_argument(
62+
'--only-force',
63+
action='store_true',
64+
help="Only update files which are likely to require extra work",
65+
)
5766

5867
group2 = parser.add_mutually_exclusive_group()
5968
group2.add_argument(

python_typing_update/const.py

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

56
version = (0, 2, 0)
67
dev_version = 1
78

89
version_str = '.'.join(map(str, version))
910
if dev_version is not None:
1011
version_str += f'-dev{dev_version}'
12+
13+
14+
class FileStatus(Flag):
15+
CLEAR = 0
16+
COMMENT = auto()
17+
COMMENT_TYPING = auto()

python_typing_update/main.py

Lines changed: 38 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,27 @@
77
import asyncio
88
import builtins
99
import logging
10-
import os
1110
from functools import partial
1211
from io import StringIO
13-
from pathlib import Path
1412

1513
import reorder_python_imports
1614
from autoflake import _main as autoflake_main
1715
from isort.main import main as isort_main
1816
from pyupgrade._main import main as pyupgrade_main
1917

18+
from .const import FileStatus
19+
from .utils import (
20+
async_check_uncommitted_changes, async_restore_files,
21+
check_comment_between_imports, check_files_exist)
22+
2023
logger = logging.getLogger("typing-update")
2124

2225

2326
async def typing_update(
2427
loop: asyncio.AbstractEventLoop,
2528
filename: str,
2629
args: argparse.Namespace,
30+
file_status: FileStatus,
2731
) -> tuple[int, str]:
2832
"""Update typing syntax.
2933
@@ -67,7 +71,7 @@ async def typing_update(
6771
None, autoflake_partial,
6872
[None, '-c', filename],
6973
)
70-
if not args.full_reorder:
74+
if not args.full_reorder and FileStatus.COMMENT_TYPING not in file_status:
7175
# -> No unused imports, revert changes
7276
return 2, filename
7377
except SystemExit:
@@ -103,56 +107,6 @@ async def typing_update(
103107
return 0, filename
104108

105109

106-
async def async_restore_files(file_list: list[str]) -> None:
107-
if not file_list:
108-
return
109-
process = await asyncio.create_subprocess_shell(
110-
f"git restore -- {' '.join(file_list)}",
111-
)
112-
await process.communicate()
113-
114-
115-
def check_files_exist(file_list: list[str]) -> list[str]:
116-
"""Check if all files exist. Return False if not."""
117-
file_errors: list[str] = []
118-
cwd = Path(os.getcwd())
119-
for file_ in file_list:
120-
if cwd.joinpath(file_).is_file() is False:
121-
file_errors.append(file_)
122-
return sorted(file_errors)
123-
124-
125-
async def async_check_uncommitted_changes(file_list: list[str]) -> bool:
126-
"""Check for uncommitted changes.
127-
128-
Returns:
129-
False: if changes still need to be committed
130-
"""
131-
process = await asyncio.create_subprocess_shell(
132-
"git diff-index --name-only HEAD --",
133-
stdout=asyncio.subprocess.PIPE,
134-
)
135-
stdout, _ = await process.communicate()
136-
files_uncommitted: set[str] = {file_ for item in stdout.decode().split('\n')
137-
if (file_ := item.strip())}
138-
return not any(True for file_ in file_list if file_ in files_uncommitted)
139-
140-
141-
async def async_check_changes(file_list: list[str]) -> list[str]:
142-
"""Check if comment was change in files.
143-
144-
That is a sing we can't update it automatically.
145-
"""
146-
if not file_list:
147-
return []
148-
process = await asyncio.create_subprocess_shell(
149-
f"git diff -G\"^#|^from.*#|^import.*#\" --name-only -- {' '.join(file_list)}",
150-
stdout=asyncio.subprocess.PIPE,
151-
)
152-
stdout, _ = await process.communicate()
153-
return sorted([file_ for file_ in stdout.decode().strip().split('\n') if file_])
154-
155-
156110
async def async_run(args: argparse.Namespace) -> int:
157111
"""Update Python typing syntax.
158112
@@ -175,17 +129,26 @@ async def async_run(args: argparse.Namespace) -> int:
175129
print("Abort! Commit all changes to '.py' files before running again.")
176130
return 11
177131

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
137+
138+
if args.only_force:
139+
filenames = {filename: file_status for filename, file_status in filenames.items()
140+
if file_status != FileStatus.CLEAR}
141+
178142
loop = asyncio.get_running_loop()
179143
files_updated: list[str] = []
180144
files_no_changes: list[str] = []
181-
files_with_comments: list[str] = []
182145

183146
# Mock builtin print to omit output
184147
original_print = builtins.print
185148
builtins.print = lambda *args, **kwargs: None
186149

187150
return_values = await asyncio.gather(
188-
*[typing_update(loop, filename, args) for filename in args.filenames])
151+
*[typing_update(loop, filename, args, file_status) for filename, file_status in filenames.items()])
189152
for status, filename in return_values:
190153
if status == 0:
191154
files_updated.append(filename)
@@ -195,6 +158,13 @@ async def async_run(args: argparse.Namespace) -> int:
195158
builtins.print = original_print
196159
await async_restore_files(files_no_changes)
197160

161+
if args.limit > 0 and len(files_updated) > args.limit:
162+
print(
163+
f"Limit applied! Only updated the first {args.limit} "
164+
f"of {len(files_updated)} files")
165+
async_restore_files(files_updated[args.limit:])
166+
files_updated = files_updated[:args.limit]
167+
198168
if args.check is True:
199169
await async_restore_files(files_updated)
200170
if files_updated:
@@ -204,9 +174,13 @@ async def async_run(args: argparse.Namespace) -> int:
204174
return 1
205175
return 0
206176

207-
files_with_comments = await async_check_changes(files_updated)
177+
files_updated_set: set[str] = set(files_updated)
178+
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
181+
])
208182
if files_with_comments:
209-
if args.force:
183+
if args.force or args.only_force:
210184
print("Force mode selected!")
211185
print("Make sure to double check:")
212186
for file_ in files_with_comments:
@@ -218,12 +192,17 @@ async def async_run(args: argparse.Namespace) -> int:
218192
await async_restore_files(files_with_comments)
219193

220194
print("---")
221-
print(f"All files: {len(args.filenames)}")
195+
print(f"All files: {len(filenames)}")
222196
print(f"No changes: {len(files_no_changes)}")
223197
print(f"Files updated: {len(files_updated) - len(files_with_comments)}")
224198
print(f"Files (no automatic update): {len(files_with_comments)}")
225199

226-
if not files_with_comments and not args.force and args.verbose == 0:
200+
if (
201+
not files_with_comments
202+
and not args.force
203+
and not args.only_force
204+
and args.verbose == 0
205+
):
227206
return 0
228207
if files_with_comments:
229208
return 2

python_typing_update/utils.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# ---------------------------------------------------------------------------
2+
# Licensed under the MIT License. See LICENSE file for license information.
3+
# ---------------------------------------------------------------------------
4+
from __future__ import annotations
5+
6+
import asyncio
7+
import os
8+
import token
9+
import tokenize
10+
from collections.abc import Iterable
11+
from pathlib import Path
12+
from typing import TextIO
13+
14+
from .const import FileStatus
15+
16+
17+
def check_files_exist(file_list: Iterable[str]) -> list[str]:
18+
"""Check if all files exist. Return False if not."""
19+
file_errors: list[str] = []
20+
cwd = Path(os.getcwd())
21+
for file_ in file_list:
22+
if cwd.joinpath(file_).is_file() is False:
23+
file_errors.append(file_)
24+
return sorted(file_errors)
25+
26+
27+
async def async_restore_files(file_list: Iterable[str]) -> None:
28+
if not file_list:
29+
return
30+
process = await asyncio.create_subprocess_shell(
31+
f"git restore -- {' '.join(file_list)}",
32+
)
33+
await process.communicate()
34+
35+
36+
async def async_check_uncommitted_changes(file_list: Iterable[str]) -> bool:
37+
"""Check for uncommitted changes.
38+
39+
Returns:
40+
False: if changes still need to be committed
41+
"""
42+
process = await asyncio.create_subprocess_shell(
43+
"git diff-index --name-only HEAD --",
44+
stdout=asyncio.subprocess.PIPE,
45+
)
46+
stdout, _ = await process.communicate()
47+
files_uncommitted: set[str] = {file_ for item in stdout.decode().split('\n')
48+
if (file_ := item.strip())}
49+
return not any(True for file_ in file_list if file_ in files_uncommitted)
50+
51+
52+
def check_comment_between_imports(fp: TextIO) -> FileStatus:
53+
"""Return True if comment is found between imports.
54+
55+
Sign that the file can't be updated automatically.
56+
"""
57+
flag_in_import_block: bool = False
58+
flag_multiple_imports: bool = False
59+
flag_typing_import: bool = False
60+
token_name_count: int = 0
61+
line_first_import: int | None = None
62+
line_last_import: int = 0
63+
line_comments: list[tuple[int, int]] = []
64+
return_value: FileStatus = FileStatus.CLEAR
65+
66+
tokens = tokenize.generate_tokens(fp.readline)
67+
while True:
68+
try:
69+
t = next(tokens)
70+
if flag_in_import_block is True:
71+
if t.type == token.NAME and token_name_count == 0:
72+
token_name_count += 1
73+
if t.string == 'typing':
74+
flag_typing_import = True
75+
elif t.type == token.NEWLINE:
76+
flag_in_import_block = False
77+
flag_multiple_imports = False
78+
flag_typing_import = False
79+
token_name_count = 0
80+
elif t.type == token.OP and t.string != '.':
81+
flag_multiple_imports = True
82+
elif t.type == token.COMMENT:
83+
if flag_typing_import is True:
84+
return_value = return_value | FileStatus.COMMENT | FileStatus.COMMENT_TYPING
85+
elif flag_multiple_imports is True:
86+
# Comment in same line as import statement
87+
return_value = return_value | FileStatus.COMMENT
88+
continue
89+
if t.type == token.NAME:
90+
if t.string in ('import', 'from'):
91+
flag_in_import_block = True
92+
if line_first_import is None:
93+
line_first_import = t.start[0]
94+
line_last_import = t.start[0]
95+
else:
96+
# Any other code block,
97+
# not in main import block anymore
98+
break
99+
elif t.type in (token.COMMENT, token.STRING):
100+
line_comments.append((t.type, t.start[0]))
101+
except StopIteration:
102+
break
103+
104+
if return_value != FileStatus.CLEAR:
105+
# If inline comment was detected, stop here
106+
return return_value
107+
108+
for _, line_number in line_comments:
109+
if line_first_import is None:
110+
# No import block detected
111+
return FileStatus.CLEAR
112+
if (line_first_import < line_number < line_last_import):
113+
# Report all comments in the main import block
114+
return FileStatus.COMMENT
115+
return FileStatus.CLEAR

setup.cfg

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@ license = MIT
77

88
[flake8]
99
ignore = E203,E231,E701,W503,W504
10-
max-line-length = 119
1110
# E203,E231,E701 -> walrus
1211
# W503/504 -> line-break-before/after-binary-operator
12+
max-line-length = 119
13+
extend-exclude =
14+
.github,
15+
.vscode,
16+
tests/fixtures,
17+
venv*,
1318

1419
[tool:pytest]
1520
testpaths = tests
@@ -32,6 +37,7 @@ combine_as_imports = true
3237
python_version = 3.8
3338
ignore_missing_imports = true
3439
follow_imports = normal
40+
exclude = tests/fixtures/.+\.py
3541
# Untyped definitions and calls
3642
disallow_untyped_calls = true
3743
disallow_untyped_defs = true

0 commit comments

Comments
 (0)