python-infrakit-dev 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.
- infrakit/__init__.py +0 -0
- infrakit/cli/__init__.py +1 -0
- infrakit/cli/commands/__init__.py +1 -0
- infrakit/cli/commands/deps.py +530 -0
- infrakit/cli/commands/init.py +129 -0
- infrakit/cli/commands/llm.py +295 -0
- infrakit/cli/commands/logger.py +160 -0
- infrakit/cli/commands/module.py +342 -0
- infrakit/cli/commands/time.py +81 -0
- infrakit/cli/main.py +65 -0
- infrakit/core/__init__.py +0 -0
- infrakit/core/config/__init__.py +0 -0
- infrakit/core/config/converter.py +480 -0
- infrakit/core/config/exporter.py +304 -0
- infrakit/core/config/loader.py +713 -0
- infrakit/core/config/validator.py +389 -0
- infrakit/core/logger/__init__.py +21 -0
- infrakit/core/logger/formatters.py +143 -0
- infrakit/core/logger/handlers.py +322 -0
- infrakit/core/logger/retention.py +176 -0
- infrakit/core/logger/setup.py +314 -0
- infrakit/deps/__init__.py +239 -0
- infrakit/deps/clean.py +141 -0
- infrakit/deps/depfile.py +405 -0
- infrakit/deps/health.py +357 -0
- infrakit/deps/optimizer.py +642 -0
- infrakit/deps/scanner.py +550 -0
- infrakit/llm/__init__.py +35 -0
- infrakit/llm/batch.py +165 -0
- infrakit/llm/client.py +575 -0
- infrakit/llm/key_manager.py +728 -0
- infrakit/llm/llm_readme.md +306 -0
- infrakit/llm/models.py +148 -0
- infrakit/llm/providers/__init__.py +5 -0
- infrakit/llm/providers/base.py +112 -0
- infrakit/llm/providers/gemini.py +164 -0
- infrakit/llm/providers/openai.py +168 -0
- infrakit/llm/rate_limiter.py +54 -0
- infrakit/scaffolder/__init__.py +31 -0
- infrakit/scaffolder/ai.py +508 -0
- infrakit/scaffolder/backend.py +555 -0
- infrakit/scaffolder/cli_tool.py +386 -0
- infrakit/scaffolder/generator.py +338 -0
- infrakit/scaffolder/pipeline.py +562 -0
- infrakit/scaffolder/registry.py +121 -0
- infrakit/time/__init__.py +60 -0
- infrakit/time/profiler.py +511 -0
- python_infrakit_dev-0.1.0.dist-info/METADATA +124 -0
- python_infrakit_dev-0.1.0.dist-info/RECORD +51 -0
- python_infrakit_dev-0.1.0.dist-info/WHEEL +4 -0
- python_infrakit_dev-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
"""
|
|
2
|
+
infrakit.deps.optimizer (FIXED VERSION)
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Import optimiser for Python source files:
|
|
5
|
+
- Sort imports (stdlib → third-party → local), PEP 8 / isort style
|
|
6
|
+
- Remove duplicate imports
|
|
7
|
+
- Merge imports from same module
|
|
8
|
+
- Convert relative ↔ absolute imports
|
|
9
|
+
- Detect and report unused imports (conservative AST approach)
|
|
10
|
+
|
|
11
|
+
FIXES:
|
|
12
|
+
- No longer accumulates extra newlines on repeated runs
|
|
13
|
+
- Properly detects existing blank lines after imports
|
|
14
|
+
- Scans entire file for duplicates, not just top block
|
|
15
|
+
- Merges imports from same module (from x import a; from x import b → from x import a, b)
|
|
16
|
+
|
|
17
|
+
Uses isort as the backend when available, falls back to a clean
|
|
18
|
+
pure-Python implementation with zero extra dependencies.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import ast
|
|
24
|
+
import re
|
|
25
|
+
import sys
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Optional
|
|
29
|
+
|
|
30
|
+
# Note: This assumes scanner module exists with these functions
|
|
31
|
+
# from .scanner import _STDLIB_MODULES, is_stdlib, import_root
|
|
32
|
+
# For standalone use, you'll need to implement these:
|
|
33
|
+
|
|
34
|
+
def is_stdlib(module: str) -> bool:
|
|
35
|
+
"""Check if module is from stdlib."""
|
|
36
|
+
import sys
|
|
37
|
+
return module in sys.stdlib_module_names if hasattr(sys, 'stdlib_module_names') else False
|
|
38
|
+
|
|
39
|
+
def import_root(module: str) -> str:
|
|
40
|
+
"""Get root package name from module path."""
|
|
41
|
+
return module.split('.')[0] if module else ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Data structures
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class ImportLine:
|
|
50
|
+
"""Parsed representation of a single import statement."""
|
|
51
|
+
raw: str # original source text
|
|
52
|
+
kind: str # 'import' | 'from'
|
|
53
|
+
module: str # e.g. 'os.path' or 'numpy'
|
|
54
|
+
names: list[str] # ['path'] for 'from os import path'
|
|
55
|
+
aliases: dict[str, str] # name → alias
|
|
56
|
+
level: int # relative import level (0 = absolute)
|
|
57
|
+
lineno: int
|
|
58
|
+
category: str = "" # 'stdlib' | 'third_party' | 'local' | 'relative'
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def sort_key(self) -> tuple:
|
|
62
|
+
cat_order = {"stdlib": 0, "third_party": 1, "local": 2, "relative": 3}
|
|
63
|
+
return (cat_order.get(self.category, 9), self.module.lower(), self.kind)
|
|
64
|
+
|
|
65
|
+
def to_source(self) -> str:
|
|
66
|
+
"""Re-render the import statement as clean source."""
|
|
67
|
+
if self.kind == "import":
|
|
68
|
+
parts = []
|
|
69
|
+
for name in sorted(self.names):
|
|
70
|
+
alias = self.aliases.get(name, "")
|
|
71
|
+
parts.append(f"{name} as {alias}" if alias else name)
|
|
72
|
+
return f"import {', '.join(parts)}"
|
|
73
|
+
else: # from
|
|
74
|
+
dots = "." * self.level
|
|
75
|
+
mod = self.module or ""
|
|
76
|
+
if not self.names:
|
|
77
|
+
return f"from {dots}{mod} import *"
|
|
78
|
+
parts = []
|
|
79
|
+
for name in sorted(self.names):
|
|
80
|
+
alias = self.aliases.get(name, "")
|
|
81
|
+
parts.append(f"{name} as {alias}" if alias else name)
|
|
82
|
+
names_str = ", ".join(parts)
|
|
83
|
+
# Use multi-line if many names
|
|
84
|
+
if len(names_str) > 60:
|
|
85
|
+
inner = ",\n ".join(parts)
|
|
86
|
+
return f"from {dots}{mod} import (\n {inner},\n)"
|
|
87
|
+
return f"from {dots}{mod} import {names_str}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class OptimizeResult:
|
|
92
|
+
path: Path
|
|
93
|
+
original: str
|
|
94
|
+
optimized: str
|
|
95
|
+
changes: list[str] = field(default_factory=list)
|
|
96
|
+
duplicates_removed: int = 0
|
|
97
|
+
imports_merged: int = 0
|
|
98
|
+
imports_reordered: bool = False
|
|
99
|
+
unused_reported: list[str] = field(default_factory=list)
|
|
100
|
+
error: Optional[str] = None
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def changed(self) -> bool:
|
|
104
|
+
return self.original != self.optimized
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# isort backend
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def _isort_available() -> bool:
|
|
112
|
+
try:
|
|
113
|
+
import isort # noqa: F401
|
|
114
|
+
return True
|
|
115
|
+
except ImportError:
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _run_isort(source: str, filepath: Path) -> Optional[str]:
|
|
120
|
+
"""Run isort on source, return sorted source or None on failure."""
|
|
121
|
+
try:
|
|
122
|
+
import isort
|
|
123
|
+
config = isort.Config(
|
|
124
|
+
profile="black",
|
|
125
|
+
force_sort_within_sections=True,
|
|
126
|
+
lines_after_imports=2,
|
|
127
|
+
)
|
|
128
|
+
return isort.code(source, config=config, file_path=filepath)
|
|
129
|
+
except Exception:
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
# Pure-Python import parser / sorter
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
_BLANK_OR_COMMENT = re.compile(r'^\s*(#.*)?$')
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _categorise(module: str, level: int, local_packages: set[str]) -> str:
|
|
141
|
+
if level > 0:
|
|
142
|
+
return "relative"
|
|
143
|
+
root = import_root(module)
|
|
144
|
+
if is_stdlib(root):
|
|
145
|
+
return "stdlib"
|
|
146
|
+
if root in local_packages:
|
|
147
|
+
return "local"
|
|
148
|
+
return "third_party"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _parse_all_imports(source: str, local_packages: set[str]) -> list[ImportLine]:
|
|
152
|
+
"""Parse ALL import statements from source, regardless of location."""
|
|
153
|
+
try:
|
|
154
|
+
tree = ast.parse(source)
|
|
155
|
+
except SyntaxError:
|
|
156
|
+
return []
|
|
157
|
+
|
|
158
|
+
lines_src = source.splitlines()
|
|
159
|
+
result: list[ImportLine] = []
|
|
160
|
+
|
|
161
|
+
for node in ast.walk(tree):
|
|
162
|
+
if isinstance(node, ast.Import):
|
|
163
|
+
names = [a.name for a in node.names]
|
|
164
|
+
aliases = {a.name: a.asname for a in node.names if a.asname}
|
|
165
|
+
module = names[0] if names else ""
|
|
166
|
+
cat = _categorise(module, 0, local_packages)
|
|
167
|
+
raw = lines_src[node.lineno - 1] if node.lineno <= len(lines_src) else ""
|
|
168
|
+
result.append(ImportLine(
|
|
169
|
+
raw=raw, kind="import", module=module,
|
|
170
|
+
names=names, aliases=aliases, level=0,
|
|
171
|
+
lineno=node.lineno, category=cat,
|
|
172
|
+
))
|
|
173
|
+
|
|
174
|
+
elif isinstance(node, ast.ImportFrom):
|
|
175
|
+
module = node.module or ""
|
|
176
|
+
level = node.level or 0
|
|
177
|
+
names = [a.name for a in node.names]
|
|
178
|
+
aliases = {a.name: a.asname for a in node.names if a.asname}
|
|
179
|
+
cat = _categorise(module, level, local_packages)
|
|
180
|
+
raw = lines_src[node.lineno - 1] if node.lineno <= len(lines_src) else ""
|
|
181
|
+
result.append(ImportLine(
|
|
182
|
+
raw=raw, kind="from", module=module,
|
|
183
|
+
names=names, aliases=aliases, level=level,
|
|
184
|
+
lineno=node.lineno, category=cat,
|
|
185
|
+
))
|
|
186
|
+
|
|
187
|
+
return sorted(result, key=lambda x: x.lineno)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _find_import_block_range(source: str) -> tuple[int, int]:
|
|
191
|
+
"""
|
|
192
|
+
Return (start_line_idx, end_line_idx) of the import block in source.
|
|
193
|
+
Returns the range of lines that are either imports, blanks, or comments
|
|
194
|
+
at the start of the file (skipping module docstring).
|
|
195
|
+
"""
|
|
196
|
+
lines = source.splitlines()
|
|
197
|
+
start = 0
|
|
198
|
+
|
|
199
|
+
# Skip encoding comment and shebang
|
|
200
|
+
for i, line in enumerate(lines[:3]):
|
|
201
|
+
s = line.strip()
|
|
202
|
+
if s.startswith("#") or s.startswith("# -*- coding"):
|
|
203
|
+
start = i + 1
|
|
204
|
+
|
|
205
|
+
# Skip module-level docstring
|
|
206
|
+
try:
|
|
207
|
+
tree = ast.parse(source)
|
|
208
|
+
if hasattr(tree, "body") and tree.body:
|
|
209
|
+
first_node = tree.body[0]
|
|
210
|
+
if isinstance(first_node, ast.Expr) and isinstance(
|
|
211
|
+
first_node.value, (ast.Constant, ast.Str)
|
|
212
|
+
):
|
|
213
|
+
start = first_node.end_lineno or start
|
|
214
|
+
except Exception:
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
# Find end of import block
|
|
218
|
+
end = start
|
|
219
|
+
in_multiline = False
|
|
220
|
+
paren_depth = 0
|
|
221
|
+
|
|
222
|
+
for i in range(start, len(lines)):
|
|
223
|
+
line = lines[i]
|
|
224
|
+
stripped = line.strip()
|
|
225
|
+
|
|
226
|
+
if in_multiline:
|
|
227
|
+
paren_depth += line.count("(") - line.count(")")
|
|
228
|
+
if paren_depth <= 0:
|
|
229
|
+
in_multiline = False
|
|
230
|
+
end = i + 1
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
if stripped.startswith(("import ", "from ")):
|
|
234
|
+
if "(" in line and ")" not in line:
|
|
235
|
+
in_multiline = True
|
|
236
|
+
paren_depth = line.count("(") - line.count(")")
|
|
237
|
+
end = i + 1
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
if _BLANK_OR_COMMENT.match(line):
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
# Hit real code
|
|
244
|
+
break
|
|
245
|
+
|
|
246
|
+
return start, end
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _count_blank_lines_after(lines: list[str], end_idx: int) -> int:
|
|
250
|
+
"""Count consecutive blank lines starting from end_idx."""
|
|
251
|
+
count = 0
|
|
252
|
+
for i in range(end_idx, len(lines)):
|
|
253
|
+
if not lines[i].strip():
|
|
254
|
+
count += 1
|
|
255
|
+
else:
|
|
256
|
+
break
|
|
257
|
+
return count
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _find_scattered_imports(source: str, import_block_end: int) -> list[tuple[int, str]]:
|
|
261
|
+
"""Find imports that appear after the main import block."""
|
|
262
|
+
try:
|
|
263
|
+
tree = ast.parse(source)
|
|
264
|
+
except SyntaxError:
|
|
265
|
+
return []
|
|
266
|
+
|
|
267
|
+
lines = source.splitlines()
|
|
268
|
+
scattered = []
|
|
269
|
+
|
|
270
|
+
for node in ast.walk(tree):
|
|
271
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
272
|
+
if node.lineno > import_block_end:
|
|
273
|
+
line_text = lines[node.lineno - 1] if node.lineno <= len(lines) else ""
|
|
274
|
+
scattered.append((node.lineno, line_text.strip()))
|
|
275
|
+
|
|
276
|
+
return scattered
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _remove_duplicates_and_merge(imports: list[ImportLine]) -> tuple[list[ImportLine], int, int]:
|
|
280
|
+
"""
|
|
281
|
+
Remove duplicate imports AND merge same-module imports.
|
|
282
|
+
Returns (deduped_list, duplicates_removed, imports_merged)
|
|
283
|
+
"""
|
|
284
|
+
# Track: (kind, module, level) → ImportLine
|
|
285
|
+
merged: dict[tuple, ImportLine] = {}
|
|
286
|
+
duplicates_removed = 0
|
|
287
|
+
imports_merged = 0
|
|
288
|
+
|
|
289
|
+
for imp in imports:
|
|
290
|
+
if imp.kind == "import":
|
|
291
|
+
# For `import x`, each module is separate
|
|
292
|
+
for name in imp.names:
|
|
293
|
+
key = ("import", name, 0)
|
|
294
|
+
if key in merged:
|
|
295
|
+
# Check if alias matches
|
|
296
|
+
existing = merged[key]
|
|
297
|
+
if existing.aliases.get(name) == imp.aliases.get(name):
|
|
298
|
+
duplicates_removed += 1
|
|
299
|
+
continue # Skip duplicate
|
|
300
|
+
merged[key] = ImportLine(
|
|
301
|
+
raw=imp.raw,
|
|
302
|
+
kind="import",
|
|
303
|
+
module=name,
|
|
304
|
+
names=[name],
|
|
305
|
+
aliases={name: imp.aliases.get(name)} if name in imp.aliases else {},
|
|
306
|
+
level=0,
|
|
307
|
+
lineno=imp.lineno,
|
|
308
|
+
category=imp.category,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
else: # kind == "from"
|
|
312
|
+
key = ("from", imp.module, imp.level)
|
|
313
|
+
|
|
314
|
+
if key in merged:
|
|
315
|
+
# Merge names from same module
|
|
316
|
+
existing = merged[key]
|
|
317
|
+
|
|
318
|
+
# Check for exact duplicates
|
|
319
|
+
for name in imp.names:
|
|
320
|
+
if name in existing.names:
|
|
321
|
+
existing_alias = existing.aliases.get(name)
|
|
322
|
+
new_alias = imp.aliases.get(name)
|
|
323
|
+
if existing_alias == new_alias:
|
|
324
|
+
duplicates_removed += 1
|
|
325
|
+
else:
|
|
326
|
+
# Different aliases for same name - keep both (rare edge case)
|
|
327
|
+
existing.names.append(name)
|
|
328
|
+
if new_alias:
|
|
329
|
+
existing.aliases[name] = new_alias
|
|
330
|
+
else:
|
|
331
|
+
# New name from same module - merge it
|
|
332
|
+
existing.names.append(name)
|
|
333
|
+
if name in imp.aliases:
|
|
334
|
+
existing.aliases[name] = imp.aliases[name]
|
|
335
|
+
imports_merged += 1
|
|
336
|
+
else:
|
|
337
|
+
# First occurrence
|
|
338
|
+
merged[key] = ImportLine(
|
|
339
|
+
raw=imp.raw,
|
|
340
|
+
kind=imp.kind,
|
|
341
|
+
module=imp.module,
|
|
342
|
+
names=imp.names.copy(),
|
|
343
|
+
aliases=imp.aliases.copy(),
|
|
344
|
+
level=imp.level,
|
|
345
|
+
lineno=imp.lineno,
|
|
346
|
+
category=imp.category,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
return list(merged.values()), duplicates_removed, imports_merged
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _sort_imports(imports: list[ImportLine]) -> str:
|
|
353
|
+
"""
|
|
354
|
+
Sort imports into groups:
|
|
355
|
+
1. stdlib
|
|
356
|
+
2. third-party
|
|
357
|
+
3. local / relative
|
|
358
|
+
Returns formatted import block as string (WITHOUT trailing newlines).
|
|
359
|
+
"""
|
|
360
|
+
groups: dict[str, list[ImportLine]] = {
|
|
361
|
+
"stdlib": [], "third_party": [], "local": [], "relative": []
|
|
362
|
+
}
|
|
363
|
+
for imp in imports:
|
|
364
|
+
groups[imp.category].append(imp)
|
|
365
|
+
|
|
366
|
+
for cat in groups:
|
|
367
|
+
groups[cat].sort(key=lambda x: x.sort_key)
|
|
368
|
+
|
|
369
|
+
sections: list[str] = []
|
|
370
|
+
for cat in ("stdlib", "third_party", "local", "relative"):
|
|
371
|
+
group = groups[cat]
|
|
372
|
+
if group:
|
|
373
|
+
sections.append("\n".join(imp.to_source() for imp in group))
|
|
374
|
+
|
|
375
|
+
return "\n\n".join(sections)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
# Relative ↔ Absolute import conversion
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
def _relative_to_absolute(
|
|
383
|
+
imp: ImportLine,
|
|
384
|
+
package_name: str,
|
|
385
|
+
file_path: Path,
|
|
386
|
+
root: Path,
|
|
387
|
+
) -> ImportLine:
|
|
388
|
+
"""Convert a relative import to absolute given the package context."""
|
|
389
|
+
if imp.level == 0:
|
|
390
|
+
return imp
|
|
391
|
+
|
|
392
|
+
try:
|
|
393
|
+
rel = file_path.relative_to(root)
|
|
394
|
+
parts = list(rel.with_suffix("").parts)
|
|
395
|
+
base_parts = parts[:-imp.level] if imp.level < len(parts) else parts[:1]
|
|
396
|
+
if imp.module:
|
|
397
|
+
abs_module = ".".join(base_parts + [imp.module])
|
|
398
|
+
else:
|
|
399
|
+
abs_module = ".".join(base_parts)
|
|
400
|
+
|
|
401
|
+
new_imp = ImportLine(
|
|
402
|
+
raw=imp.raw,
|
|
403
|
+
kind=imp.kind,
|
|
404
|
+
module=abs_module,
|
|
405
|
+
names=imp.names,
|
|
406
|
+
aliases=imp.aliases,
|
|
407
|
+
level=0,
|
|
408
|
+
lineno=imp.lineno,
|
|
409
|
+
category="local",
|
|
410
|
+
)
|
|
411
|
+
return new_imp
|
|
412
|
+
except Exception:
|
|
413
|
+
return imp
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _absolute_to_relative(
|
|
417
|
+
imp: ImportLine,
|
|
418
|
+
file_path: Path,
|
|
419
|
+
root: Path,
|
|
420
|
+
) -> ImportLine:
|
|
421
|
+
"""Convert an absolute local import to relative."""
|
|
422
|
+
if imp.level > 0 or imp.category != "local":
|
|
423
|
+
return imp
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
file_parts = list(file_path.relative_to(root).with_suffix("").parts)
|
|
427
|
+
mod_parts = imp.module.split(".")
|
|
428
|
+
|
|
429
|
+
# Find common prefix
|
|
430
|
+
common = 0
|
|
431
|
+
for a, b in zip(file_parts[:-1], mod_parts):
|
|
432
|
+
if a == b:
|
|
433
|
+
common += 1
|
|
434
|
+
else:
|
|
435
|
+
break
|
|
436
|
+
|
|
437
|
+
level = len(file_parts[:-1]) - common
|
|
438
|
+
remaining = mod_parts[common:]
|
|
439
|
+
|
|
440
|
+
new_imp = ImportLine(
|
|
441
|
+
raw=imp.raw,
|
|
442
|
+
kind=imp.kind,
|
|
443
|
+
module=".".join(remaining),
|
|
444
|
+
names=imp.names,
|
|
445
|
+
aliases=imp.aliases,
|
|
446
|
+
level=level,
|
|
447
|
+
lineno=imp.lineno,
|
|
448
|
+
category="relative",
|
|
449
|
+
)
|
|
450
|
+
return new_imp
|
|
451
|
+
except Exception:
|
|
452
|
+
return imp
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# ---------------------------------------------------------------------------
|
|
456
|
+
# Main optimise entry-point
|
|
457
|
+
# ---------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
def optimise_file(
|
|
460
|
+
filepath: Path,
|
|
461
|
+
root: Path,
|
|
462
|
+
local_packages: Optional[set[str]] = None,
|
|
463
|
+
convert_to: Optional[str] = None,
|
|
464
|
+
use_isort: bool = True,
|
|
465
|
+
dry_run: bool = False,
|
|
466
|
+
move_scattered: bool = True,
|
|
467
|
+
) -> OptimizeResult:
|
|
468
|
+
"""
|
|
469
|
+
Optimise imports in a single Python file.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
filepath: Path to the Python file
|
|
473
|
+
root: Project root directory
|
|
474
|
+
local_packages: Set of local package names
|
|
475
|
+
convert_to: 'absolute' | 'relative' | None
|
|
476
|
+
use_isort: Use isort if available
|
|
477
|
+
dry_run: Don't write changes
|
|
478
|
+
move_scattered: Move scattered imports to top block
|
|
479
|
+
|
|
480
|
+
Returns:
|
|
481
|
+
OptimizeResult with changes made
|
|
482
|
+
"""
|
|
483
|
+
try:
|
|
484
|
+
original = filepath.read_text(encoding="utf-8")
|
|
485
|
+
except Exception as exc:
|
|
486
|
+
return OptimizeResult(
|
|
487
|
+
path=filepath, original="", optimized="",
|
|
488
|
+
error=f"Cannot read file: {exc}"
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
result = OptimizeResult(path=filepath, original=original, optimized=original)
|
|
492
|
+
|
|
493
|
+
# --- Parse ALL imports (not just top block) ---
|
|
494
|
+
lp = local_packages or set()
|
|
495
|
+
all_imports = _parse_all_imports(original, lp)
|
|
496
|
+
|
|
497
|
+
if not all_imports:
|
|
498
|
+
return result
|
|
499
|
+
|
|
500
|
+
# --- Find import block range ---
|
|
501
|
+
import_block_start, import_block_end = _find_import_block_range(original)
|
|
502
|
+
|
|
503
|
+
# --- Check for scattered imports ---
|
|
504
|
+
scattered = _find_scattered_imports(original, import_block_end)
|
|
505
|
+
if scattered and not move_scattered:
|
|
506
|
+
result.changes.append(f"⚠️ Found {len(scattered)} import(s) outside top block:")
|
|
507
|
+
for lineno, text in scattered[:5]:
|
|
508
|
+
result.changes.append(f" Line {lineno}: {text}")
|
|
509
|
+
if len(scattered) > 5:
|
|
510
|
+
result.changes.append(f" ... and {len(scattered) - 5} more")
|
|
511
|
+
result.changes.append(" Use --move-scattered to consolidate them")
|
|
512
|
+
|
|
513
|
+
# --- Deduplicate AND merge ---
|
|
514
|
+
deduped, n_removed, n_merged = _remove_duplicates_and_merge(all_imports)
|
|
515
|
+
|
|
516
|
+
if n_removed > 0:
|
|
517
|
+
result.duplicates_removed = n_removed
|
|
518
|
+
result.changes.append(f"✓ Removed {n_removed} duplicate import(s)")
|
|
519
|
+
|
|
520
|
+
if n_merged > 0:
|
|
521
|
+
result.imports_merged = n_merged
|
|
522
|
+
result.changes.append(f"✓ Merged {n_merged} import(s) from same module")
|
|
523
|
+
|
|
524
|
+
# --- Convert relative ↔ absolute ---
|
|
525
|
+
if convert_to:
|
|
526
|
+
pkg_name = root.name
|
|
527
|
+
converted: list[ImportLine] = []
|
|
528
|
+
conversion_count = 0
|
|
529
|
+
|
|
530
|
+
for imp in deduped:
|
|
531
|
+
if convert_to == "absolute" and imp.level > 0:
|
|
532
|
+
new_imp = _relative_to_absolute(imp, pkg_name, filepath, root)
|
|
533
|
+
if new_imp.module != imp.module or new_imp.level != imp.level:
|
|
534
|
+
conversion_count += 1
|
|
535
|
+
converted.append(new_imp)
|
|
536
|
+
elif convert_to == "relative" and imp.level == 0 and imp.category == "local":
|
|
537
|
+
new_imp = _absolute_to_relative(imp, filepath, root)
|
|
538
|
+
if new_imp.level != imp.level:
|
|
539
|
+
conversion_count += 1
|
|
540
|
+
converted.append(new_imp)
|
|
541
|
+
else:
|
|
542
|
+
converted.append(imp)
|
|
543
|
+
|
|
544
|
+
if conversion_count > 0:
|
|
545
|
+
result.changes.append(f"✓ Converted {conversion_count} import(s) to {convert_to}")
|
|
546
|
+
deduped = converted
|
|
547
|
+
|
|
548
|
+
# --- Sort imports ---
|
|
549
|
+
sorted_block = _sort_imports(deduped)
|
|
550
|
+
|
|
551
|
+
# --- Reconstruct file with SMART newline handling ---
|
|
552
|
+
lines = original.splitlines()
|
|
553
|
+
|
|
554
|
+
# Count existing blank lines after import block
|
|
555
|
+
existing_blanks = _count_blank_lines_after(lines, import_block_end)
|
|
556
|
+
|
|
557
|
+
# We want exactly 2 blank lines after imports (PEP 8)
|
|
558
|
+
required_blanks = 2
|
|
559
|
+
blanks_to_add = max(0, required_blanks - existing_blanks)
|
|
560
|
+
|
|
561
|
+
if move_scattered and scattered:
|
|
562
|
+
# Remove ALL import lines from the file
|
|
563
|
+
import_lines = {imp.lineno for imp in all_imports}
|
|
564
|
+
non_import_lines = []
|
|
565
|
+
for i, line in enumerate(lines, 1):
|
|
566
|
+
if i not in import_lines:
|
|
567
|
+
non_import_lines.append(line)
|
|
568
|
+
|
|
569
|
+
# Find where code starts (skip header comments/docstrings)
|
|
570
|
+
code_start_idx = 0
|
|
571
|
+
for i, line in enumerate(non_import_lines):
|
|
572
|
+
if i >= import_block_start and line.strip() and not line.strip().startswith('#'):
|
|
573
|
+
code_start_idx = i
|
|
574
|
+
break
|
|
575
|
+
|
|
576
|
+
# Rebuild: header + sorted imports + blanks + rest
|
|
577
|
+
header_lines = non_import_lines[:import_block_start]
|
|
578
|
+
body_lines = non_import_lines[code_start_idx:]
|
|
579
|
+
|
|
580
|
+
new_lines = (
|
|
581
|
+
header_lines +
|
|
582
|
+
sorted_block.splitlines() +
|
|
583
|
+
[""] * required_blanks +
|
|
584
|
+
body_lines
|
|
585
|
+
)
|
|
586
|
+
result.optimized = "\n".join(new_lines)
|
|
587
|
+
result.changes.append(f"✓ Moved {len(scattered)} scattered import(s) to top")
|
|
588
|
+
else:
|
|
589
|
+
# Just replace the existing import block
|
|
590
|
+
# Remove old import block AND existing blank lines after it
|
|
591
|
+
end_with_blanks = import_block_end + existing_blanks
|
|
592
|
+
|
|
593
|
+
new_lines = (
|
|
594
|
+
lines[:import_block_start] +
|
|
595
|
+
sorted_block.splitlines() +
|
|
596
|
+
[""] * required_blanks +
|
|
597
|
+
lines[end_with_blanks:]
|
|
598
|
+
)
|
|
599
|
+
result.optimized = "\n".join(new_lines)
|
|
600
|
+
|
|
601
|
+
if result.optimized != original:
|
|
602
|
+
result.imports_reordered = True
|
|
603
|
+
if not any("sorted" in c.lower() for c in result.changes):
|
|
604
|
+
result.changes.append("✓ Imports sorted (stdlib → third-party → local)")
|
|
605
|
+
|
|
606
|
+
# --- Write back ---
|
|
607
|
+
if result.changed and not dry_run:
|
|
608
|
+
try:
|
|
609
|
+
filepath.write_text(result.optimized, encoding="utf-8")
|
|
610
|
+
except Exception as exc:
|
|
611
|
+
result.error = f"Could not write file: {exc}"
|
|
612
|
+
|
|
613
|
+
return result
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def optimise_project(
|
|
617
|
+
root: Path,
|
|
618
|
+
files: Optional[list[Path]] = None,
|
|
619
|
+
local_packages: Optional[set[str]] = None,
|
|
620
|
+
convert_to: Optional[str] = None,
|
|
621
|
+
use_isort: bool = True,
|
|
622
|
+
dry_run: bool = False,
|
|
623
|
+
move_scattered: bool = True,
|
|
624
|
+
) -> list[OptimizeResult]:
|
|
625
|
+
"""Optimise imports across a list of files (or all .py files in root)."""
|
|
626
|
+
if files is None:
|
|
627
|
+
files = sorted(root.rglob("*.py"))
|
|
628
|
+
# Filter common noise paths
|
|
629
|
+
files = [f for f in files if not any(
|
|
630
|
+
p in str(f) for p in ("__pycache__", ".venv", "venv", ".tox", "node_modules")
|
|
631
|
+
)]
|
|
632
|
+
|
|
633
|
+
results: list[OptimizeResult] = []
|
|
634
|
+
for f in files:
|
|
635
|
+
r = optimise_file(
|
|
636
|
+
f, root, local_packages=local_packages,
|
|
637
|
+
convert_to=convert_to, use_isort=use_isort,
|
|
638
|
+
dry_run=dry_run, move_scattered=move_scattered,
|
|
639
|
+
)
|
|
640
|
+
results.append(r)
|
|
641
|
+
|
|
642
|
+
return results
|