wexample-filestate-python 0.0.48__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.
- wexample_filestate_python/__init__.py +0 -0
- wexample_filestate_python/__pycache__/__init__.py +0 -0
- wexample_filestate_python/common/__init__.py +0 -0
- wexample_filestate_python/common/__pycache__/__init__.py +0 -0
- wexample_filestate_python/common/pipy_gateway.py +20 -0
- wexample_filestate_python/config_option/__init__.py +0 -0
- wexample_filestate_python/config_option/__pycache__/__init__.py +0 -0
- wexample_filestate_python/config_option/mixin/__init__.py +0 -0
- wexample_filestate_python/config_option/mixin/__pycache__/__init__.py +0 -0
- wexample_filestate_python/config_option/mixin/with_stdout_wrapping_mixin.py +46 -0
- wexample_filestate_python/config_value/__init__.py +0 -0
- wexample_filestate_python/config_value/__pycache__/__init__.py +0 -0
- wexample_filestate_python/config_value/python_config_value.py +195 -0
- wexample_filestate_python/const/__init__.py +0 -0
- wexample_filestate_python/const/__pycache__/__init__.py +0 -0
- wexample_filestate_python/const/name_pattern.py +4 -0
- wexample_filestate_python/const/python_file.py +5 -0
- wexample_filestate_python/file/__init__.py +0 -0
- wexample_filestate_python/file/__pycache__/__init__.py +0 -0
- wexample_filestate_python/file/python_file.py +12 -0
- wexample_filestate_python/helpers/__init__.py +0 -0
- wexample_filestate_python/helpers/__pycache__/__init__.py +0 -0
- wexample_filestate_python/helpers/package.py +122 -0
- wexample_filestate_python/helpers/toml.py +116 -0
- wexample_filestate_python/option/__init__.py +0 -0
- wexample_filestate_python/option/__pycache__/__init__.py +0 -0
- wexample_filestate_python/option/abstract_python_file_content_option.py +45 -0
- wexample_filestate_python/option/add_future_annotations_option.py +79 -0
- wexample_filestate_python/option/add_return_types_option.py +265 -0
- wexample_filestate_python/option/fix_attrs_option.py +37 -0
- wexample_filestate_python/option/fix_blank_lines_option.py +47 -0
- wexample_filestate_python/option/format_option.py +34 -0
- wexample_filestate_python/option/fstringify_option.py +34 -0
- wexample_filestate_python/option/modernize_typing_option.py +25 -0
- wexample_filestate_python/option/order_class_attributes_option.py +34 -0
- wexample_filestate_python/option/order_class_docstring_option.py +36 -0
- wexample_filestate_python/option/order_class_methods_option.py +37 -0
- wexample_filestate_python/option/order_constants_option.py +35 -0
- wexample_filestate_python/option/order_iterable_items_option.py +31 -0
- wexample_filestate_python/option/order_main_guard_option.py +44 -0
- wexample_filestate_python/option/order_module_docstring_option.py +73 -0
- wexample_filestate_python/option/order_module_functions_option.py +42 -0
- wexample_filestate_python/option/order_module_metadata_option.py +62 -0
- wexample_filestate_python/option/order_type_checking_block_option.py +51 -0
- wexample_filestate_python/option/python_option.py +164 -0
- wexample_filestate_python/option/relocate_imports_option.py +189 -0
- wexample_filestate_python/option/remove_unused_option.py +45 -0
- wexample_filestate_python/option/sort_imports_option.py +26 -0
- wexample_filestate_python/option/unquote_annotations_option.py +85 -0
- wexample_filestate_python/options_provider/__init__.py +0 -0
- wexample_filestate_python/options_provider/__pycache__/__init__.py +0 -0
- wexample_filestate_python/options_provider/python_options_provider.py +24 -0
- wexample_filestate_python/py.typed +0 -0
- wexample_filestate_python/utils/__init__.py +0 -0
- wexample_filestate_python/utils/__pycache__/__init__.py +0 -0
- wexample_filestate_python/utils/python_attrs_utils.py +112 -0
- wexample_filestate_python/utils/python_blank_lines_utils.py +568 -0
- wexample_filestate_python/utils/python_class_attributes_utils.py +275 -0
- wexample_filestate_python/utils/python_class_docstring_utils.py +85 -0
- wexample_filestate_python/utils/python_class_methods_utils.py +230 -0
- wexample_filestate_python/utils/python_constants_utils.py +302 -0
- wexample_filestate_python/utils/python_docstring_utils.py +117 -0
- wexample_filestate_python/utils/python_functions_utils.py +212 -0
- wexample_filestate_python/utils/python_iterable_utils.py +131 -0
- wexample_filestate_python/utils/python_main_guard_utils.py +80 -0
- wexample_filestate_python/utils/python_module_metadata_utils.py +147 -0
- wexample_filestate_python/utils/python_type_checking_utils.py +113 -0
- wexample_filestate_python/utils/relocate_imports/__init__.py +7 -0
- wexample_filestate_python/utils/relocate_imports/__pycache__/__init__.py +0 -0
- wexample_filestate_python/utils/relocate_imports/python_import_rewriter.py +413 -0
- wexample_filestate_python/utils/relocate_imports/python_localize_runtime_imports.py +324 -0
- wexample_filestate_python/utils/relocate_imports/python_parser_import_index.py +80 -0
- wexample_filestate_python/utils/relocate_imports/python_runtime_symbol_collector.py +33 -0
- wexample_filestate_python/utils/relocate_imports/python_usage_collector.py +410 -0
- wexample_filestate_python/workdir/__init__.py +0 -0
- wexample_filestate_python/workdir/__pycache__/__init__.py +0 -0
- wexample_filestate_python-0.0.48.dist-info/METADATA +191 -0
- wexample_filestate_python-0.0.48.dist-info/RECORD +80 -0
- wexample_filestate_python-0.0.48.dist-info/WHEEL +4 -0
- wexample_filestate_python-0.0.48.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
import libcst as cst
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
|
|
11
|
+
FLAG_NAME = "python-constant-sort"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def find_flagged_constant_blocks(
|
|
15
|
+
module: cst.Module, src: str
|
|
16
|
+
) -> list[tuple[int, int, list[cst.SimpleStatementLine]]]:
|
|
17
|
+
"""Find blocks of contiguous UPPER_CASE assignments following the filestate flag.
|
|
18
|
+
|
|
19
|
+
A block starts at the first assignment statement that has the flag in its leading
|
|
20
|
+
comments; it continues with subsequent contiguous constant assignments until a
|
|
21
|
+
blank line or a non-constant statement is found.
|
|
22
|
+
|
|
23
|
+
Returns list of tuples (start_index, end_index_exclusive, nodes_in_block)
|
|
24
|
+
where indices refer to module.body positions.
|
|
25
|
+
"""
|
|
26
|
+
blocks: list[tuple[int, int, list[cst.SimpleStatementLine]]] = []
|
|
27
|
+
|
|
28
|
+
i = 0
|
|
29
|
+
body = module.body
|
|
30
|
+
n = len(body)
|
|
31
|
+
while i < n:
|
|
32
|
+
stmt = body[i]
|
|
33
|
+
if isinstance(stmt, cst.SimpleStatementLine):
|
|
34
|
+
has_flag = _stmt_has_flag(stmt, src) or _prev_line_has_flag(list(body), i)
|
|
35
|
+
if has_flag and _get_simple_assignment_name(stmt) is not None:
|
|
36
|
+
# Start a block at i
|
|
37
|
+
j = i
|
|
38
|
+
nodes: list[cst.SimpleStatementLine] = []
|
|
39
|
+
while j < n:
|
|
40
|
+
s = body[j]
|
|
41
|
+
if isinstance(s, cst.SimpleStatementLine):
|
|
42
|
+
if j != i:
|
|
43
|
+
# Stop the block ONLY if there is a blank line separation
|
|
44
|
+
# (an EmptyLine without a comment) among leading_lines.
|
|
45
|
+
if any(el.comment is None for el in s.leading_lines):
|
|
46
|
+
break
|
|
47
|
+
name = _get_simple_assignment_name(s)
|
|
48
|
+
if name is None:
|
|
49
|
+
break
|
|
50
|
+
nodes.append(s)
|
|
51
|
+
j += 1
|
|
52
|
+
continue
|
|
53
|
+
# Stop at any other node
|
|
54
|
+
break
|
|
55
|
+
if nodes:
|
|
56
|
+
blocks.append((i, j, nodes))
|
|
57
|
+
i = j
|
|
58
|
+
continue
|
|
59
|
+
i += 1
|
|
60
|
+
|
|
61
|
+
return blocks
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# -------- Class-level support --------
|
|
65
|
+
def find_flagged_constant_blocks_in_class(
|
|
66
|
+
classdef: cst.ClassDef, src: str
|
|
67
|
+
) -> list[tuple[int, int, list[cst.SimpleStatementLine]]]:
|
|
68
|
+
"""Find flagged constant blocks within a class body.
|
|
69
|
+
|
|
70
|
+
Returns list of tuples (start_index, end_index_exclusive, nodes_in_block)
|
|
71
|
+
where indices refer to classdef.body.body positions.
|
|
72
|
+
"""
|
|
73
|
+
blocks: list[tuple[int, int, list[cst.SimpleStatementLine]]] = []
|
|
74
|
+
body_list = list(classdef.body.body)
|
|
75
|
+
n = len(body_list)
|
|
76
|
+
i = 0
|
|
77
|
+
while i < n:
|
|
78
|
+
item = body_list[i]
|
|
79
|
+
if isinstance(item, cst.SimpleStatementLine):
|
|
80
|
+
has_flag = _stmt_has_flag(item, src) or _prev_line_has_flag(body_list, i)
|
|
81
|
+
if has_flag and _get_simple_assignment_name(item) is not None:
|
|
82
|
+
j = i
|
|
83
|
+
nodes: list[cst.SimpleStatementLine] = []
|
|
84
|
+
while j < n:
|
|
85
|
+
s = body_list[j]
|
|
86
|
+
if isinstance(s, cst.SimpleStatementLine):
|
|
87
|
+
if j != i:
|
|
88
|
+
# Stop the block ONLY on a blank line (no comment) among leading_lines.
|
|
89
|
+
if any(el.comment is None for el in s.leading_lines):
|
|
90
|
+
break
|
|
91
|
+
name = _get_simple_assignment_name(s)
|
|
92
|
+
if name is None:
|
|
93
|
+
break
|
|
94
|
+
nodes.append(s)
|
|
95
|
+
j += 1
|
|
96
|
+
continue
|
|
97
|
+
break
|
|
98
|
+
if nodes:
|
|
99
|
+
blocks.append((i, j, nodes))
|
|
100
|
+
i = j
|
|
101
|
+
continue
|
|
102
|
+
i += 1
|
|
103
|
+
return blocks
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def reorder_flagged_constants(module: cst.Module, src: str) -> cst.Module:
|
|
107
|
+
blocks = find_flagged_constant_blocks(module, src)
|
|
108
|
+
if not blocks:
|
|
109
|
+
return module
|
|
110
|
+
|
|
111
|
+
new_body = list(module.body)
|
|
112
|
+
|
|
113
|
+
# Process blocks from last to first to keep indices stable
|
|
114
|
+
for start, end, nodes in reversed(blocks):
|
|
115
|
+
sorted_nodes = sort_constants_block(nodes)
|
|
116
|
+
# If unchanged, skip
|
|
117
|
+
if all(a is b for a, b in zip(nodes, sorted_nodes)):
|
|
118
|
+
continue
|
|
119
|
+
# Replace slice
|
|
120
|
+
new_body[start:end] = sorted_nodes
|
|
121
|
+
|
|
122
|
+
return module.with_changes(body=new_body)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def reorder_flagged_constants_everywhere(module: cst.Module, src: str) -> cst.Module:
|
|
126
|
+
"""Reorder flagged constant blocks at module level and within class bodies."""
|
|
127
|
+
first = reorder_flagged_constants(module, src)
|
|
128
|
+
second = reorder_flagged_constants_in_classes(first, src)
|
|
129
|
+
return second
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def reorder_flagged_constants_in_classes(module: cst.Module, src: str) -> cst.Module:
|
|
133
|
+
"""Reorder flagged constant blocks inside all class definitions in the module."""
|
|
134
|
+
changed = False
|
|
135
|
+
new_module_body = list(module.body)
|
|
136
|
+
|
|
137
|
+
for idx, node in enumerate(new_module_body):
|
|
138
|
+
if isinstance(node, cst.ClassDef):
|
|
139
|
+
class_body_list = list(node.body.body)
|
|
140
|
+
blocks = find_flagged_constant_blocks_in_class(node, src)
|
|
141
|
+
if not blocks:
|
|
142
|
+
continue
|
|
143
|
+
# Apply from last to first within the class body
|
|
144
|
+
for start, end, nodes in reversed(blocks):
|
|
145
|
+
sorted_nodes = sort_constants_block(nodes)
|
|
146
|
+
if all(a is b for a, b in zip(nodes, sorted_nodes)):
|
|
147
|
+
continue
|
|
148
|
+
class_body_list[start:end] = sorted_nodes
|
|
149
|
+
changed = True
|
|
150
|
+
if changed:
|
|
151
|
+
new_class_body = node.body.with_changes(body=class_body_list)
|
|
152
|
+
new_module_body[idx] = node.with_changes(body=new_class_body)
|
|
153
|
+
|
|
154
|
+
if not changed:
|
|
155
|
+
return module
|
|
156
|
+
return module.with_changes(body=new_module_body)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def sort_constants_block(
|
|
160
|
+
nodes: list[cst.SimpleStatementLine],
|
|
161
|
+
) -> list[cst.SimpleStatementLine]:
|
|
162
|
+
"""Return a new list of nodes sorted by variable name (case-insensitive).
|
|
163
|
+
|
|
164
|
+
Preserve the flag comment by attaching it to the first node of the
|
|
165
|
+
sorted block (even if a different node becomes first after sorting),
|
|
166
|
+
and clear leading_lines of subsequent nodes to avoid extra blank lines.
|
|
167
|
+
"""
|
|
168
|
+
from wexample_filestate.helpers.flag import flag_exists
|
|
169
|
+
|
|
170
|
+
# Preserve the entire leading_lines per node; additionally, capture the flag
|
|
171
|
+
# comment lines from whichever node currently holds them so we can keep the flag
|
|
172
|
+
# on the first node after sorting.
|
|
173
|
+
original_leadings = [n.leading_lines for n in nodes]
|
|
174
|
+
|
|
175
|
+
# Collect flag lines from any node (typically the first) to attach to new first
|
|
176
|
+
def _flag_lines(ll: Sequence[cst.EmptyLine]) -> list[cst.EmptyLine]:
|
|
177
|
+
return [
|
|
178
|
+
el
|
|
179
|
+
for el in ll
|
|
180
|
+
if el.comment is not None and flag_exists(FLAG_NAME, el.comment.value)
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
collected_flag_lines: list[cst.EmptyLine] = []
|
|
184
|
+
for ll in original_leadings:
|
|
185
|
+
fl = _flag_lines(ll)
|
|
186
|
+
if fl:
|
|
187
|
+
collected_flag_lines = fl
|
|
188
|
+
break
|
|
189
|
+
|
|
190
|
+
pairs: list[tuple[str, cst.SimpleStatementLine]] = []
|
|
191
|
+
for node in nodes:
|
|
192
|
+
name = _get_simple_assignment_name(node)
|
|
193
|
+
if name is None:
|
|
194
|
+
# Shouldn't happen given precondition
|
|
195
|
+
continue
|
|
196
|
+
pairs.append((name, node))
|
|
197
|
+
|
|
198
|
+
# If already sorted, return original (no changes)
|
|
199
|
+
sorted_pairs = sorted(pairs, key=lambda p: p[0].lower())
|
|
200
|
+
if [n for _, n in sorted_pairs] == [n for _, n in pairs]:
|
|
201
|
+
return nodes
|
|
202
|
+
|
|
203
|
+
# Build new nodes preserving each node's original leading_lines, but move the
|
|
204
|
+
# flag comment lines to the new first node (removing them from others).
|
|
205
|
+
sorted_nodes: list[cst.SimpleStatementLine] = []
|
|
206
|
+
|
|
207
|
+
# Capture blank lines that precede the flag comment from the first node
|
|
208
|
+
first_node_blank_lines: list[cst.EmptyLine] = []
|
|
209
|
+
if original_leadings:
|
|
210
|
+
for el in original_leadings[0]:
|
|
211
|
+
if el.comment is None:
|
|
212
|
+
first_node_blank_lines.append(el)
|
|
213
|
+
elif flag_exists(FLAG_NAME, el.comment.value):
|
|
214
|
+
# Stop before the flag comment
|
|
215
|
+
break
|
|
216
|
+
|
|
217
|
+
# Pre-clean each node's leading_lines by removing any flag lines to avoid duplicates
|
|
218
|
+
# and removing blank lines (except for the first node's leading blanks before flag)
|
|
219
|
+
cleaned_leadings = []
|
|
220
|
+
for idx_ll, ll in enumerate(original_leadings):
|
|
221
|
+
cleaned = [
|
|
222
|
+
el
|
|
223
|
+
for el in ll
|
|
224
|
+
if not (el.comment is not None and flag_exists(FLAG_NAME, el.comment.value))
|
|
225
|
+
and el.comment is not None # Remove blank lines (EmptyLine with no comment)
|
|
226
|
+
]
|
|
227
|
+
cleaned_leadings.append(cleaned)
|
|
228
|
+
|
|
229
|
+
for idx, (_, node) in enumerate(sorted_pairs):
|
|
230
|
+
# Determine the original index of this node in 'nodes' list
|
|
231
|
+
original_index = next((i for i, (_, n) in enumerate(pairs) if n is node), None)
|
|
232
|
+
leading = (
|
|
233
|
+
cleaned_leadings[original_index]
|
|
234
|
+
if original_index is not None
|
|
235
|
+
else node.leading_lines
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# For the first node, add blank lines before flag, then flag lines
|
|
239
|
+
if idx == 0:
|
|
240
|
+
leading = first_node_blank_lines + collected_flag_lines + list(leading)
|
|
241
|
+
|
|
242
|
+
sorted_nodes.append(node.with_changes(leading_lines=leading))
|
|
243
|
+
return sorted_nodes
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _get_simple_assignment_name(stmt: cst.SimpleStatementLine) -> str | None:
|
|
247
|
+
if len(stmt.body) != 1:
|
|
248
|
+
return None
|
|
249
|
+
small = stmt.body[0]
|
|
250
|
+
if isinstance(small, cst.Assign):
|
|
251
|
+
if len(small.targets) != 1:
|
|
252
|
+
return None
|
|
253
|
+
target = small.targets[0].target
|
|
254
|
+
if isinstance(target, cst.Name) and _is_upper_name(target.value):
|
|
255
|
+
return target.value
|
|
256
|
+
return None
|
|
257
|
+
if isinstance(small, cst.AnnAssign):
|
|
258
|
+
target = small.target
|
|
259
|
+
if isinstance(target, cst.Name) and _is_upper_name(target.value):
|
|
260
|
+
return target.value
|
|
261
|
+
return None
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _is_blank_line(stmt: cst.CSTNode) -> bool:
|
|
266
|
+
# In Module.body, blank lines are represented as EmptyLine nodes
|
|
267
|
+
return isinstance(stmt, cst.EmptyLine)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _is_upper_name(name: str) -> bool:
|
|
271
|
+
return name.isupper()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _prev_line_has_flag(body_list: list[cst.CSTNode], index: int) -> bool:
|
|
275
|
+
"""Return True if the previous sibling is an EmptyLine whose comment contains the flag."""
|
|
276
|
+
from wexample_filestate.helpers.flag import flag_exists
|
|
277
|
+
|
|
278
|
+
if index - 1 < 0:
|
|
279
|
+
return False
|
|
280
|
+
prev = body_list[index - 1]
|
|
281
|
+
if isinstance(prev, cst.EmptyLine) and prev.comment is not None:
|
|
282
|
+
return flag_exists(FLAG_NAME, prev.comment.value)
|
|
283
|
+
return False
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _stmt_has_flag(stmt: cst.SimpleStatementLine, src: str) -> bool:
|
|
287
|
+
"""Detect if a simple statement line is preceded by the filestate flag.
|
|
288
|
+
|
|
289
|
+
We look into leading_lines comments, else fallback to searching raw src segment
|
|
290
|
+
of the statement's leading trivia.
|
|
291
|
+
"""
|
|
292
|
+
from wexample_filestate.helpers.flag import flag_exists
|
|
293
|
+
|
|
294
|
+
# Check libcst leading_lines comments
|
|
295
|
+
for el in stmt.leading_lines:
|
|
296
|
+
if el.comment is not None:
|
|
297
|
+
comment_text = el.comment.value # includes '#'
|
|
298
|
+
if flag_exists(FLAG_NAME, comment_text):
|
|
299
|
+
return True
|
|
300
|
+
# Do NOT fallback to scanning the entire file universally; detection via
|
|
301
|
+
# previous sibling EmptyLine is handled by the callers.
|
|
302
|
+
return False
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
import libcst as cst
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def find_module_docstring(
|
|
12
|
+
module: cst.Module,
|
|
13
|
+
) -> tuple[cst.SimpleStatementLine | None, int]:
|
|
14
|
+
"""Find the module docstring in a CST module.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
module: The CST module to search
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
A tuple of (docstring_node, position) where position is the index
|
|
21
|
+
in module.body. Returns (None, -1) if no docstring found.
|
|
22
|
+
"""
|
|
23
|
+
for i, stmt in enumerate(module.body):
|
|
24
|
+
# Skip comments and blank lines at the start
|
|
25
|
+
if isinstance(stmt, (cst.SimpleStatementLine)):
|
|
26
|
+
if len(stmt.body) == 1 and isinstance(stmt.body[0], cst.Expr):
|
|
27
|
+
expr = stmt.body[0]
|
|
28
|
+
if isinstance(expr.value, cst.SimpleString):
|
|
29
|
+
# This is a string literal at module level - likely a docstring
|
|
30
|
+
return stmt, i
|
|
31
|
+
elif not isinstance(stmt, cst.SimpleStatementLine):
|
|
32
|
+
# Hit a non-simple statement (like import, class, def) - no docstring
|
|
33
|
+
break
|
|
34
|
+
|
|
35
|
+
return None, -1
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_module_docstring_at_top(module: cst.Module) -> bool:
|
|
39
|
+
"""Check if the module docstring is already at the top position.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
module: The CST module to check
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
True if docstring is at position 0, False otherwise
|
|
46
|
+
"""
|
|
47
|
+
docstring_node, position = find_module_docstring(module)
|
|
48
|
+
return docstring_node is not None and position == 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def move_docstring_to_top(module: cst.Module) -> cst.Module:
|
|
52
|
+
"""Move the module docstring to the top of the file.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
module: The CST module to modify
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Modified module with docstring at the top
|
|
59
|
+
"""
|
|
60
|
+
docstring_node, position = find_module_docstring(module)
|
|
61
|
+
|
|
62
|
+
if docstring_node is None or position == 0:
|
|
63
|
+
# No docstring or already at top
|
|
64
|
+
return module
|
|
65
|
+
|
|
66
|
+
# Normalize quotes in the docstring
|
|
67
|
+
normalized_docstring = normalize_docstring_quotes(docstring_node)
|
|
68
|
+
|
|
69
|
+
# Remove docstring from current position
|
|
70
|
+
new_body = list(module.body)
|
|
71
|
+
new_body.pop(position)
|
|
72
|
+
|
|
73
|
+
# Insert at the beginning with no leading whitespace
|
|
74
|
+
# Ensure the docstring has no leading newlines
|
|
75
|
+
clean_docstring = normalized_docstring.with_changes(leading_lines=[])
|
|
76
|
+
|
|
77
|
+
new_body.insert(0, clean_docstring)
|
|
78
|
+
|
|
79
|
+
return module.with_changes(body=new_body)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def normalize_docstring_quotes(
|
|
83
|
+
docstring_node: cst.SimpleStatementLine,
|
|
84
|
+
) -> cst.SimpleStatementLine:
|
|
85
|
+
"""Convert single quotes to double quotes in docstring nodes.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
docstring_node: A CST node containing a docstring statement
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
The same node with normalized double quotes
|
|
92
|
+
"""
|
|
93
|
+
if not isinstance(docstring_node.body[0], cst.Expr):
|
|
94
|
+
return docstring_node
|
|
95
|
+
|
|
96
|
+
expr = docstring_node.body[0]
|
|
97
|
+
if not isinstance(expr.value, cst.SimpleString):
|
|
98
|
+
return docstring_node
|
|
99
|
+
|
|
100
|
+
string_value = expr.value
|
|
101
|
+
quote = string_value.quote
|
|
102
|
+
|
|
103
|
+
# Convert single quotes to double quotes
|
|
104
|
+
if quote.startswith("'''"):
|
|
105
|
+
new_quote = '"""'
|
|
106
|
+
elif quote.startswith("'"):
|
|
107
|
+
new_quote = '"'
|
|
108
|
+
else:
|
|
109
|
+
# Already using double quotes
|
|
110
|
+
return docstring_node
|
|
111
|
+
|
|
112
|
+
# Create new string with double quotes
|
|
113
|
+
new_string = string_value.with_changes(quote=new_quote)
|
|
114
|
+
new_expr = expr.with_changes(value=new_string)
|
|
115
|
+
new_body = [new_expr] + list(docstring_node.body[1:])
|
|
116
|
+
|
|
117
|
+
return docstring_node.with_changes(body=new_body)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import libcst as cst
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def collect_module_function_groups(
|
|
9
|
+
module: cst.Module,
|
|
10
|
+
) -> list[tuple[int, FunctionGroup]]:
|
|
11
|
+
"""Collect top-level functions into groups, preserving overload sequences.
|
|
12
|
+
|
|
13
|
+
A group is formed by consecutive FunctionDef nodes with the same name when
|
|
14
|
+
the first N-1 have @overload and the last is the implementation (may or may not
|
|
15
|
+
have @overload in stub-only modules). If there are multiple consecutive
|
|
16
|
+
@overload for a name but no implementation following, they still form a group.
|
|
17
|
+
"""
|
|
18
|
+
groups: list[tuple[int, FunctionGroup]] = []
|
|
19
|
+
i = 0
|
|
20
|
+
body = module.body
|
|
21
|
+
n = len(body)
|
|
22
|
+
while i < n:
|
|
23
|
+
node = body[i]
|
|
24
|
+
if isinstance(node, cst.FunctionDef):
|
|
25
|
+
name = _func_name(node)
|
|
26
|
+
j = i + 1
|
|
27
|
+
collected: list[cst.FunctionDef] = [node]
|
|
28
|
+
# collect further overloads of the same name that are directly consecutive
|
|
29
|
+
while j < n:
|
|
30
|
+
next_node = body[j]
|
|
31
|
+
if (
|
|
32
|
+
isinstance(next_node, cst.FunctionDef)
|
|
33
|
+
and _func_name(next_node) == name
|
|
34
|
+
):
|
|
35
|
+
collected.append(next_node)
|
|
36
|
+
j += 1
|
|
37
|
+
continue
|
|
38
|
+
break
|
|
39
|
+
groups.append((i, FunctionGroup(name=name, nodes=tuple(collected))))
|
|
40
|
+
i = j
|
|
41
|
+
continue
|
|
42
|
+
i += 1
|
|
43
|
+
return groups
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def module_functions_sorted_before_classes(module: cst.Module) -> bool:
|
|
47
|
+
"""Check if all function groups appear before the first class in the module."""
|
|
48
|
+
first_class_index = None
|
|
49
|
+
for idx, node in enumerate(module.body):
|
|
50
|
+
if isinstance(node, cst.ClassDef):
|
|
51
|
+
first_class_index = idx
|
|
52
|
+
break
|
|
53
|
+
if first_class_index is None:
|
|
54
|
+
return True
|
|
55
|
+
# Find first function index
|
|
56
|
+
for idx, node in enumerate(module.body):
|
|
57
|
+
if isinstance(node, cst.FunctionDef):
|
|
58
|
+
return idx < first_class_index
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def reorder_module_functions(module: cst.Module) -> cst.Module:
|
|
63
|
+
"""Reorder module-level functions: group, sort (public then private), and place before classes.
|
|
64
|
+
|
|
65
|
+
Keeps overload groups intact and preserves each group's leading_lines on its first function.
|
|
66
|
+
"""
|
|
67
|
+
groups_with_idx = collect_module_function_groups(module)
|
|
68
|
+
if not groups_with_idx:
|
|
69
|
+
return module
|
|
70
|
+
|
|
71
|
+
# Extract groups in original order
|
|
72
|
+
groups = [g for _, g in groups_with_idx]
|
|
73
|
+
|
|
74
|
+
# If there is only functions and no classes or their order already correct and sorted, skip?
|
|
75
|
+
# We'll compute a new ordering and compare.
|
|
76
|
+
sorted_groups = sort_function_groups(groups)
|
|
77
|
+
|
|
78
|
+
# Remove all function nodes from body
|
|
79
|
+
remove_indices = []
|
|
80
|
+
for idx, g in groups_with_idx:
|
|
81
|
+
remove_indices.extend(range(idx, idx + len(g.nodes)))
|
|
82
|
+
remove_indices = sorted(set(remove_indices))
|
|
83
|
+
|
|
84
|
+
new_body: list[cst.CSTNode] = []
|
|
85
|
+
for idx, node in enumerate(module.body):
|
|
86
|
+
if idx in remove_indices:
|
|
87
|
+
continue
|
|
88
|
+
new_body.append(node)
|
|
89
|
+
|
|
90
|
+
# Determine insertion index using an anchor strategy:
|
|
91
|
+
# - Find the index of the FIRST function definition in the original module
|
|
92
|
+
# - Reinsert the whole (sorted) functions block at that original position
|
|
93
|
+
# (adjusted for removals). This avoids moving unrelated code like type
|
|
94
|
+
# aliases or sys.path mutations and preserves the developer's chosen
|
|
95
|
+
# placement of the function block.
|
|
96
|
+
def _is_main_guard(node: cst.CSTNode) -> bool:
|
|
97
|
+
if not isinstance(node, cst.If):
|
|
98
|
+
return False
|
|
99
|
+
test = node.test
|
|
100
|
+
# Match patterns like: if __name__ == "__main__":
|
|
101
|
+
if isinstance(test, cst.Comparison):
|
|
102
|
+
left = test.left
|
|
103
|
+
comps = test.comparisons
|
|
104
|
+
if (
|
|
105
|
+
len(comps) == 1
|
|
106
|
+
and isinstance(left, cst.Name)
|
|
107
|
+
and left.value == "__name__"
|
|
108
|
+
):
|
|
109
|
+
comp = comps[0]
|
|
110
|
+
# operator should be ==
|
|
111
|
+
if isinstance(comp.operator, cst.Equal):
|
|
112
|
+
right = comp.comparator
|
|
113
|
+
if isinstance(right, cst.SimpleString):
|
|
114
|
+
val = (
|
|
115
|
+
right.evaluated_value
|
|
116
|
+
if hasattr(right, "evaluated_value")
|
|
117
|
+
else right.value.strip("\"'")
|
|
118
|
+
)
|
|
119
|
+
return val == "__main__" or right.value.strip() in (
|
|
120
|
+
"'__main__'",
|
|
121
|
+
'"__main__"',
|
|
122
|
+
)
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
# Anchor = index of first function in original body
|
|
126
|
+
first_func_index: int | None = None
|
|
127
|
+
for idx, node in enumerate(module.body):
|
|
128
|
+
if isinstance(node, cst.FunctionDef):
|
|
129
|
+
first_func_index = idx
|
|
130
|
+
break
|
|
131
|
+
|
|
132
|
+
if first_func_index is None:
|
|
133
|
+
# No functions at module level
|
|
134
|
+
return module
|
|
135
|
+
|
|
136
|
+
# Adjust anchor for removed nodes
|
|
137
|
+
removed_before_anchor = sum(1 for i in remove_indices if i < first_func_index)
|
|
138
|
+
insert_at = first_func_index - removed_before_anchor
|
|
139
|
+
|
|
140
|
+
# Keep __main__ guard last: if we somehow would insert after it, clamp to its position
|
|
141
|
+
for idx, node in enumerate(new_body):
|
|
142
|
+
if _is_main_guard(node) and insert_at > idx:
|
|
143
|
+
insert_at = idx
|
|
144
|
+
break
|
|
145
|
+
|
|
146
|
+
# Ensure functions come before the first class if any
|
|
147
|
+
first_class_index = None
|
|
148
|
+
for idx, node in enumerate(new_body):
|
|
149
|
+
if isinstance(node, cst.ClassDef):
|
|
150
|
+
first_class_index = idx
|
|
151
|
+
break
|
|
152
|
+
if first_class_index is not None and insert_at > first_class_index:
|
|
153
|
+
insert_at = first_class_index
|
|
154
|
+
|
|
155
|
+
# Build function nodes preserving each group's comments/spacing on first element
|
|
156
|
+
rebuilt_functions: list[cst.CSTNode] = []
|
|
157
|
+
for g in sorted_groups:
|
|
158
|
+
# Preserve leading_lines of the original first node in the group
|
|
159
|
+
original_first_leading = g.nodes[0].leading_lines
|
|
160
|
+
for k, fn in enumerate(g.nodes):
|
|
161
|
+
if k == 0:
|
|
162
|
+
rebuilt_functions.append(
|
|
163
|
+
fn.with_changes(leading_lines=original_first_leading)
|
|
164
|
+
)
|
|
165
|
+
else:
|
|
166
|
+
rebuilt_functions.append(fn.with_changes(leading_lines=[]))
|
|
167
|
+
|
|
168
|
+
# Insert functions as a contiguous block
|
|
169
|
+
new_body[insert_at:insert_at] = rebuilt_functions
|
|
170
|
+
|
|
171
|
+
return module.with_changes(body=new_body)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def sort_function_groups(groups: list[FunctionGroup]) -> list[FunctionGroup]:
|
|
175
|
+
"""Sort groups by public (A–Z) then private (_*), each alphabetically case-insensitive."""
|
|
176
|
+
public = [g for g in groups if not _is_private_name(g.name)]
|
|
177
|
+
private = [g for g in groups if _is_private_name(g.name)]
|
|
178
|
+
public.sort(key=lambda g: g.name.lower())
|
|
179
|
+
private.sort(key=lambda g: g.name.lower())
|
|
180
|
+
return public + private
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _func_name(fn: cst.FunctionDef) -> str:
|
|
184
|
+
return fn.name.value
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _has_overload_decorator(fn: cst.FunctionDef) -> bool:
|
|
188
|
+
if fn.decorators:
|
|
189
|
+
return any(_is_overload_decorator(d) for d in fn.decorators)
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _is_overload_decorator(dec: cst.Decorator) -> bool:
|
|
194
|
+
expr = dec.decorator
|
|
195
|
+
# @overload
|
|
196
|
+
if isinstance(expr, cst.Name) and expr.value == "overload":
|
|
197
|
+
return True
|
|
198
|
+
# @typing.overload
|
|
199
|
+
if isinstance(expr, cst.Attribute):
|
|
200
|
+
if isinstance(expr.attr, cst.Name) and expr.attr.value == "overload":
|
|
201
|
+
return True
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _is_private_name(name: str) -> bool:
|
|
206
|
+
return name.startswith("_")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@dataclass(frozen=True)
|
|
210
|
+
class FunctionGroup:
|
|
211
|
+
name: str
|
|
212
|
+
nodes: tuple[cst.FunctionDef, ...]
|