Skip to content

Commit c14fc60

Browse files
authored
Merge pull request #43 from George-Ogden/dict-rewrite
Dict Rewrite Hook
2 parents 0544ba9 + 6d2c8f0 commit c14fc60

19 files changed

+343
-6
lines changed

.pre-commit-config.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
default_stages: ["pre-commit"]
2+
default_install_hook_types: ["pre-commit", "commit-msg"]
23

34
repos:
45
- repo: https://github.com/pre-commit/pre-commit-hooks
@@ -30,3 +31,20 @@ repos:
3031
args:
3132
- --fix
3233
exclude: ^tests/[^/]*/test_data/
34+
35+
- repo: https://github.com/pre-commit/mirrors-mypy
36+
rev: v1.18.2
37+
hooks:
38+
- id: mypy
39+
args: []
40+
exclude: ^tests/[^/]*/test_data/
41+
additional_dependencies:
42+
- GitPython
43+
- libcst==1.8.5
44+
- pytest
45+
46+
- repo: https://github.com/codespell-project/codespell
47+
rev: v2.4.1
48+
hooks:
49+
- id: codespell
50+
stages: [commit-msg]

.pre-commit-hooks.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,10 @@
4242
language: python
4343
stages: [commit-msg]
4444
additional_dependencies: [codespell]
45+
46+
- id: dict-rewrite
47+
name: Rewrite string-keyed dictionaries to use `dict`.
48+
entry: dict-rewrite
49+
types: [python]
50+
language: python
51+
additional_dependencies: [libcst==1.8.5]

