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.
- defsort/__init__.py +332 -0
- defsort/__main__.py +8 -0
- defsort/_classify.py +56 -0
- defsort/_cli.py +1186 -0
- defsort/_color.py +86 -0
- defsort/_config.py +576 -0
- defsort/_core/__init__.py +5 -0
- defsort/_core/_analysis.py +1237 -0
- defsort/_core/_api.py +373 -0
- defsort/_core/_banners.py +563 -0
- defsort/_core/_full.py +407 -0
- defsort/_core/_future.py +233 -0
- defsort/_core/_guard.py +151 -0
- defsort/_core/_hoist.py +238 -0
- defsort/_core/_incremental.py +741 -0
- defsort/_core/_layout.py +496 -0
- defsort/_core/_merge.py +123 -0
- defsort/_core/_methods.py +1283 -0
- defsort/_core/_ordering.py +394 -0
- defsort/_core/_parse.py +763 -0
- defsort/_core/_reassemble.py +42 -0
- defsort/_core/_safety.py +411 -0
- defsort/_detector.py +152 -0
- defsort/_display/__init__.py +16 -0
- defsort/_display/_constraints.py +60 -0
- defsort/_display/_detect.py +62 -0
- defsort/_display/_diff.py +105 -0
- defsort/_display/_json.py +138 -0
- defsort/_display/_movement.py +282 -0
- defsort/_display/_skipped.py +130 -0
- defsort/_display/_summary.py +211 -0
- defsort/_display/_terminal.py +24 -0
- defsort/_display/_types.py +166 -0
- defsort/_display/_width.py +34 -0
- defsort/_downstream.py +181 -0
- defsort/_files.py +272 -0
- defsort/_git.py +1021 -0
- defsort/_git_executable.py +51 -0
- defsort/_grammar.py +785 -0
- defsort/_hook.py +1564 -0
- defsort/_hosts/__init__.py +50 -0
- defsort/_shell.py +66 -0
- defsort/_shellparse.py +595 -0
- defsort/_source.py +140 -0
- defsort/_types.py +840 -0
- defsort/hooks/__init__.py +242 -0
- defsort/py.typed +0 -0
- defsort-0.1.0.dist-info/METADATA +150 -0
- defsort-0.1.0.dist-info/RECORD +58 -0
- defsort-0.1.0.dist-info/WHEEL +4 -0
- defsort-0.1.0.dist-info/entry_points.txt +7 -0
- defsort-0.1.0.dist-info/licenses/LICENSE +21 -0
- plugins/agy/_defsort/__init__.py +1 -0
- plugins/agy/_defsort/_adapter.py +121 -0
- plugins/claude/_defsort/__init__.py +1 -0
- plugins/claude/_defsort/_adapter.py +131 -0
- plugins/codex/_defsort/__init__.py +1 -0
- 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
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)
|