Skip to content

Commit 8900cc3

Browse files
authored
Ignore global vars (#124, issue: #96)
1 parent 199b997 commit 8900cc3

File tree

10 files changed

+613
-264
lines changed

10 files changed

+613
-264
lines changed

README.md

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ Auto-fixer for Python code that adds `typing.Final` annotation to variable assig
1515
Basically, this, but handles different operations (like usage of `nonlocal`, augmented assignments: `+=`, etc) as well.
1616

1717
- Keeps mypy happy.
18-
- Ignores global variables to avoid confusion with the type aliases like `Fruit = Apple | Banana`.
19-
- Ignores class variables: it is common to use `typing.ClassVar` instead of `typing.Final`.
2018
- Adds global import if it's not imported yet (`import typing`/`from typing import Final`).
2119
- Inspects one file at a time.
20+
- Is careful with global variables: adds Final only for uppercase variables, ignores variable that are referenced in `global` statement inside functions in current file, and avoids removing Final when it already was set.
21+
- Ignores class variables: it is common to use `typing.ClassVar` instead of `typing.Final`.
2222

2323
## How To Use
2424

@@ -34,7 +34,6 @@ or:
3434
pipx run auto-typing-final .
3535
```
3636

37-
3837
### Options
3938

4039
You can specify `--check` flag to check the files instead of actually fixing them:
@@ -52,8 +51,17 @@ auto-typing-final . --import-style typing-final
5251
- `typing-final` enforces `import typing` and `typing.Final`,
5352
- `final` enforces `from typing import Final` and `Final`.
5453

54+
Also, you can set `--ignore-global-vars` flag to ignore global variables:
55+
56+
```sh
57+
auto-typing-final . --ignore-global-vars
58+
```
59+
60+
### Ignore comment
61+
62+
You can ignore variables by adding `# auto-typing-final: ignore` comment to the line.
5563

56-
## VS Code Extension
64+
### VS Code Extension
5765

5866
<img width="768" alt="image" src="https://github.com/community-of-python/auto-typing-final/assets/75225148/f1541056-06f5-4caa-8c94-0a5eaf98ba15">
5967

@@ -67,7 +75,8 @@ After that, install the extension: https://marketplace.visualstudio.com/items?it
6775

6876
### Settings
6977

70-
Import style can be configured in settings: `"auto-typing-final.import-style": "typing-final"` or `"auto-typing-final.import-style": "final"`.
78+
- Import style can be configured in settings: `"auto-typing-final.import-style": "typing-final"` or `"auto-typing-final.import-style": "final"`.
79+
- Ignore global variables can be configured in settings: `"auto-typing-final.ignore-global-vars": true`.
7180

7281
### Notes
7382

auto_typing_final/finder.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from ast_grep_py import Config, SgNode
77

88
# https://github.com/tree-sitter/tree-sitter-python/blob/71778c2a472ed00a64abf4219544edbf8e4b86d7/grammar.js
9-
DEFINITION_RULE: Config = {
9+
DEFINITION_RULE: Final[Config] = {
1010
"rule": {
1111
"any": [
1212
{"kind": "assignment"},
@@ -28,6 +28,14 @@
2828
]
2929
}
3030
}
31+
GLOBAL_STATEMENTS_RULE: Final[Config] = {
32+
"rule": {
33+
"any": [
34+
{"kind": "global_statement"},
35+
]
36+
}
37+
}
38+
IGNORE_COMMENT_TEXT: Final = "# auto-typing-final: ignore"
3139

3240

3341
def _get_last_child_of_type(node: SgNode, type_: str) -> SgNode | None:
@@ -134,9 +142,20 @@ def _find_identifiers_made_by_node(node: SgNode) -> Iterable[SgNode]: # noqa: C
134142
yield from left.find_all(kind="identifier")
135143

136144

145+
def _line_has_ignore_comment(node: SgNode) -> bool:
146+
if not (parent := node.parent()):
147+
return False
148+
if not (grand_parent := parent.parent()):
149+
return False
150+
for one_child in grand_parent.children():
151+
if one_child.kind() == "comment" and IGNORE_COMMENT_TEXT in one_child.text():
152+
return True
153+
return False
154+
155+
137156
def _find_identifiers_in_current_scope(node: SgNode) -> Iterable[tuple[SgNode, SgNode]]:
138157
for child in node.find_all(DEFINITION_RULE):
139-
if _is_inside_inner_function_or_class(node, child) or child == node:
158+
if child == node or _is_inside_inner_function_or_class(node, child) or _line_has_ignore_comment(child):
140159
continue
141160
for identifier in _find_identifiers_made_by_node(child):
142161
yield identifier, child
@@ -172,6 +191,19 @@ def has_global_identifier_with_name(root: SgNode, name: str) -> bool:
172191
return name in {identifier.text() for identifier, _ in _find_identifiers_in_current_scope(root)}
173192

174193

194+
def find_global_definitions(root: SgNode) -> Iterable[list[SgNode]]:
195+
definitions_by_name: Final = defaultdict(list)
196+
197+
for identifier, definition_node in _find_identifiers_in_current_scope(root):
198+
definitions_by_name[identifier.text()].append(definition_node)
199+
200+
for one_node in root.find_all(GLOBAL_STATEMENTS_RULE):
201+
for one_identifier in _find_identifiers_in_children(one_node):
202+
definitions_by_name[one_identifier.text()].append(one_node)
203+
204+
return definitions_by_name.values()
205+
206+
175207
@dataclass(slots=True, kw_only=True)
176208
class ImportsResult:
177209
module_aliases: set[str]

auto_typing_final/lsp.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def path_from_uri(uri: str) -> Path | None:
4747
return path_
4848

4949

50-
ClientSettings = TypedDict("ClientSettings", {"import-style": ImportStyle})
50+
ClientSettings = TypedDict("ClientSettings", {"import-style": ImportStyle, "ignore-global-vars": bool})
5151
FullClientSettings = TypedDict("FullClientSettings", {"auto-typing-final": ClientSettings})
5252

5353

@@ -80,6 +80,7 @@ class Service:
8080
ls_name: str
8181
ignored_paths: list[Path]
8282
import_config: ImportConfig
83+
ignore_global_vars: bool
8384

8485
@staticmethod
8586
def try_from_settings(ls_name: str, settings: Any) -> "Service | None": # noqa: ANN401
@@ -93,11 +94,14 @@ def try_from_settings(ls_name: str, settings: Any) -> "Service | None": # noqa:
9394
ls_name=ls_name,
9495
ignored_paths=[executable_path.parent.parent] if executable_path.parent.name == "bin" else [],
9596
import_config=IMPORT_STYLES_TO_IMPORT_CONFIGS[validated_settings["auto-typing-final"]["import-style"]],
97+
ignore_global_vars=validated_settings["auto-typing-final"]["ignore-global-vars"],
9698
)
9799

98100
def make_diagnostics(self, source: str) -> list[lsp.Diagnostic]:
99101
replacement_result: Final = make_replacements(
100-
root=SgRoot(source, "python").root(), import_config=self.import_config
102+
root=SgRoot(source, "python").root(),
103+
import_config=self.import_config,
104+
ignore_global_vars=self.ignore_global_vars,
101105
)
102106
result: Final = []
103107

@@ -131,7 +135,9 @@ def make_diagnostics(self, source: str) -> list[lsp.Diagnostic]:
131135

132136
def make_fix_all_text_edits(self, source: str) -> list[lsp.TextEdit | lsp.AnnotatedTextEdit]:
133137
replacement_result: Final = make_replacements(
134-
root=SgRoot(source, "python").root(), import_config=self.import_config
138+
root=SgRoot(source, "python").root(),
139+
import_config=self.import_config,
140+
ignore_global_vars=self.ignore_global_vars,
135141
)
136142
result: Final[list[lsp.TextEdit | lsp.AnnotatedTextEdit]] = [
137143
make_text_edit(edit) for replacement in replacement_result.replacements for edit in replacement.edits
@@ -150,7 +156,7 @@ class CustomLanguageServer(LanguageServer):
150156
service: Service | None = None
151157

152158

153-
LSP_SERVER = CustomLanguageServer(name="auto-typing-final", version=version("auto-typing-final"), max_workers=5)
159+
LSP_SERVER: Final = CustomLanguageServer(name="auto-typing-final", version=version("auto-typing-final"), max_workers=5)
154160

155161

156162
@LSP_SERVER.feature(lsp.INITIALIZE)

auto_typing_final/main.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
from auto_typing_final.transform import IMPORT_STYLES_TO_IMPORT_CONFIGS, ImportConfig, ImportStyle, make_replacements
1111

1212

13-
def transform_file_content(source: str, import_config: ImportConfig) -> str:
13+
def transform_file_content(source: str, import_config: ImportConfig, ignore_global_vars: bool) -> str: # noqa: FBT001
1414
root: Final = SgRoot(source, "python").root()
15-
result: Final = make_replacements(root, import_config)
15+
result: Final = make_replacements(root, import_config, ignore_global_vars)
1616
new_text: Final = root.commit_edits(
1717
[edit.node.replace(edit.new_text) for replacement in result.replacements for edit in replacement.edits]
1818
)
@@ -46,6 +46,9 @@ def main() -> int:
4646
parser.add_argument("files", type=Path, nargs="*", default=[Path()])
4747
parser.add_argument("--check", action="store_true")
4848
parser.add_argument("--import-style", type=str, choices=get_args(ImportStyle), default="typing-final")
49+
parser.add_argument(
50+
"--ignore-global-vars", action="store_true", help="Ignore global variables when applying Final annotations"
51+
)
4952

5053
args: Final = parser.parse_args()
5154
import_config: Final = IMPORT_STYLES_TO_IMPORT_CONFIGS[args.import_style]
@@ -55,7 +58,9 @@ def main() -> int:
5558
for path in find_all_source_files(args.files):
5659
with path.open(open_mode) as file:
5760
source = file.read()
58-
transformed_content = transform_file_content(source=source, import_config=import_config)
61+
transformed_content = transform_file_content(
62+
source=source, import_config=import_config, ignore_global_vars=args.ignore_global_vars
63+
)
5964
if source == transformed_content:
6065
continue
6166
changed_files_count += 1

auto_typing_final/transform.py

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import typing
12
from collections.abc import Iterable
23
from dataclasses import dataclass
34
from typing import Final, Literal
@@ -7,6 +8,7 @@
78
from auto_typing_final.finder import (
89
ImportsResult,
910
find_all_definitions_in_functions,
11+
find_global_definitions,
1012
find_imports_of_identifier_in_scope,
1113
has_global_identifier_with_name,
1214
)
@@ -20,10 +22,11 @@ class ImportConfig:
2022

2123

2224
ImportStyle = Literal["typing-final", "final"]
23-
IMPORT_STYLES_TO_IMPORT_CONFIGS: dict[ImportStyle, ImportConfig] = {
25+
IMPORT_STYLES_TO_IMPORT_CONFIGS: Final[dict[ImportStyle, ImportConfig]] = {
2426
"typing-final": ImportConfig(value="typing.Final", import_text="import typing", import_identifier="typing"),
2527
"final": ImportConfig(value="Final", import_text="from typing import Final", import_identifier="Final"),
2628
}
29+
IGNORED_DEFINITION_PATTERNS: typing.Final = {"TypeVar", "ParamSpec"}
2730

2831

2932
@dataclass(frozen=True, slots=True)
@@ -87,18 +90,38 @@ def _make_definition_from_definition_node(node: SgNode) -> Definition:
8790
return OtherDefinition(node)
8891

8992

90-
def _make_operation_from_definitions_of_one_name(nodes: list[SgNode]) -> Operation:
93+
def _should_skip_global_variable(definition: Definition) -> bool:
94+
return isinstance(definition, EditableAssignmentWithoutAnnotation | EditableAssignmentWithAnnotation) and (
95+
not (definition.left.isupper() and len(definition.left) > 1)
96+
or any(one_pattern in definition.right for one_pattern in IGNORED_DEFINITION_PATTERNS)
97+
)
98+
99+
100+
def _make_operation_from_definitions_of_one_name(nodes: list[SgNode], ignore_global_vars: bool) -> Operation | None: # noqa: FBT001
91101
value_definitions: Final[list[Definition]] = []
92102
has_node_inside_loop = False
103+
has_global_scope_definition = False
93104

94105
for node in nodes:
95106
if any(ancestor.kind() in {"for_statement", "while_statement"} for ancestor in node.ancestors()):
96107
has_node_inside_loop = True
108+
109+
if all(ancestor.kind() != "function_definition" for ancestor in node.ancestors()):
110+
has_global_scope_definition = True
111+
97112
value_definitions.append(_make_definition_from_definition_node(node))
98113

99114
if has_node_inside_loop:
100115
return RemoveFinal(value_definitions)
101116

117+
if (
118+
not ignore_global_vars
119+
and has_global_scope_definition
120+
and value_definitions
121+
and _should_skip_global_variable(value_definitions[0])
122+
):
123+
return None
124+
102125
match value_definitions:
103126
case [definition]:
104127
return AddFinal(definition)
@@ -192,13 +215,14 @@ class MakeReplacementsResult:
192215
import_text: str | None
193216

194217

195-
def make_replacements(root: SgNode, import_config: ImportConfig) -> MakeReplacementsResult:
218+
def make_replacements(root: SgNode, import_config: ImportConfig, ignore_global_vars: bool) -> MakeReplacementsResult: # noqa: FBT001
196219
replacements: Final = []
197220
has_added_final = False
198221
imports_result: Final = find_imports_of_identifier_in_scope(root, module_name="typing", identifier_name="Final")
199222

200223
for current_definitions in find_all_definitions_in_functions(root):
201-
operation = _make_operation_from_definitions_of_one_name(current_definitions)
224+
if not (operation := _make_operation_from_definitions_of_one_name(current_definitions, ignore_global_vars)):
225+
continue
202226
edits = [
203227
Edit(node=node, new_text=new_text)
204228
for node, new_text in _make_changed_text_from_operation(
@@ -215,6 +239,29 @@ def make_replacements(root: SgNode, import_config: ImportConfig) -> MakeReplacem
215239

216240
replacements.append(Replacement(operation_type=operation_type, edits=edits))
217241

242+
if not ignore_global_vars:
243+
for current_definitions in find_global_definitions(root):
244+
if (
245+
not (operation := _make_operation_from_definitions_of_one_name(current_definitions, ignore_global_vars))
246+
or (operation_type := type(operation)) == RemoveFinal
247+
):
248+
continue
249+
250+
edits = [
251+
Edit(node=node, new_text=new_text)
252+
for node, new_text in _make_changed_text_from_operation(
253+
operation=operation,
254+
final_value=import_config.value,
255+
imports_result=imports_result,
256+
identifier_name="Final",
257+
)
258+
if node.text() != new_text
259+
]
260+
if not edits:
261+
continue
262+
has_added_final = True
263+
replacements.append(Replacement(operation_type=operation_type, edits=edits))
264+
218265
return MakeReplacementsResult(
219266
replacements=replacements,
220267
import_text=(

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@
3636
],
3737
"description": "Import style",
3838
"scope": "resource"
39+
},
40+
"auto-typing-final.ignore-global-vars": {
41+
"default": false,
42+
"type": "boolean",
43+
"description": "Do not add Final to global variables.",
44+
"scope": "resource"
3945
}
4046
}
4147
}

tests/test_ignore_comment.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from typing import Final
2+
3+
import pytest
4+
5+
from auto_typing_final.main import transform_file_content
6+
from auto_typing_final.transform import IMPORT_STYLES_TO_IMPORT_CONFIGS
7+
8+
9+
@pytest.mark.parametrize(
10+
("before", "after"),
11+
[
12+
("VAR_WITH_COMMENT = 1 # some comment", "VAR_WITH_COMMENT: Final = 1 # some comment"),
13+
("IGNORED_VAR = 1 # auto-typing-final: ignore", "IGNORED_VAR = 1 # auto-typing-final: ignore"),
14+
("IGNORED_VAR: Final = 1 # auto-typing-final: ignore", "IGNORED_VAR: Final = 1 # auto-typing-final: ignore"),
15+
(
16+
"IGNORED_VAR: Final = 1 # auto-typing-final: ignore # some comment",
17+
"IGNORED_VAR: Final = 1 # auto-typing-final: ignore # some comment",
18+
),
19+
("def foo():\n a = 1", "def foo():\n a: Final = 1"),
20+
("def foo():\n a = 1 # auto-typing-final: ignore", "def foo():\n a = 1 # auto-typing-final: ignore"),
21+
],
22+
)
23+
def test_ignore_comment(before: str, after: str) -> None:
24+
import_config: Final = IMPORT_STYLES_TO_IMPORT_CONFIGS["final"]
25+
result: Final = transform_file_content(
26+
f"{import_config.import_text}\n" + before.strip(), import_config=import_config, ignore_global_vars=False
27+
)
28+
assert result == f"{import_config.import_text}\n" + after.strip()

0 commit comments

Comments
 (0)