auto-walrus 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.3
2
+ Name: auto-walrus
3
+ Version: 0.3.0
4
+ Summary: Automatically apply the awesome walrus operator
5
+ Project-URL: Homepage, https://github.com/MarcoGorelli/auto-walrus
6
+ Project-URL: Bug Tracker, https://github.com/MarcoGorelli/auto-walrus
7
+ Author-email: Marco Gorelli <33491632+MarcoGorelli@users.noreply.github.com>
8
+ License-File: LICENSE
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.8
13
+ Description-Content-Type: text/markdown
14
+
15
+ <h1 align="center">
16
+ auto-walrus
17
+ </h1>
18
+
19
+ <p align="center">
20
+ <img width="458" alt="auto-walrus" src="https://user-images.githubusercontent.com/33491632/195613331-f7442140-09da-4376-90aa-2ac4aaa242fa.png">
21
+ </p>
22
+
23
+ auto-walrus
24
+ ===========
25
+ [![Build Status](https://github.com/MarcoGorelli/auto-walrus/workflows/tox/badge.svg)](https://github.com/MarcoGorelli/auto-walrus/actions?workflow=tox)
26
+ [![Coverage](https://codecov.io/gh/MarcoGorelli/auto-walrus/branch/main/graph/badge.svg)](https://codecov.io/gh/MarcoGorelli/auto-walrus)
27
+ [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/MarcoGorelli/auto-walrus/main.svg)](https://results.pre-commit.ci/latest/github/MarcoGorelli/auto-walrus/main)
28
+
29
+
30
+ A tool and pre-commit hook to automatically apply the awesome walrus operator.
31
+
32
+
33
+ ## Installation
34
+
35
+ ```console
36
+ pip install auto-walrus
37
+ ```
38
+
39
+ ## Usage as a pre-commit hook
40
+
41
+ See [pre-commit](https://github.com/pre-commit/pre-commit) for instructions
42
+
43
+ Sample `.pre-commit-config.yaml`:
44
+
45
+ ```yaml
46
+ - repo: https://github.com/MarcoGorelli/auto-walrus
47
+ rev: v0.2.2
48
+ hooks:
49
+ - id: auto-walrus
50
+ ```
51
+
52
+ ## Command-line example
53
+
54
+ ```console
55
+ auto-walrus myfile.py
56
+ ```
57
+
58
+ ```diff
59
+ - n = 10
60
+ - if n > 3:
61
+ + if (n := 10) > 3:
62
+ print(n)
63
+ ```
64
+
65
+ ## Configuration
66
+
67
+ Using the walrus operator can result in longer lines. Lines longer than what you
68
+ pass to ``--line-length`` won't be rewritten to use walrus operators.
69
+
70
+ E.g.
71
+ ```
72
+ auto-walrus myfile_1.py myfile_2.py --line-length 89
73
+ ```
74
+
75
+ Lines with comments won't be rewritten.
76
+
77
+ ## Used by
78
+
79
+ To my great surprise, this is being used by:
80
+
81
+ - https://github.com/python-graphblas/python-graphblas
82
+ - https://github.com/Remi-Gau/bids2cite
83
+ - https://github.com/TheAlgorithms/Python
84
+ - https://github.com/apache/superset
85
+
86
+ Anyone else? Please let me know, or you can open a pull request to add yourself.
87
+
88
+ ## Testimonials
89
+
90
+ **Christopher Redwine**, [Senior Software Engineer at TechnologyAdvice](https://github.com/chrisRedwine)
91
+
92
+ > hmm, i dunno about this one chief
93
+
94
+ **Michael Kennedy & Brian Okken**, [hosts of the Python Bytes podcast](https://pythonbytes.fm/):
95
+
96
+ > I kind of like this being separate from other tools
97
+
98
+ **Someone on Discord**
99
+
100
+ > you're a monster
101
+
102
+ **Will McGugan**, [CEO / Founder of http://Textualize.io](https://www.willmcgugan.com/):
103
+
104
+ > Embrace the Walrus!
105
+
106
+ ## Credits
107
+
108
+ Logo by [lion_space](https://www.fiverr.com/lion_space)
@@ -0,0 +1,5 @@
1
+ auto_walrus.py,sha256=VI_WBQuwOu7OUY6fkul4wqRKedOWF9tt8U8GV8ZJiwk,12579
2
+ auto_walrus-0.3.0.dist-info/METADATA,sha256=y_wh-WMBR4Siy5ciZNrQIXXj3WjV2VhOVvqVzozSe_4,2936
3
+ auto_walrus-0.3.0.dist-info/WHEEL,sha256=as-1oFTWSeWBgyzh0O_qF439xqBe6AbBgt4MfYe5zwY,87
4
+ auto_walrus-0.3.0.dist-info/licenses/LICENSE,sha256=YhJBcZzQmTfUwcrA-3FDZB3hiSIawDPD-VJSugAZXLY,1071
5
+ auto_walrus-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.22.5
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022, Marco Gorelli
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
auto_walrus.py ADDED
@@ -0,0 +1,408 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import ast
5
+ import os
6
+ import pathlib
7
+ import re
8
+ import sys
9
+ from typing import Any
10
+ from typing import Iterable
11
+ from typing import Sequence
12
+ from typing import Tuple
13
+
14
+ if sys.version_info >= (3, 11): # pragma: no cover
15
+ import tomllib
16
+ else: # pragma: no cover
17
+ import tomli as tomllib
18
+
19
+ SEP_SYMBOLS = frozenset(("(", ")", ",", ":"))
20
+ # name, lineno, col_offset, end_lineno, end_col_offset
21
+ Token = Tuple[str, int, int, int, int]
22
+ SIMPLE_NODE = (ast.Name, ast.Constant)
23
+ ENDS_WITH_COMMENT = re.compile(r"#.*$")
24
+ EXCLUDES = (
25
+ r"/("
26
+ r"\.direnv|\.eggs|\.git|\.hg|\.ipynb_checkpoints|\.mypy_cache|\.nox|\.svn|"
27
+ r"\.tox|\.venv|"
28
+ r"_build|buck-out|build|dist|venv"
29
+ r")/"
30
+ )
31
+
32
+
33
+ def name_lineno_coloffset_iterable(
34
+ tokens: Iterable[Token],
35
+ ) -> list[tuple[str, int, int]]:
36
+ return [(i[0], i[1], i[2]) for i in tokens]
37
+
38
+
39
+ def name_lineno_coloffset(tokens: Token) -> tuple[str, int, int]:
40
+ return (tokens[0], tokens[1], tokens[2])
41
+
42
+
43
+ def is_simple_test(node: ast.AST) -> bool:
44
+ return isinstance(node, SIMPLE_NODE) or (
45
+ isinstance(node, ast.Compare)
46
+ and isinstance(node.left, SIMPLE_NODE)
47
+ and (all(isinstance(_node, SIMPLE_NODE) for _node in node.comparators))
48
+ )
49
+
50
+
51
+ def record_name_lineno_coloffset(
52
+ node: ast.Name,
53
+ end_lineno: int | None = None,
54
+ end_col_offset: int | None = None,
55
+ ) -> Token:
56
+ if end_lineno is None:
57
+ assert node.end_lineno is not None
58
+ _end_lineno = node.end_lineno
59
+ else:
60
+ _end_lineno = end_lineno
61
+ if end_col_offset is None:
62
+ assert node.end_col_offset is not None
63
+ _end_col_offset = node.end_col_offset
64
+ else:
65
+ _end_col_offset = end_col_offset
66
+ return (
67
+ node.id,
68
+ node.lineno,
69
+ node.col_offset,
70
+ _end_lineno,
71
+ _end_col_offset,
72
+ )
73
+
74
+
75
+ def find_names(
76
+ node: ast.AST,
77
+ end_lineno: int | None = None,
78
+ end_col_offset: int | None = None,
79
+ ) -> set[Token]:
80
+ names = set()
81
+ for _node in ast.walk(node):
82
+ if isinstance(_node, ast.Name):
83
+ names.add(
84
+ record_name_lineno_coloffset(
85
+ _node,
86
+ end_lineno,
87
+ end_col_offset,
88
+ ),
89
+ )
90
+ return names
91
+
92
+
93
+ def process_if(
94
+ node: ast.If,
95
+ in_body_vars: dict[Token, set[Token]],
96
+ ) -> set[Token]:
97
+ _names = find_names(node.test)
98
+ _body_names = {_name for _body in node.body for _name in find_names(_body)}
99
+ for _name in _names:
100
+ in_body_vars[_name] = _body_names
101
+ return _names
102
+
103
+
104
+ def process_assign(
105
+ node: ast.Assign,
106
+ assignments: set[Token],
107
+ related_vars: dict[str, list[Token]],
108
+ ) -> None:
109
+ if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
110
+ target = node.targets[0]
111
+ assignments.add(
112
+ record_name_lineno_coloffset(
113
+ target,
114
+ node.end_lineno,
115
+ node.end_col_offset,
116
+ ),
117
+ )
118
+ related_vars[target.id] = list(find_names(node.value))
119
+
120
+
121
+ def is_walrussable(
122
+ _assignment: Token,
123
+ _if_statement: Token,
124
+ sorted_names: list[Token],
125
+ assignment_idx: int,
126
+ if_statement_idx: int,
127
+ _other_assignments: list[Token],
128
+ _other_usages: list[Token],
129
+ names: set[Token],
130
+ in_body_vars: dict[Token, set[Token]],
131
+ ) -> bool:
132
+ return (
133
+ # check name doesn't appear between assignment and if statement
134
+ _assignment[0]
135
+ not in [sorted_names[i][0] for i in range(assignment_idx + 1, if_statement_idx)]
136
+ # check it's the variable's only assignment
137
+ and (len(_other_assignments) == 1)
138
+ # check this is the first usage of this name
139
+ and (
140
+ name_lineno_coloffset(
141
+ _other_usages[0],
142
+ )
143
+ == name_lineno_coloffset(_assignment)
144
+ )
145
+ # check it's used at least somewhere else
146
+ and (len(_other_usages) > 2)
147
+ # check it doesn't appear anywhere else
148
+ and not [
149
+ i
150
+ for i in names
151
+ if (
152
+ name_lineno_coloffset(i)
153
+ not in name_lineno_coloffset_iterable(in_body_vars[_if_statement])
154
+ )
155
+ and (
156
+ name_lineno_coloffset(
157
+ i,
158
+ )
159
+ != name_lineno_coloffset(_assignment)
160
+ )
161
+ and (
162
+ name_lineno_coloffset(
163
+ i,
164
+ )
165
+ != name_lineno_coloffset(_if_statement)
166
+ )
167
+ and i[0] == _assignment[0]
168
+ ]
169
+ )
170
+
171
+
172
+ def related_vars_are_unused(
173
+ related_vars: dict[str, list[Token]],
174
+ name: str,
175
+ sorted_names: list[Token],
176
+ assignment_idx: int,
177
+ if_statement_idx: int,
178
+ ) -> bool:
179
+ # Check that names which appear in right hand side of
180
+ # assignment aren't used between assignment and if-statement.
181
+ related = related_vars[name]
182
+ should_break = False
183
+ for rel in related:
184
+ usages = [i for i in sorted_names if i[0] == rel[0] if i != rel]
185
+ for usage in usages:
186
+ rel_used_idx = name_lineno_coloffset_iterable(
187
+ sorted_names,
188
+ ).index(name_lineno_coloffset(usage))
189
+ if assignment_idx < rel_used_idx < if_statement_idx:
190
+ should_break = True
191
+ return not should_break
192
+
193
+
194
+ def visit_function_def(
195
+ node: ast.FunctionDef,
196
+ ) -> list[tuple[Token, Token]]:
197
+ names = set()
198
+ assignments: set[Token] = set()
199
+ ifs = set()
200
+ for _node in ast.walk(node):
201
+ if isinstance(_node, ast.Name):
202
+ names.add(record_name_lineno_coloffset(_node))
203
+
204
+ related_vars: dict[str, list[Token]] = {}
205
+ in_body_vars: dict[Token, set[Token]] = {}
206
+
207
+ for _node in node.body:
208
+ if isinstance(_node, ast.Assign):
209
+ process_assign(_node, assignments, related_vars)
210
+ elif isinstance(_node, ast.If):
211
+ if is_simple_test(_node.test):
212
+ ifs.update(process_if(_node, in_body_vars))
213
+ for __node in _node.orelse:
214
+ if isinstance(__node, ast.If) and is_simple_test(__node.test):
215
+ ifs.update(process_if(__node, in_body_vars))
216
+
217
+ sorted_names = sorted(names, key=lambda x: (x[1], x[2]))
218
+ sorted_assignments = sorted(assignments, key=lambda x: (x[1], x[2]))
219
+ sorted_ifs = sorted(ifs, key=lambda x: (x[1], x[2]))
220
+ walrus = []
221
+
222
+ for _assignment in sorted_assignments:
223
+ _if_statements = [i for i in sorted_ifs if i[0] == _assignment[0]]
224
+ if len(_if_statements) != 1:
225
+ continue
226
+ _if_statement = _if_statements[0]
227
+ assignment_idx = name_lineno_coloffset_iterable(
228
+ sorted_names,
229
+ ).index(name_lineno_coloffset(_assignment))
230
+ if_statement_idx = name_lineno_coloffset_iterable(
231
+ sorted_names,
232
+ ).index(name_lineno_coloffset(_if_statement))
233
+ _other_assignments = [i for i in sorted_assignments if i[0] == _assignment[0]]
234
+ _other_usages = [i for i in sorted_names if i[0] == _assignment[0]]
235
+ if is_walrussable(
236
+ _assignment,
237
+ _if_statement,
238
+ sorted_names,
239
+ assignment_idx,
240
+ if_statement_idx,
241
+ _other_assignments,
242
+ _other_usages,
243
+ names,
244
+ in_body_vars,
245
+ ) and related_vars_are_unused(
246
+ related_vars,
247
+ _assignment[0],
248
+ sorted_names,
249
+ assignment_idx,
250
+ if_statement_idx,
251
+ ):
252
+ walrus.append((_assignment, _if_statement))
253
+ return walrus
254
+
255
+
256
+ def auto_walrus(
257
+ content: str,
258
+ line_length: int,
259
+ ) -> str | None:
260
+ lines = content.splitlines()
261
+ try:
262
+ tree = ast.parse(content)
263
+ except SyntaxError: # pragma: no cover
264
+ return None
265
+
266
+ walruses = []
267
+ for node in ast.walk(tree):
268
+ if isinstance(node, ast.FunctionDef):
269
+ walruses.extend(visit_function_def(node))
270
+ lines_to_remove = []
271
+ walruses = sorted(walruses, key=lambda x: (-x[1][1], -x[1][2]))
272
+
273
+ if not walruses:
274
+ return None
275
+
276
+ for _assignment, _if_statement in walruses:
277
+ if _assignment[1] != _assignment[3]:
278
+ continue
279
+ txt = lines[_assignment[1] - 1][_assignment[2] : _assignment[4]]
280
+ if txt.count("=") > 1:
281
+ continue
282
+ line = lines[_if_statement[1] - 1]
283
+ left_bit = line[: _if_statement[2]]
284
+ right_bit = line[_if_statement[4] :]
285
+ no_paren = any(left_bit.endswith(i) for i in SEP_SYMBOLS) and any(
286
+ right_bit.startswith(i) for i in SEP_SYMBOLS
287
+ )
288
+ replace = txt.replace("=", ":=")
289
+ if no_paren:
290
+ line_with_walrus = left_bit + replace + right_bit
291
+ else:
292
+ line_with_walrus = left_bit + "(" + replace + ")" + right_bit
293
+ if len(line_with_walrus) > line_length:
294
+ # don't rewrite if it would split over multiple lines
295
+ continue
296
+ # replace assignment
297
+ line_without_assignment = (
298
+ f"{lines[_assignment[1]-1][:_assignment[2]]}"
299
+ f"{lines[_assignment[1]-1][_assignment[4]:]}"
300
+ )
301
+ if (ENDS_WITH_COMMENT.search(lines[_assignment[1] - 1]) is not None) or (
302
+ ENDS_WITH_COMMENT.search(lines[_if_statement[1] - 1]) is not None
303
+ ):
304
+ continue
305
+ lines[_assignment[1] - 1] = line_without_assignment
306
+ # add walrus
307
+ lines[_if_statement[1] - 1] = line_with_walrus
308
+ # remove empty line
309
+ if not lines[_assignment[1] - 1].strip():
310
+ lines_to_remove.append(_assignment[1] - 1)
311
+
312
+ newlines = [
313
+ line
314
+ for i, line in enumerate(
315
+ lines,
316
+ )
317
+ if i not in lines_to_remove
318
+ ]
319
+ newcontent = "\n".join(newlines)
320
+ if newcontent and content.endswith("\n"):
321
+ newcontent += "\n"
322
+ if newcontent != content:
323
+ return newcontent
324
+ return None
325
+
326
+
327
+ def _get_config(paths: list[pathlib.Path]) -> dict[str, Any]:
328
+ """Get the configuration from a config file.
329
+
330
+ Search for a pyproject.toml in common parent directories
331
+ of the given list of paths.
332
+ """
333
+ root = pathlib.Path(os.path.commonpath(paths))
334
+ root = root.parent if root.is_file() else root
335
+
336
+ while root != root.parent:
337
+ config_file = root / "pyproject.toml"
338
+ if config_file.is_file():
339
+ config = tomllib.loads(config_file.read_text())
340
+ config = config.get("tool", {}).get("auto-walrus", {})
341
+ if config:
342
+ return config
343
+
344
+ root = root.parent
345
+
346
+ return {}
347
+
348
+
349
+ def main(argv: Sequence[str] | None = None) -> int: # pragma: no cover
350
+ parser = argparse.ArgumentParser()
351
+ parser.add_argument("paths", nargs="*")
352
+ parser.add_argument(
353
+ "--files",
354
+ help="Regex pattern with which to match files to include",
355
+ required=False,
356
+ default=r"",
357
+ )
358
+ parser.add_argument(
359
+ "--exclude",
360
+ help="Regex pattern with which to match files to exclude",
361
+ required=False,
362
+ default=r"^$",
363
+ )
364
+ # black formatter's default
365
+ parser.add_argument("--line-length", type=int, default=88)
366
+ args = parser.parse_args(argv)
367
+ paths = [pathlib.Path(path).resolve() for path in args.paths]
368
+
369
+ # Update defaults from pyproject.toml if present
370
+ config = {k.replace("-", "_"): v for k, v in _get_config(paths).items()}
371
+ parser.set_defaults(**config)
372
+ args = parser.parse_args(argv)
373
+
374
+ ret = 0
375
+
376
+ for path in paths:
377
+ if path.is_file():
378
+ filepaths = iter((path,))
379
+ else:
380
+ filepaths = (
381
+ p
382
+ for p in path.rglob("*")
383
+ if re.search(args.files, p.as_posix(), re.VERBOSE)
384
+ and not re.search(args.exclude, p.as_posix(), re.VERBOSE)
385
+ and not re.search(EXCLUDES, p.as_posix())
386
+ and p.suffix == ".py"
387
+ )
388
+
389
+ for filepath in filepaths:
390
+ try:
391
+ with open(filepath, encoding="utf-8") as fd:
392
+ content = fd.read()
393
+ except UnicodeDecodeError:
394
+ continue
395
+ new_content = auto_walrus(
396
+ content,
397
+ line_length=args.line_length,
398
+ )
399
+ if new_content is not None and content != new_content:
400
+ sys.stdout.write(f"Rewriting {filepath}\n")
401
+ with open(filepath, "w", encoding="utf-8") as fd:
402
+ fd.write(new_content)
403
+ ret = 1
404
+ return ret
405
+
406
+
407
+ if __name__ == "__main__":
408
+ sys.exit(main())