funcsort 0.2.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.
- funcsort/__init__.py +3 -0
- funcsort/config.py +158 -0
- funcsort/config_types.py +77 -0
- funcsort/groups.py +184 -0
- funcsort/logger.py +32 -0
- funcsort/main.py +171 -0
- funcsort/sorter.py +338 -0
- funcsort-0.2.0.dist-info/METADATA +384 -0
- funcsort-0.2.0.dist-info/RECORD +12 -0
- funcsort-0.2.0.dist-info/WHEEL +4 -0
- funcsort-0.2.0.dist-info/entry_points.txt +2 -0
- funcsort-0.2.0.dist-info/licenses/LICENSE +21 -0
funcsort/sorter.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Core sorting logic for class methods and module-level functions.
|
|
2
|
+
|
|
3
|
+
The transformer is a thin libcst adapter over the pure model in :mod:`funcsort.groups`.
|
|
4
|
+
A single shared helper (:func:`_sort_block`) reorders the members of any block — a class
|
|
5
|
+
body or the module body — so class and module scope share one implementation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import difflib
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import cast, override
|
|
15
|
+
|
|
16
|
+
import libcst as cst
|
|
17
|
+
|
|
18
|
+
from funcsort.groups import (
|
|
19
|
+
Group,
|
|
20
|
+
Member,
|
|
21
|
+
MemberKind,
|
|
22
|
+
MethodKind,
|
|
23
|
+
Scope,
|
|
24
|
+
classify,
|
|
25
|
+
default_groups,
|
|
26
|
+
)
|
|
27
|
+
from . import logger
|
|
28
|
+
|
|
29
|
+
_DEFAULT_METHOD_TYPE_ORDER = [MethodKind.INSTANCE, MethodKind.CLASS, MethodKind.STATIC]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class BlockSortResult:
|
|
34
|
+
"""Outcome of sorting a single block (class or module body)."""
|
|
35
|
+
|
|
36
|
+
new_body: list[cst.BaseStatement]
|
|
37
|
+
modified: bool
|
|
38
|
+
unmatched: tuple[Member, ...]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class SortResult:
|
|
43
|
+
"""Outcome of sorting a whole file."""
|
|
44
|
+
|
|
45
|
+
path: Path
|
|
46
|
+
modified: bool
|
|
47
|
+
unmatched: tuple[Member, ...] = ()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def has_nosort_comment(node: cst.CSTNode) -> bool:
|
|
51
|
+
"""Return whether ``node`` carries a ``# nosort`` directive.
|
|
52
|
+
|
|
53
|
+
Looks at leading comment lines, a trailing comment on the same line (assignments),
|
|
54
|
+
and the trailing comment of a block header (functions/classes). Case-insensitive.
|
|
55
|
+
"""
|
|
56
|
+
for line in getattr(node, "leading_lines", []):
|
|
57
|
+
if isinstance(line, cst.EmptyLine) and line.comment and "nosort" in line.comment.value.lower():
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
trailing = getattr(node, "trailing_whitespace", None)
|
|
61
|
+
if isinstance(trailing, cst.TrailingWhitespace) and trailing.comment and "nosort" in trailing.comment.value.lower():
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
body = getattr(node, "body", None)
|
|
65
|
+
header = getattr(body, "header", None)
|
|
66
|
+
return bool(
|
|
67
|
+
isinstance(header, cst.TrailingWhitespace) and header.comment and "nosort" in header.comment.value.lower(),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def file_has_nosort(module: cst.Module) -> bool:
|
|
72
|
+
"""Return whether the file has a ``# nosort: file`` directive in its header."""
|
|
73
|
+
for line in module.header:
|
|
74
|
+
if isinstance(line, cst.EmptyLine) and line.comment: # pyright: ignore[reportUnnecessaryIsInstance]
|
|
75
|
+
comment_text = line.comment.value.lower()
|
|
76
|
+
if "nosort" in comment_text and "file" in comment_text:
|
|
77
|
+
return True
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_method_type(method: cst.FunctionDef) -> MethodKind:
|
|
82
|
+
"""Determine a function's binding from its decorators."""
|
|
83
|
+
for name in _decorator_names(method):
|
|
84
|
+
if name == "classmethod":
|
|
85
|
+
return MethodKind.CLASS
|
|
86
|
+
if name == "staticmethod":
|
|
87
|
+
return MethodKind.STATIC
|
|
88
|
+
return MethodKind.INSTANCE
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def sort_block(
|
|
92
|
+
body: Sequence[cst.BaseStatement],
|
|
93
|
+
*,
|
|
94
|
+
scope: Scope,
|
|
95
|
+
groups: list[Group],
|
|
96
|
+
method_type_order: list[MethodKind],
|
|
97
|
+
) -> BlockSortResult:
|
|
98
|
+
"""Reorder the sortable members of a block, anchoring everything else in place."""
|
|
99
|
+
items = list(body)
|
|
100
|
+
assignments_sortable = any(group.targets_assignments() for group in groups)
|
|
101
|
+
|
|
102
|
+
buckets: dict[tuple[str, MethodKind], list[_Placed]] = {}
|
|
103
|
+
unmatched: list[Member] = []
|
|
104
|
+
candidate_indices: list[int] = []
|
|
105
|
+
for index, item in enumerate(items):
|
|
106
|
+
member = _as_member(index, item, scope)
|
|
107
|
+
if member is None or has_nosort_comment(item):
|
|
108
|
+
continue
|
|
109
|
+
if member.kind is MemberKind.ASSIGNMENT and not assignments_sortable:
|
|
110
|
+
continue
|
|
111
|
+
position = len(candidate_indices)
|
|
112
|
+
candidate_indices.append(index)
|
|
113
|
+
result = classify(member, groups)
|
|
114
|
+
if result.group is None:
|
|
115
|
+
unmatched.append(member)
|
|
116
|
+
else:
|
|
117
|
+
buckets.setdefault((result.group.name, member.method_type), []).append((position, member))
|
|
118
|
+
|
|
119
|
+
if not candidate_indices:
|
|
120
|
+
return BlockSortResult(items, modified=False, unmatched=())
|
|
121
|
+
|
|
122
|
+
effective_order = _effective_method_type_order(method_type_order)
|
|
123
|
+
ordered: list[Member] = []
|
|
124
|
+
current_position = 0
|
|
125
|
+
for group in groups:
|
|
126
|
+
for method_type in effective_order:
|
|
127
|
+
bucket = buckets.get((group.name, method_type))
|
|
128
|
+
if not bucket:
|
|
129
|
+
continue
|
|
130
|
+
ordered.extend(_order_bucket(bucket, current_position))
|
|
131
|
+
current_position += len(bucket)
|
|
132
|
+
ordered.extend(unmatched)
|
|
133
|
+
|
|
134
|
+
ordered_nodes = [cast("cst.BaseStatement", member.node) for member in ordered]
|
|
135
|
+
original_nodes = [items[index] for index in candidate_indices]
|
|
136
|
+
modified = any(new is not old for new, old in zip(ordered_nodes, original_nodes, strict=True))
|
|
137
|
+
|
|
138
|
+
fill = iter(ordered_nodes)
|
|
139
|
+
candidate_set = set(candidate_indices)
|
|
140
|
+
new_body = [next(fill) if index in candidate_set else item for index, item in enumerate(items)]
|
|
141
|
+
|
|
142
|
+
return BlockSortResult(new_body, modified=modified, unmatched=tuple(unmatched))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def sort_file(
|
|
146
|
+
file_path: Path,
|
|
147
|
+
groups: list[Group] | None = None,
|
|
148
|
+
method_type_order: list[MethodKind] | None = None,
|
|
149
|
+
*,
|
|
150
|
+
sort_module: bool = True,
|
|
151
|
+
check_only: bool = False,
|
|
152
|
+
show_diff: bool = False,
|
|
153
|
+
) -> SortResult:
|
|
154
|
+
"""Sort the methods (and optionally module functions) of a Python file.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
file_path: The Python file to sort.
|
|
158
|
+
groups: Ordered groups; defaults to the built-in groups when omitted.
|
|
159
|
+
method_type_order: Secondary ordering; defaults to instance/class/static.
|
|
160
|
+
sort_module: Whether to sort module-level functions.
|
|
161
|
+
check_only: If True, do not write changes back to disk.
|
|
162
|
+
show_diff: If True, print a unified diff of the changes.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
A :class:`SortResult` describing whether the file changed and any unmatched members.
|
|
166
|
+
"""
|
|
167
|
+
resolved_groups = groups if groups is not None else default_groups()
|
|
168
|
+
resolved_order = method_type_order if method_type_order is not None else list(_DEFAULT_METHOD_TYPE_ORDER)
|
|
169
|
+
|
|
170
|
+
with open(file_path, encoding="utf-8") as f:
|
|
171
|
+
source_code = f.read()
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
tree = cst.parse_module(source_code)
|
|
175
|
+
except cst.ParserSyntaxError as e:
|
|
176
|
+
raise ValueError(f"Syntax error in {file_path}: {e}")
|
|
177
|
+
|
|
178
|
+
if file_has_nosort(tree):
|
|
179
|
+
return SortResult(file_path, modified=False)
|
|
180
|
+
|
|
181
|
+
sorter = MethodSorter(resolved_groups, resolved_order, sort_module=sort_module)
|
|
182
|
+
new_tree = tree.visit(sorter)
|
|
183
|
+
|
|
184
|
+
if not sorter.modified:
|
|
185
|
+
return SortResult(file_path, modified=False, unmatched=tuple(sorter.unmatched))
|
|
186
|
+
|
|
187
|
+
new_code = new_tree.code
|
|
188
|
+
|
|
189
|
+
if show_diff:
|
|
190
|
+
diff = difflib.unified_diff(
|
|
191
|
+
source_code.splitlines(keepends=True),
|
|
192
|
+
new_code.splitlines(keepends=True),
|
|
193
|
+
fromfile=str(file_path),
|
|
194
|
+
tofile=str(file_path),
|
|
195
|
+
)
|
|
196
|
+
logger.diff("".join(diff))
|
|
197
|
+
|
|
198
|
+
if not check_only:
|
|
199
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
200
|
+
f.write(new_code)
|
|
201
|
+
|
|
202
|
+
return SortResult(file_path, modified=True, unmatched=tuple(sorter.unmatched))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _decorator_names(method: cst.FunctionDef) -> tuple[str, ...]:
|
|
206
|
+
"""Return the normalised dotted names of a function's decorators (calls stripped)."""
|
|
207
|
+
return tuple(name for name in (_decorator_name(d.decorator) for d in method.decorators) if name)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _decorator_name(node: cst.BaseExpression) -> str:
|
|
211
|
+
"""Normalise a decorator expression to its dotted name (``a.b.c``), stripping calls."""
|
|
212
|
+
if isinstance(node, cst.Call):
|
|
213
|
+
node = node.func
|
|
214
|
+
parts: list[str] = []
|
|
215
|
+
while isinstance(node, cst.Attribute):
|
|
216
|
+
parts.append(node.attr.value)
|
|
217
|
+
node = node.value
|
|
218
|
+
if isinstance(node, cst.Name):
|
|
219
|
+
parts.append(node.value)
|
|
220
|
+
return ".".join(reversed(parts))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _assignment_target_name(line: cst.SimpleStatementLine) -> str | None:
|
|
224
|
+
"""Return the single simple target name of an assignment line, else None."""
|
|
225
|
+
if len(line.body) != 1:
|
|
226
|
+
return None
|
|
227
|
+
statement = line.body[0]
|
|
228
|
+
if isinstance(statement, cst.Assign):
|
|
229
|
+
if len(statement.targets) == 1 and isinstance(statement.targets[0].target, cst.Name):
|
|
230
|
+
return statement.targets[0].target.value
|
|
231
|
+
return None
|
|
232
|
+
if isinstance(statement, cst.AnnAssign) and isinstance(statement.target, cst.Name):
|
|
233
|
+
return statement.target.value
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# A candidate paired with its position in the candidate sequence (the index space the
|
|
238
|
+
# minimise-movement logic operates on, independent of anchored/structural items).
|
|
239
|
+
_Placed = tuple[int, Member]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _as_member(index: int, node: cst.BaseStatement, scope: Scope) -> Member | None:
|
|
243
|
+
"""Build a :class:`Member` for a sortable statement, or None for structural items."""
|
|
244
|
+
if isinstance(node, cst.FunctionDef):
|
|
245
|
+
return Member(
|
|
246
|
+
index,
|
|
247
|
+
node,
|
|
248
|
+
MemberKind.FUNCTION,
|
|
249
|
+
node.name.value,
|
|
250
|
+
get_method_type(node),
|
|
251
|
+
scope,
|
|
252
|
+
_decorator_names(node),
|
|
253
|
+
)
|
|
254
|
+
if isinstance(node, cst.SimpleStatementLine):
|
|
255
|
+
name = _assignment_target_name(node)
|
|
256
|
+
if name is not None:
|
|
257
|
+
return Member(index, node, MemberKind.ASSIGNMENT, name, MethodKind.INSTANCE, scope)
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _effective_method_type_order(method_type_order: list[MethodKind]) -> list[MethodKind]:
|
|
262
|
+
"""Return the order with any missing method types appended (never drop members)."""
|
|
263
|
+
return [*method_type_order, *(t for t in MethodKind if t not in method_type_order)]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class MethodSorter(cst.CSTTransformer):
|
|
267
|
+
"""Transformer that sorts class methods and (optionally) module-level functions."""
|
|
268
|
+
|
|
269
|
+
def __init__(
|
|
270
|
+
self,
|
|
271
|
+
groups: list[Group],
|
|
272
|
+
method_type_order: list[MethodKind],
|
|
273
|
+
sort_module: bool = True,
|
|
274
|
+
) -> None:
|
|
275
|
+
"""Initialise the transformer with resolved configuration."""
|
|
276
|
+
self.groups = groups
|
|
277
|
+
self.method_type_order = method_type_order
|
|
278
|
+
self.sort_module = sort_module
|
|
279
|
+
self.modified = False
|
|
280
|
+
self.unmatched: list[Member] = []
|
|
281
|
+
|
|
282
|
+
@override
|
|
283
|
+
def leave_ClassDef(self, original_node: cst.ClassDef, updated_node: cst.ClassDef) -> cst.ClassDef: # noqa: N802, ARG002
|
|
284
|
+
"""Sort the methods within a class definition."""
|
|
285
|
+
body = updated_node.body
|
|
286
|
+
if has_nosort_comment(updated_node) or not isinstance(body, cst.IndentedBlock):
|
|
287
|
+
return updated_node
|
|
288
|
+
new_body = self._apply(list(body.body), Scope.CLASS)
|
|
289
|
+
if new_body is None:
|
|
290
|
+
return updated_node
|
|
291
|
+
return updated_node.with_changes(body=body.with_changes(body=new_body))
|
|
292
|
+
|
|
293
|
+
@override
|
|
294
|
+
def leave_Module(self, original_node: cst.Module, updated_node: cst.Module) -> cst.Module: # noqa: N802, ARG002
|
|
295
|
+
"""Sort the top-level functions of a module when enabled."""
|
|
296
|
+
if not self.sort_module:
|
|
297
|
+
return updated_node
|
|
298
|
+
new_body = self._apply(list(updated_node.body), Scope.MODULE)
|
|
299
|
+
if new_body is None:
|
|
300
|
+
return updated_node
|
|
301
|
+
return updated_node.with_changes(body=new_body)
|
|
302
|
+
|
|
303
|
+
def _apply(self, body: list[cst.BaseStatement], scope: Scope) -> list[cst.BaseStatement] | None:
|
|
304
|
+
"""Run the shared sorter on a block; record state; return the new body or None."""
|
|
305
|
+
result = sort_block(
|
|
306
|
+
body,
|
|
307
|
+
scope=scope,
|
|
308
|
+
groups=self.groups,
|
|
309
|
+
method_type_order=self.method_type_order,
|
|
310
|
+
)
|
|
311
|
+
self.unmatched.extend(result.unmatched)
|
|
312
|
+
if not result.modified:
|
|
313
|
+
return None
|
|
314
|
+
self.modified = True
|
|
315
|
+
return result.new_body
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _order_bucket(bucket: list[_Placed], current_position: int) -> list[Member]:
|
|
319
|
+
"""Order one bucket, minimising movement relative to the candidate sequence.
|
|
320
|
+
|
|
321
|
+
Members that started before the bucket's span (or before what is already placed) go
|
|
322
|
+
first, members within the span keep their place, and members after the span go last —
|
|
323
|
+
each subgroup stable by original position.
|
|
324
|
+
"""
|
|
325
|
+
positions = [position for position, _ in bucket]
|
|
326
|
+
min_pos, max_pos = min(positions), max(positions)
|
|
327
|
+
|
|
328
|
+
moved_down: list[Member] = []
|
|
329
|
+
in_place: list[Member] = []
|
|
330
|
+
moved_up: list[Member] = []
|
|
331
|
+
for position, member in sorted(bucket, key=lambda placed: placed[0]):
|
|
332
|
+
if position < min_pos or (current_position > 0 and position < current_position):
|
|
333
|
+
moved_down.append(member)
|
|
334
|
+
elif position > max_pos:
|
|
335
|
+
moved_up.append(member)
|
|
336
|
+
else:
|
|
337
|
+
in_place.append(member)
|
|
338
|
+
return moved_down + in_place + moved_up
|