defsort 0.1.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.
Files changed (58) hide show
  1. defsort/__init__.py +332 -0
  2. defsort/__main__.py +8 -0
  3. defsort/_classify.py +56 -0
  4. defsort/_cli.py +1186 -0
  5. defsort/_color.py +86 -0
  6. defsort/_config.py +576 -0
  7. defsort/_core/__init__.py +5 -0
  8. defsort/_core/_analysis.py +1237 -0
  9. defsort/_core/_api.py +373 -0
  10. defsort/_core/_banners.py +563 -0
  11. defsort/_core/_full.py +407 -0
  12. defsort/_core/_future.py +233 -0
  13. defsort/_core/_guard.py +151 -0
  14. defsort/_core/_hoist.py +238 -0
  15. defsort/_core/_incremental.py +741 -0
  16. defsort/_core/_layout.py +496 -0
  17. defsort/_core/_merge.py +123 -0
  18. defsort/_core/_methods.py +1283 -0
  19. defsort/_core/_ordering.py +394 -0
  20. defsort/_core/_parse.py +763 -0
  21. defsort/_core/_reassemble.py +42 -0
  22. defsort/_core/_safety.py +411 -0
  23. defsort/_detector.py +152 -0
  24. defsort/_display/__init__.py +16 -0
  25. defsort/_display/_constraints.py +60 -0
  26. defsort/_display/_detect.py +62 -0
  27. defsort/_display/_diff.py +105 -0
  28. defsort/_display/_json.py +138 -0
  29. defsort/_display/_movement.py +282 -0
  30. defsort/_display/_skipped.py +130 -0
  31. defsort/_display/_summary.py +211 -0
  32. defsort/_display/_terminal.py +24 -0
  33. defsort/_display/_types.py +166 -0
  34. defsort/_display/_width.py +34 -0
  35. defsort/_downstream.py +181 -0
  36. defsort/_files.py +272 -0
  37. defsort/_git.py +1021 -0
  38. defsort/_git_executable.py +51 -0
  39. defsort/_grammar.py +785 -0
  40. defsort/_hook.py +1564 -0
  41. defsort/_hosts/__init__.py +50 -0
  42. defsort/_shell.py +66 -0
  43. defsort/_shellparse.py +595 -0
  44. defsort/_source.py +140 -0
  45. defsort/_types.py +840 -0
  46. defsort/hooks/__init__.py +242 -0
  47. defsort/py.typed +0 -0
  48. defsort-0.1.0.dist-info/METADATA +150 -0
  49. defsort-0.1.0.dist-info/RECORD +58 -0
  50. defsort-0.1.0.dist-info/WHEEL +4 -0
  51. defsort-0.1.0.dist-info/entry_points.txt +7 -0
  52. defsort-0.1.0.dist-info/licenses/LICENSE +21 -0
  53. plugins/agy/_defsort/__init__.py +1 -0
  54. plugins/agy/_defsort/_adapter.py +121 -0
  55. plugins/claude/_defsort/__init__.py +1 -0
  56. plugins/claude/_defsort/_adapter.py +131 -0
  57. plugins/codex/_defsort/__init__.py +1 -0
  58. plugins/codex/_defsort/_adapter.py +142 -0
