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/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