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.
Files changed (51) hide show
  1. infrakit/__init__.py +0 -0
  2. infrakit/cli/__init__.py +1 -0
  3. infrakit/cli/commands/__init__.py +1 -0
  4. infrakit/cli/commands/deps.py +530 -0
  5. infrakit/cli/commands/init.py +129 -0
  6. infrakit/cli/commands/llm.py +295 -0
  7. infrakit/cli/commands/logger.py +160 -0
  8. infrakit/cli/commands/module.py +342 -0
  9. infrakit/cli/commands/time.py +81 -0
  10. infrakit/cli/main.py +65 -0
  11. infrakit/core/__init__.py +0 -0
  12. infrakit/core/config/__init__.py +0 -0
  13. infrakit/core/config/converter.py +480 -0
  14. infrakit/core/config/exporter.py +304 -0
  15. infrakit/core/config/loader.py +713 -0
  16. infrakit/core/config/validator.py +389 -0
  17. infrakit/core/logger/__init__.py +21 -0
  18. infrakit/core/logger/formatters.py +143 -0
  19. infrakit/core/logger/handlers.py +322 -0
  20. infrakit/core/logger/retention.py +176 -0
  21. infrakit/core/logger/setup.py +314 -0
  22. infrakit/deps/__init__.py +239 -0
  23. infrakit/deps/clean.py +141 -0
  24. infrakit/deps/depfile.py +405 -0
  25. infrakit/deps/health.py +357 -0
  26. infrakit/deps/optimizer.py +642 -0
  27. infrakit/deps/scanner.py +550 -0
  28. infrakit/llm/__init__.py +35 -0
  29. infrakit/llm/batch.py +165 -0
  30. infrakit/llm/client.py +575 -0
  31. infrakit/llm/key_manager.py +728 -0
  32. infrakit/llm/llm_readme.md +306 -0
  33. infrakit/llm/models.py +148 -0
  34. infrakit/llm/providers/__init__.py +5 -0
  35. infrakit/llm/providers/base.py +112 -0
  36. infrakit/llm/providers/gemini.py +164 -0
  37. infrakit/llm/providers/openai.py +168 -0
  38. infrakit/llm/rate_limiter.py +54 -0
  39. infrakit/scaffolder/__init__.py +31 -0
  40. infrakit/scaffolder/ai.py +508 -0
  41. infrakit/scaffolder/backend.py +555 -0
  42. infrakit/scaffolder/cli_tool.py +386 -0
  43. infrakit/scaffolder/generator.py +338 -0
  44. infrakit/scaffolder/pipeline.py +562 -0
  45. infrakit/scaffolder/registry.py +121 -0
  46. infrakit/time/__init__.py +60 -0
  47. infrakit/time/profiler.py +511 -0
  48. python_infrakit_dev-0.1.0.dist-info/METADATA +124 -0
  49. python_infrakit_dev-0.1.0.dist-info/RECORD +51 -0
  50. python_infrakit_dev-0.1.0.dist-info/WHEEL +4 -0
  51. 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