defsort/__init__.py ADDED
@@ -0,0 +1,332 @@
1
+ """Sort Python source strings by dependency or alphabetically.
2
+
3
+ Use `sort()`, `check()`, and `detect()` for source-string operations. `sort()`
4
+ and `check()` accept an explicit `DefsortConfig`; call `resolve_config()` when
5
+ a file's `[tool.defsort]` configuration should apply. Configuration discovery
6
+ is cached for the process lifetime, so long-running callers must call
7
+ `clear_config_cache()` after relevant `pyproject.toml` changes.
8
+ """
9
+
10
+ import ast
11
+ import warnings
12
+
13
+ from defsort._config import (
14
+ DefsortConfigWarning,
15
+ clear_config_cache,
16
+ resolve_config,
17
+ )
18
+ from defsort._config import (
19
+ ineffective_policy_values as _ineffective_policy_values,
20
+ )
21
+ from defsort._core import detect_order, sort_file
22
+ from defsort._core._incremental import sort_incremental_impl
23
+ from defsort._types import (
24
+ BANNER_MODE_VALUES,
25
+ BannerMode,
26
+ Blocker,
27
+ ClassBlocker,
28
+ DefsortConfig,
29
+ DetectResult,
30
+ EvaluationOrder,
31
+ MainPlacement,
32
+ MethodBodies,
33
+ MethodDunders,
34
+ MethodVisibility,
35
+ OrderKind,
36
+ SortCheckResult,
37
+ SortScopeOrder,
38
+ SortStatus,
39
+ TieBreak,
40
+ is_soft_blocker,
41
+ sort_policy_from_config,
42
+ validate_banner_pattern,
43
+ validate_config,
44
+ validate_vocabulary,
45
+ )
46
+ from defsort._types import (
47
+ EngineOptions as _EngineOptions,
48
+ )
49
+ from defsort._types import (
50
+ blocker_message as _blocker_message,
51
+ )
52
+
53
+ __all__ = [
54
+ 'BannerMode',
55
+ 'Blocker',
56
+ 'ClassBlocker',
57
+ 'DefsortConfig',
58
+ 'DefsortConfigWarning',
59
+ 'DetectResult',
60
+ 'EvaluationOrder',
61
+ 'MainPlacement',
62
+ 'MethodBodies',
63
+ 'MethodDunders',
64
+ 'MethodVisibility',
65
+ 'OrderKind',
66
+ 'SortCheckResult',
67
+ 'SortScopeOrder',
68
+ 'SortStatus',
69
+ 'TieBreak',
70
+ 'check',
71
+ 'clear_config_cache',
72
+ 'detect',
73
+ 'resolve_config',
74
+ 'sort',
75
+ ]
76
+
77
+
78
+ def _apply_hard_blocker(new_source: str | None, blocker: Blocker | None) -> str | None:
79
+ """Suppress new_source when a hard blocker is present."""
80
+ if new_source is not None and blocker is not None and not is_soft_blocker(blocker):
81
+ return None
82
+ return new_source
83
+
84
+
85
+ def _engine_options(config: DefsortConfig) -> _EngineOptions:
86
+ """Convert public configuration to concrete non-transforming engine settings."""
87
+ for key in _ineffective_policy_values(config):
88
+ warnings.warn(
89
+ f'[tool.defsort] {key} has no effect under the resolved strategies',
90
+ DefsortConfigWarning,
91
+ stacklevel=3,
92
+ )
93
+ return _EngineOptions(
94
+ policy=sort_policy_from_config(config),
95
+ force=config.force,
96
+ hoist=False,
97
+ add_future=False,
98
+ remove_future=False,
99
+ )
100
+
101
+
102
+ def _check_incremental(
103
+ source: str,
104
+ baseline: str,
105
+ *,
106
+ options: DefsortConfig,
107
+ ) -> SortCheckResult:
108
+ """Check whether incremental sorting would change the source."""
109
+ try:
110
+ tree = ast.parse(source)
111
+ except (SyntaxError, ValueError):
112
+ return SortCheckResult(status='invalid')
113
+
114
+ # sort_incremental_impl -> compile() can raise
115
+ # SyntaxError for constructs ast.parse accepts (bare return/break).
116
+ try:
117
+ result = sort_incremental_impl(
118
+ source,
119
+ baseline,
120
+ options=_engine_options(options),
121
+ _tree=tree,
122
+ )
123
+ except SyntaxError:
124
+ return SortCheckResult(status='invalid')
125
+ new_source = _apply_hard_blocker(result.new_source, result.blocker)
126
+ banner_constrained = bool(result.banner_constraints)
127
+ if new_source is not None:
128
+ return SortCheckResult(
129
+ status='changed',
130
+ banner_constrained=banner_constrained,
131
+ class_blockers=result.class_blockers,
132
+ )
133
+ if result.blocker is None:
134
+ return SortCheckResult(
135
+ status='skipped' if result.class_blockers else 'unchanged',
136
+ banner_constrained=banner_constrained,
137
+ class_blockers=result.class_blockers,
138
+ )
139
+ return SortCheckResult(
140
+ status='skipped',
141
+ blocker=result.blocker,
142
+ blocker_message=_blocker_message(result.blocker),
143
+ banner_constrained=banner_constrained,
144
+ class_blockers=result.class_blockers,
145
+ )
146
+
147
+
148
+ def _normalize_baseline(baseline: str | None) -> str | None:
149
+ """Return a non-empty incremental baseline, if present."""
150
+ if baseline is None or not baseline.strip():
151
+ return None
152
+ return baseline
153
+
154
+
155
+ def check(
156
+ source: str,
157
+ *,
158
+ baseline: str | None = None,
159
+ config: DefsortConfig | None = None,
160
+ ) -> SortCheckResult:
161
+ """Return the sort status of source without modifying it.
162
+
163
+ Args:
164
+ source: Python source code to analyze.
165
+ baseline: When non-empty, only check new definitions added since
166
+ baseline (incremental mode). Empty or whitespace-only baselines use
167
+ full sorting.
168
+ config: Transformation configuration. Defaults to `DefsortConfig()`.
169
+
170
+ Returns:
171
+ SortCheckResult with:
172
+ 'invalid' if source is not valid Python (SyntaxError or ValueError,
173
+ including null bytes and lone Unicode surrogates).
174
+ 'skipped' when sorting is blocked. A file-level skip carries its
175
+ classification in `blocker` and display text in
176
+ `blocker_message`. A class-only skip can instead report
177
+ `class_blockers` with both file-level fields `None`. A `layout`
178
+ blocker refuses the whole file even without an order change.
179
+ 'changed' if sort() would change the source.
180
+ 'unchanged' if source is already in the correct order.
181
+
182
+ Raises:
183
+ TypeError: If `config.force` is not a `bool`, or
184
+ `config.banner_pattern` is neither a string nor `None`.
185
+ ValueError: If a `config` vocabulary field holds an unknown value, or
186
+ `config.banner_pattern` does not compile or uses unsupported
187
+ syntax.
188
+ Compilation failures chain the `re.error`.
189
+ """
190
+ policy = config if config is not None else DefsortConfig()
191
+ validate_config(policy)
192
+ incremental_baseline = _normalize_baseline(baseline)
193
+ if incremental_baseline is not None:
194
+ return _check_incremental(source, incremental_baseline, options=policy)
195
+ try:
196
+ tree = ast.parse(source)
197
+ compile(tree, '<defsort.check>', 'exec')
198
+ except (SyntaxError, ValueError):
199
+ return SortCheckResult(status='invalid')
200
+ result = sort_file(
201
+ source,
202
+ options=_engine_options(policy),
203
+ _tree=tree,
204
+ )
205
+ banner_constrained = bool(result.banner_constraints)
206
+ if result.skipped:
207
+ old_ids = [d.index for d in result.original_order]
208
+ new_ids = [d.index for d in result.new_order]
209
+ if (
210
+ result.blocker != 'layout'
211
+ and old_ids == new_ids
212
+ and not result.method_sorts
213
+ and not result.class_blockers
214
+ ):
215
+ return SortCheckResult(
216
+ status='unchanged',
217
+ banner_constrained=banner_constrained,
218
+ )
219
+ message = _blocker_message(result.blocker) if result.blocker is not None else None
220
+ return SortCheckResult(
221
+ status='skipped',
222
+ blocker=result.blocker,
223
+ blocker_message=message,
224
+ banner_constrained=banner_constrained,
225
+ class_blockers=result.class_blockers,
226
+ )
227
+ if result.new_source != source:
228
+ return SortCheckResult(
229
+ status='changed',
230
+ banner_constrained=banner_constrained,
231
+ class_blockers=result.class_blockers,
232
+ )
233
+ return SortCheckResult(
234
+ status='unchanged',
235
+ banner_constrained=banner_constrained,
236
+ class_blockers=result.class_blockers,
237
+ )
238
+
239
+
240
+ def detect(
241
+ source: str,
242
+ *,
243
+ banners: BannerMode = 'barriers',
244
+ banner_pattern: str | None = None,
245
+ ) -> DetectResult | None:
246
+ """Detect what ordering a file's definitions follow.
247
+
248
+ Args:
249
+ source: Python source code to analyze.
250
+ banners: Treatment of recognized section banners.
251
+ banner_pattern: Optional custom banner regular expression from the
252
+ supported bounded subset.
253
+
254
+ Returns:
255
+ DetectResult with classification and scores, or None when no comparable
256
+ section contains at least two definitions.
257
+
258
+ Raises:
259
+ TypeError: If `banner_pattern` is neither a string nor `None`.
260
+ ValueError: If `banners` is outside the `BannerMode` vocabulary, or
261
+ `banner_pattern` does not compile or uses unsupported syntax, or
262
+ source contains lone Unicode surrogates (possibly raised as
263
+ `UnicodeEncodeError`). Compilation failures chain the `re.error`.
264
+ SyntaxError: If source is syntactically invalid.
265
+ """
266
+ validate_vocabulary('banners', banners, BANNER_MODE_VALUES)
267
+ validate_banner_pattern(banner_pattern)
268
+ tree = ast.parse(source) # raises SyntaxError for invalid Python
269
+ compile(tree, '<defsort.detect>', 'exec')
270
+ result = detect_order(
271
+ source,
272
+ banners=banners,
273
+ banner_pattern=banner_pattern,
274
+ _tree=tree,
275
+ )
276
+ if result is None:
277
+ return None
278
+ order, dep_score, alpha_score = result
279
+ return DetectResult(order=order, dep_score=dep_score, alpha_score=alpha_score)
280
+
281
+
282
+ def sort(
283
+ source: str,
284
+ *,
285
+ baseline: str | None = None,
286
+ config: DefsortConfig | None = None,
287
+ ) -> str:
288
+ """Return source with definitions sorted by dependency or alphabetically.
289
+
290
+ Args:
291
+ source: Python source code to sort.
292
+ baseline: When non-empty, only sort new definitions added since
293
+ baseline (incremental mode). Empty or whitespace-only baselines use
294
+ full sorting.
295
+ config: Transformation configuration. Defaults to `DefsortConfig()`.
296
+
297
+ Returns:
298
+ Sorted source code. Hard file blockers preserve the exact input. Soft
299
+ top-level incremental blockers may coexist with independent method
300
+ changes. Does not add or remove future-annotations imports.
301
+
302
+ Raises:
303
+ TypeError: If `config.force` is not a `bool`, or
304
+ `config.banner_pattern` is neither a string nor `None`.
305
+ SyntaxError: If source is not valid Python, including null bytes.
306
+ ValueError: If a `config` vocabulary field holds an unknown value,
307
+ `config.banner_pattern` does not compile or uses unsupported
308
+ syntax, or source contains lone Unicode surrogates. Compilation
309
+ failures chain the `re.error`.
310
+ """
311
+ policy = config if config is not None else DefsortConfig()
312
+ validate_config(policy)
313
+ engine_options = _engine_options(policy)
314
+ incremental_baseline = _normalize_baseline(baseline)
315
+ if incremental_baseline is not None:
316
+ tree = ast.parse(source)
317
+ result = sort_incremental_impl(
318
+ source,
319
+ incremental_baseline,
320
+ options=engine_options,
321
+ _tree=tree,
322
+ )
323
+ new_source = _apply_hard_blocker(result.new_source, result.blocker)
324
+ return new_source if new_source is not None else source
325
+ tree = ast.parse(source)
326
+ compile(tree, '<defsort.sort>', 'exec')
327
+ result = sort_file(
328
+ source,
329
+ options=engine_options,
330
+ _tree=tree,
331
+ )
332
+ return result.new_source
defsort/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Module entry point for `python -m defsort`."""
2
+
3
+ import sys
4
+
5
+ from defsort._cli import main
6
+
7
+ if __name__ == '__main__':
8
+ sys.exit(main())
defsort/_classify.py ADDED
@@ -0,0 +1,56 @@
1
+ """Classify a pre-tool shell command into one of three outcomes."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Literal
5
+
6
+ from defsort._detector import detect_commit
7
+ from defsort._display._terminal import escape_terminal_text
8
+ from defsort._grammar import CommitPlan, NoCommit, Rejection, classify_grammar
9
+ from defsort._shellparse import parse_command
10
+
11
+ _REASON_PREFIX = 'defsort: unsupported commit command'
12
+
13
+ type _Outcome = Literal['unrelated', 'supported', 'unsupported']
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class Classification:
18
+ """Outcome of classifying one command.
19
+
20
+ Args:
21
+ outcome: `unrelated`, `supported`, or `unsupported`.
22
+ plan: Resolved commit plan, or `NoCommit`, for a supported command.
23
+ reason: Deny reason for an unsupported command.
24
+ """
25
+
26
+ outcome: _Outcome
27
+ plan: CommitPlan | NoCommit | None = None
28
+ reason: str | None = None
29
+
30
+
31
+ def _deny_reason(rejection: Rejection) -> str:
32
+ """Render one sanitized reason for a rejected command."""
33
+ return escape_terminal_text(f'{_REASON_PREFIX}: {rejection.element}; {rejection.hint}')
34
+
35
+
36
+ def classify_command(text: str, command_cwd: str | None) -> Classification:
37
+ """Classify a shell command as unrelated, supported, or unsupported.
38
+
39
+ Args:
40
+ text: Shell command text from the hook payload.
41
+ command_cwd: Absolute directory the command executes in, or `None` when
42
+ unknown.
43
+
44
+ Returns:
45
+ The classification. The grammar decides first; a rejected command is
46
+ unsupported when the detector finds a commit and unrelated otherwise.
47
+ A committing command with an unknown directory and no absolute route is
48
+ unsupported.
49
+ """
50
+ command = parse_command(text)
51
+ result = classify_grammar(command, command_cwd)
52
+ if isinstance(result, Rejection):
53
+ if detect_commit(command):
54
+ return Classification('unsupported', reason=_deny_reason(result))
55
+ return Classification('unrelated')
56
+ return Classification('supported', plan=result)