README.md

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ This repository provides the following pre-commit hooks:
1818
- [check-merge-conflict](#check-merge-conflict) - check for merge conflicts.
1919
- [mypy](#mypy) - run [MyPy](#https://github.com/python/mypy).
2020
- [spell-check-commit-msgs](#spell-check-commit-msgs) - check for spelling errors in commit messages.
21+
- [dict-rewrite](#dict-rewrite) - rewrite string-keyed dictionaries to use `dict`.
2122

2223
### dbg-check
2324

@@ -53,12 +54,33 @@ You can set the MyPy version in the requirements file (eg `mypy==1.17.1`), other
5354

5455
### spell-check-commit-msgs
5556

56-
> _[If you] make spelling mistakes in commit messages, it's then a real pain to amend the commit._
57-
> _And god forbid if you pushed._
57+
> _[If you] make spelling mistakes in commit messages, it's then a real pain to amend the commit. And god forbid if you pushed._
5858
5959
This uses [`codespell`](https://github.com/codespell-project/codespell) under the hood, and accepts the same flags via the `args` field, but interactive mode is not supported.
6060
You need to ensure that you install the `commit-msg` hooks, which you can do with `pre-commit install -t pre-commit -t commit-msg` or adding `default_install_hook_types: ["pre-commit", "commit-msg"]` to the `.pre-commit-config.yaml` (like below).
6161

62+
### dict-rewrite
63+
64+
Make string-based dictionaries use `dict` instead of curly braces. For example,
65+
66+
```python
67+
{
68+
"a": "ascii",
69+
"r": repr,
70+
"integer": 15,
71+
}
72+
# is rewritten as
73+
dict(
74+
a="ascii",
75+
r=repr,
76+
integer=15
77+
)
78+
```
79+
80+
Use the `--fix` arg to apply the change, as well as linting files.
81+
The change may disrupt the style of the code, so consider using a formatter, such as [`ruff`](https://github.com/astral-sh/ruff/).
82+
It is possible to ignore dictionaries using `# dict-ignore`.
83+
6284
## Example Use
6385

6486
Here's a sample `.pre-commit-config.yaml`:
@@ -76,7 +98,7 @@ repos:
7698
- id: trailing-whitespace
7799

78100
- repo: https://github.com/George-Ogden/pre-commit-hooks/
79-
rev: v1.3.2
101+
rev: v1.4.0
80102
hooks:
81103
- id: dbg-check
82104
exclude: ^test/
@@ -87,6 +109,8 @@ repos:
87109
- id: mypy
88110
args: [-r, requirements.txt, --strict]
89111
- id: spell-check-commit-msgs
112+
- id: dict-rewrite
113+
args: [--fix]
90114
```
91115
92116
### Development

dict_rewrite.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import argparse
2+
import bisect
3+
import re
4+
import sys
5+
import textwrap
6+
from typing import NoReturn, Optional, cast
7+
8+
import libcst as cst
9+
from libcst._position import CodeRange
10+
import libcst.matchers as m
11+
import libcst.metadata as metadata
12+
13+
14+
class CommentFinder(cst.CSTVisitor):
15+
METADATA_DEPENDENCIES = (metadata.PositionProvider,)
16+
17+
def __init__(self) -> None:
18+
self.ignored_linenos: set[int] = set()
19+
20+
def visit_Comment(self, node: cst.Comment) -> None:
21+
if re.search(r"\bdict-ignore\b", node.value):
22+
position: Optional[CodeRange] = self.get_metadata(metadata.PositionProvider, node) # type: ignore
23+
if position:
24+
for lineno in range(position.start.line, position.end.line + 1):
25+
self.ignored_linenos.add(lineno)
26+
27+
@classmethod
28+
def get_ignored_lines(cls, wrapper: cst.MetadataWrapper) -> frozenset[int]:
29+
comment_finder = cls()
30+
wrapper.visit(comment_finder)
31+
return frozenset(comment_finder.ignored_linenos)
32+
33+
34+
class DictChecker(cst.CSTTransformer):
35+
METADATA_DEPENDENCIES = (metadata.PositionProvider,)
36+
37+
def __init__(self, fix: bool) -> None:
38+
self.fix = fix
39+
self.ignored_linenos: tuple[int, ...] = ()
40+
41+
def setup(self, filename: str) -> None:
42+
with open(filename) as f:
43+
self.lines = f.readlines()
44+
self.num_changes = 0
45+
self.filename = filename
46+
47+
def is_ignored(self, position: Optional[CodeRange]) -> bool:
48+
if position is None:
49+
return False
50+
next_idx = bisect.bisect_left(self.ignored_linenos, position.start.line)
51+
return (
52+
next_idx < len(self.ignored_linenos)
53+
and self.ignored_linenos[next_idx] <= position.end.line
54+
)
55+
56+
def leave_Dict(self, original_node: cst.Dict, updated_node: cst.Dict) -> cst.BaseExpression:
57+
position: Optional[CodeRange] = self.get_metadata(metadata.PositionProvider, original_node) # type: ignore
58+
if self.is_ignored(position):
59+
return updated_node
60+
existing_elements: list[cst.DictElement] = [
61+
element # type: ignore
62+
for element in updated_node.elements
63+
if m.matches(element, m.DictElement())
64+
]
65+
if existing_elements and all(
66+
self.is_compatible_element(element) for element in existing_elements
67+
):
68+
self.num_changes += 1
69+
if self.fix:
70+
return cst.Call(
71+
cst.Name("dict"),
72+
args=[
73+
cst.Arg(
74+
keyword=cst.Name(cast(cst.SimpleString, element.key).raw_value),
75+
value=element.value,
76+
equal=cst.AssignEqual(
77+
whitespace_before=cst.SimpleWhitespace(""),
78+
whitespace_after=cst.SimpleWhitespace(""),
79+
),
80+
comma=element.comma,
81+
)
82+
if isinstance(element, cst.DictElement)
83+
else cst.Arg(
84+
value=element.value,
85+
star="**",
86+
comma=element.comma,
87+
whitespace_after_star=cast(
88+
cst.StarredDictElement, element
89+
).whitespace_before_value,
90+
)
91+
for element in updated_node.elements
92+
],
93+
)
94+
else:
95+
print(
96+
f"{self.filename}:{self.format_range(position)} {self.format_code(original_node)}"
97+
)
98+
return updated_node
99+
100+
def format_range(self, range: Optional[CodeRange]) -> str:
101+
if range is None:
102+
return ""
103+
return f"{range.start.line}:{range.start.column + 1}:"
104+
105+
def format_code(self, node: cst.CSTNode) -> str:
106+
source_code = self.module.code_for_node(node)
107+
first_line, *lines = source_code.split("\n", maxsplit=1)
108+
if lines:
109+
[line] = lines
110+
return f"{first_line}\n{textwrap.dedent(line)}"
111+
else:
112+
return first_line
113+
114+
@classmethod
115+
def is_compatible_element(cls, element: cst.DictElement) -> bool:
116+
return (
117+
m.matches(element.key, m.SimpleString())
118+
and cast(cst.SimpleString, element.key).raw_value.isidentifier()
119+
)
120+
121+
def check_files(self, filenames: str) -> bool:
122+
"""Return whether the check fails for any file."""
123+
failed = False
124+
for filename in filenames:
125+
self.filename = filename
126+
if self.check_file(filename):
127+
failed = True
128+
129+
return failed
130+
131+
def check_file(self, filename: str) -> bool:
132+
"""Return whether the check fails."""
133+
self.setup(filename)
134+
try:
135+
self.module = cst.parse_module("".join(self.lines))
136+
except (SyntaxError, ValueError):
137+
return True
138+
else:
139+
wrapper = cst.MetadataWrapper(self.module)
140+
self.ignored_linenos = tuple(sorted(CommentFinder.get_ignored_lines(wrapper)))
141+
updated_module = wrapper.visit(self)
142+
if self.num_changes and self.fix:
143+
error = "error" if self.num_changes == 1 else "errors"
144+
print(f"Fixed {self.num_changes} {error} in '{filename}'.")
145+
with open(filename, "w") as f:
146+
f.write(updated_module.code)
147+
return self.num_changes > 0
148+
149+
150+
def parse_args() -> argparse.Namespace:
151+
parser = argparse.ArgumentParser()
152+
parser.add_argument("files", nargs="+", metavar="FILES")
153+
parser.add_argument("--fix", action="store_true", help="Modify files in place.")
154+
return parser.parse_args()
155+
156+
157+
def main(args: argparse.Namespace) -> int:
158+
filenames = args.files
159+
fix = args.fix
160+
161+
checker = DictChecker(fix)
162+
if checker.check_files(filenames):
163+
return 1
164+
return 0
165+
166+
167+
def main_cli() -> NoReturn:
168+
args = parse_args()
169+
sys.exit(main(args))
170+
171+
172+
if __name__ == "__main__":
173+
main_cli()

pyproject.toml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,26 @@
11
[project]
22
name = "Pre-Commit-Hooks"
3-
version = "1.3.2"
3+
version = "1.4.0"
44
requires-python = ">=3.9,<3.14"
55

6+
[project.scripts]
7+
dict-rewrite = "dict_rewrite:main_cli"
8+
69
[build-system]
710
requires = ["setuptools"]
811
build-backend = "setuptools.build_meta"
912

1013
[tool.setuptools]
1114
packages = []
12-
py-modules = []
15+
py-modules = ["dict_rewrite"]
1316
script-files = ["./run-spell-check.sh"]
1417

1518
[tool.ruff]
1619
line-length = 100
1720

1821
[tool.ruff.lint]
1922
extend-select = ["I", "UP", "F", "E", "W", "ERA001"]
20-
extend-ignore = ["F811"]
23+
extend-ignore = ["F811", "E501"]
2124

2225
[tool.ruff.lint.isort]
2326
force-sort-within-sections = true

requirements-dev.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
-r requirements.txt
12
GitPython
23
pre-commit
34
pytest

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
libcst==1.8.5
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#!/bin/bash
2+
3+
set -e
4+
set -o pipefail
5+
DIRECTORY=$(dirname $0)
6+
LOG=`mktemp`
7+
8+
pre-commit try-repo . dict-rewrite -v --files $DIRECTORY/test_data/commented.py | tee $LOG && exit 1
9+
grep -Poz 'commented\.py:9:5: {\n "C": False,\n}' $LOG
10+
grep -Poz 'commented\.py:13:5: {\n "D": False,\n}' $LOG
11+
grep -Poz 'commented\.py:19:5: {\n "F": False,\n}' $LOG
12+
! grep -F 'True' $LOG
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
a = {
2+
"A": True,
3+
} # dict-ignore
4+
5+
b = { # noqo:dict-ignore
6+
"B": True,
7+
}
8+
9+
c = {
10+
"C": False,
11+
} # random comment
12+
13+
d = {
14+
"D": False,
15+
}
16+
17+
e = {"e": {"E": True}} #dict-ignore
18+
19+
f = {
20+
"F": False,
21+
} # dict-ignore_fail

tests/dict-rewrite/test_data/empty.py

Whitespace-only changes.

0 commit comments

Comments
 (0)