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.
Files changed (80) hide show
  1. wexample_filestate_python/__init__.py +0 -0
  2. wexample_filestate_python/__pycache__/__init__.py +0 -0
  3. wexample_filestate_python/common/__init__.py +0 -0
  4. wexample_filestate_python/common/__pycache__/__init__.py +0 -0
  5. wexample_filestate_python/common/pipy_gateway.py +20 -0
  6. wexample_filestate_python/config_option/__init__.py +0 -0
  7. wexample_filestate_python/config_option/__pycache__/__init__.py +0 -0
  8. wexample_filestate_python/config_option/mixin/__init__.py +0 -0
  9. wexample_filestate_python/config_option/mixin/__pycache__/__init__.py +0 -0
  10. wexample_filestate_python/config_option/mixin/with_stdout_wrapping_mixin.py +46 -0
  11. wexample_filestate_python/config_value/__init__.py +0 -0
  12. wexample_filestate_python/config_value/__pycache__/__init__.py +0 -0
  13. wexample_filestate_python/config_value/python_config_value.py +195 -0
  14. wexample_filestate_python/const/__init__.py +0 -0
  15. wexample_filestate_python/const/__pycache__/__init__.py +0 -0
  16. wexample_filestate_python/const/name_pattern.py +4 -0
  17. wexample_filestate_python/const/python_file.py +5 -0
  18. wexample_filestate_python/file/__init__.py +0 -0
  19. wexample_filestate_python/file/__pycache__/__init__.py +0 -0
  20. wexample_filestate_python/file/python_file.py +12 -0
  21. wexample_filestate_python/helpers/__init__.py +0 -0
  22. wexample_filestate_python/helpers/__pycache__/__init__.py +0 -0
  23. wexample_filestate_python/helpers/package.py +122 -0
  24. wexample_filestate_python/helpers/toml.py +116 -0
  25. wexample_filestate_python/option/__init__.py +0 -0
  26. wexample_filestate_python/option/__pycache__/__init__.py +0 -0
  27. wexample_filestate_python/option/abstract_python_file_content_option.py +45 -0
  28. wexample_filestate_python/option/add_future_annotations_option.py +79 -0
  29. wexample_filestate_python/option/add_return_types_option.py +265 -0
  30. wexample_filestate_python/option/fix_attrs_option.py +37 -0
  31. wexample_filestate_python/option/fix_blank_lines_option.py +47 -0
  32. wexample_filestate_python/option/format_option.py +34 -0
  33. wexample_filestate_python/option/fstringify_option.py +34 -0
  34. wexample_filestate_python/option/modernize_typing_option.py +25 -0
  35. wexample_filestate_python/option/order_class_attributes_option.py +34 -0
  36. wexample_filestate_python/option/order_class_docstring_option.py +36 -0
  37. wexample_filestate_python/option/order_class_methods_option.py +37 -0
  38. wexample_filestate_python/option/order_constants_option.py +35 -0
  39. wexample_filestate_python/option/order_iterable_items_option.py +31 -0
  40. wexample_filestate_python/option/order_main_guard_option.py +44 -0
  41. wexample_filestate_python/option/order_module_docstring_option.py +73 -0
  42. wexample_filestate_python/option/order_module_functions_option.py +42 -0
  43. wexample_filestate_python/option/order_module_metadata_option.py +62 -0
  44. wexample_filestate_python/option/order_type_checking_block_option.py +51 -0
  45. wexample_filestate_python/option/python_option.py +164 -0
  46. wexample_filestate_python/option/relocate_imports_option.py +189 -0
  47. wexample_filestate_python/option/remove_unused_option.py +45 -0
  48. wexample_filestate_python/option/sort_imports_option.py +26 -0
  49. wexample_filestate_python/option/unquote_annotations_option.py +85 -0
  50. wexample_filestate_python/options_provider/__init__.py +0 -0
  51. wexample_filestate_python/options_provider/__pycache__/__init__.py +0 -0
  52. wexample_filestate_python/options_provider/python_options_provider.py +24 -0
  53. wexample_filestate_python/py.typed +0 -0
  54. wexample_filestate_python/utils/__init__.py +0 -0
  55. wexample_filestate_python/utils/__pycache__/__init__.py +0 -0
  56. wexample_filestate_python/utils/python_attrs_utils.py +112 -0
  57. wexample_filestate_python/utils/python_blank_lines_utils.py +568 -0
  58. wexample_filestate_python/utils/python_class_attributes_utils.py +275 -0
  59. wexample_filestate_python/utils/python_class_docstring_utils.py +85 -0
  60. wexample_filestate_python/utils/python_class_methods_utils.py +230 -0
  61. wexample_filestate_python/utils/python_constants_utils.py +302 -0
  62. wexample_filestate_python/utils/python_docstring_utils.py +117 -0
  63. wexample_filestate_python/utils/python_functions_utils.py +212 -0
  64. wexample_filestate_python/utils/python_iterable_utils.py +131 -0
  65. wexample_filestate_python/utils/python_main_guard_utils.py +80 -0
  66. wexample_filestate_python/utils/python_module_metadata_utils.py +147 -0
  67. wexample_filestate_python/utils/python_type_checking_utils.py +113 -0
  68. wexample_filestate_python/utils/relocate_imports/__init__.py +7 -0
  69. wexample_filestate_python/utils/relocate_imports/__pycache__/__init__.py +0 -0
  70. wexample_filestate_python/utils/relocate_imports/python_import_rewriter.py +413 -0
  71. wexample_filestate_python/utils/relocate_imports/python_localize_runtime_imports.py +324 -0
  72. wexample_filestate_python/utils/relocate_imports/python_parser_import_index.py +80 -0
  73. wexample_filestate_python/utils/relocate_imports/python_runtime_symbol_collector.py +33 -0
  74. wexample_filestate_python/utils/relocate_imports/python_usage_collector.py +410 -0
  75. wexample_filestate_python/workdir/__init__.py +0 -0
  76. wexample_filestate_python/workdir/__pycache__/__init__.py +0 -0
  77. wexample_filestate_python-0.0.48.dist-info/METADATA +191 -0
  78. wexample_filestate_python-0.0.48.dist-info/RECORD +80 -0
  79. wexample_filestate_python-0.0.48.dist-info/WHEEL +4 -0
  80. 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, ...]