ballpython 2.0.0__py3-none-any.whl → 2.0.2__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.
@@ -9,10 +9,12 @@ and maximum nesting depth.
9
9
  from __future__ import annotations
10
10
 
11
11
  import ast
12
- import os
12
+ from collections.abc import Sequence
13
13
  from dataclasses import dataclass, field
14
14
  from pathlib import Path
15
15
 
16
+ from pycleaner.discovery import collect_project_python_files
17
+
16
18
 
17
19
  @dataclass(slots=True)
18
20
  class ComplexityMetrics:
@@ -328,23 +330,6 @@ class _NestingDepthCounter(ast.NodeVisitor):
328
330
  class ComplexityAnalyzer:
329
331
  """Analyzes per-function complexity metrics across Python source files."""
330
332
 
331
- IGNORE_DIRS = frozenset(
332
- {
333
- ".git",
334
- ".venv",
335
- "venv",
336
- "env",
337
- "__pycache__",
338
- "build",
339
- "dist",
340
- ".tox",
341
- ".mypy_cache",
342
- ".pytest_cache",
343
- ".ruff_cache",
344
- "site-packages",
345
- }
346
- )
347
-
348
333
  def analyze_source(
349
334
  self, source: str, filename: str = "<unknown>"
350
335
  ) -> ComplexityReport:
@@ -369,21 +354,24 @@ class ComplexityAnalyzer:
369
354
  except OSError:
370
355
  return None
371
356
 
372
- def analyze_project(self, root_dir: Path | str) -> ComplexityReport:
357
+ def analyze_project(
358
+ self, root_dir: Path | str, exclude_patterns: Sequence[str] = ()
359
+ ) -> ComplexityReport:
373
360
  """Analyze complexity across all Python files in a project."""
374
361
  root = Path(root_dir).resolve()
375
362
  all_functions: list[ComplexityMetrics] = []
376
363
  files_scanned = 0
377
364
 
378
- for current_root, dirs, filenames in os.walk(root):
379
- dirs[:] = [
380
- d for d in dirs if d not in self.IGNORE_DIRS and not d.startswith(".")
381
- ]
382
- for fname in filenames:
383
- file_funcs = self._collect_file_metrics(current_root, fname)
384
- if file_funcs is not None:
385
- all_functions.extend(file_funcs)
386
- files_scanned += 1
365
+ for fpath in collect_project_python_files(
366
+ root, exclude_patterns=exclude_patterns
367
+ ):
368
+ try:
369
+ content = fpath.read_text(encoding="utf-8", errors="replace")
370
+ metrics = self.analyze_source(content, filename=str(fpath)).functions
371
+ all_functions.extend(metrics)
372
+ files_scanned += 1
373
+ except OSError:
374
+ continue
387
375
 
388
376
  all_functions.sort(key=lambda f: f.cyclomatic, reverse=True)
389
377
  return ComplexityReport(functions=all_functions, files_scanned=files_scanned)
pycleaner/config.py CHANGED
@@ -36,6 +36,7 @@ class PyCleanerConfig:
36
36
  "generated/",
37
37
  "*_pb2.py",
38
38
  "*_pb2_grpc.py",
39
+ "editor-*.py",
39
40
  ]
40
41
  )
41
42
  include: list[str] = field(default_factory=list)
@@ -155,7 +156,13 @@ def load_config(
155
156
  """Load and merge configuration from file and CLI overrides."""
156
157
  file_config = _resolve_file_config(project_root, explicit_config_file)
157
158
  merged: dict[str, Any] = {
158
- "exclude": ["migrations/", "generated/", "*_pb2.py", "*_pb2_grpc.py"],
159
+ "exclude": [
160
+ "migrations/",
161
+ "generated/",
162
+ "*_pb2.py",
163
+ "*_pb2_grpc.py",
164
+ "editor-*.py",
165
+ ],
159
166
  "include": [],
160
167
  "fix_py2_syntax": True,
161
168
  "fix_conditional_assignments": True,
@@ -8,11 +8,14 @@ unreachable code after return/raise/break/continue, and empty pass branches.
8
8
  from __future__ import annotations
9
9
 
10
10
  import ast
11
- import os
11
+ from collections.abc import Sequence
12
12
  from dataclasses import dataclass, field
13
13
  from pathlib import Path
14
14
  from typing import ClassVar
15
15
 
16
+ from pycleaner.discovery import collect_project_python_files
17
+ from pycleaner.frameworks import FrameworkRegistry, get_default_registry
18
+
16
19
 
17
20
  @dataclass(slots=True)
18
21
  class DeadCodeItem:
@@ -46,14 +49,30 @@ class DeadCodeReport:
46
49
  class _DefinitionCollector(ast.NodeVisitor):
47
50
  """Collects all function and class definitions with their line numbers."""
48
51
 
49
- def __init__(self, filepath: str) -> None:
52
+ def __init__(
53
+ self,
54
+ filepath: str,
55
+ tree: ast.AST | None = None,
56
+ registry: FrameworkRegistry | None = None,
57
+ ) -> None:
50
58
  self.filepath = filepath
59
+ self.tree = tree
60
+ self.registry = registry or get_default_registry()
51
61
  self.definitions: list[tuple[str, str, int, int | None, str]] = []
52
62
  # (name, kind, lineno, end_lineno, scope_context)
53
63
  self._scope_stack: list[str] = []
64
+ self._class_stack: list[ast.ClassDef] = []
54
65
 
55
66
  def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
56
67
  context = ".".join(self._scope_stack) if self._scope_stack else "<module>"
68
+ if self.tree is not None and self.registry.is_protected(
69
+ node.name, "function", node, context, self.tree, self.filepath
70
+ ):
71
+ self._scope_stack.append(node.name)
72
+ self.generic_visit(node)
73
+ self._scope_stack.pop()
74
+ return
75
+
57
76
  self.definitions.append(
58
77
  (node.name, "function", node.lineno, node.end_lineno, context)
59
78
  )
@@ -65,14 +84,33 @@ class _DefinitionCollector(ast.NodeVisitor):
65
84
 
66
85
  def visit_ClassDef(self, node: ast.ClassDef) -> None:
67
86
  context = ".".join(self._scope_stack) if self._scope_stack else "<module>"
87
+ if self.tree is not None and self.registry.is_protected(
88
+ node.name, "class", node, context, self.tree, self.filepath
89
+ ):
90
+ self._scope_stack.append(node.name)
91
+ self._class_stack.append(node)
92
+ self.generic_visit(node)
93
+ self._class_stack.pop()
94
+ self._scope_stack.pop()
95
+ return
96
+
68
97
  self.definitions.append(
69
98
  (node.name, "class", node.lineno, node.end_lineno, context)
70
99
  )
71
100
  self._scope_stack.append(node.name)
101
+ self._class_stack.append(node)
72
102
  self.generic_visit(node)
103
+ self._class_stack.pop()
73
104
  self._scope_stack.pop()
74
105
 
75
106
  def visit_Assign(self, node: ast.Assign) -> None:
107
+ if self._class_stack and self.tree is not None:
108
+ if self.registry.is_field_protected(
109
+ node, self._class_stack[-1], self.tree, self.filepath
110
+ ):
111
+ self.generic_visit(node)
112
+ return
113
+
76
114
  # Module-level constants or class attributes (not local function variables)
77
115
  if not self._scope_stack or len(self._scope_stack) == 1:
78
116
  for target in node.targets:
@@ -84,12 +122,23 @@ class _DefinitionCollector(ast.NodeVisitor):
84
122
  if self._scope_stack
85
123
  else "<module>"
86
124
  )
125
+ if self.tree is not None and self.registry.is_protected(
126
+ name, "variable", node, context, self.tree, self.filepath
127
+ ):
128
+ continue
87
129
  self.definitions.append(
88
130
  (name, "variable", node.lineno, node.end_lineno, context)
89
131
  )
90
132
  self.generic_visit(node)
91
133
 
92
134
  def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
135
+ if self._class_stack and self.tree is not None:
136
+ if self.registry.is_field_protected(
137
+ node, self._class_stack[-1], self.tree, self.filepath
138
+ ):
139
+ self.generic_visit(node)
140
+ return
141
+
93
142
  if (not self._scope_stack or len(self._scope_stack) == 1) and isinstance(
94
143
  node.target, ast.Name
95
144
  ):
@@ -98,6 +147,11 @@ class _DefinitionCollector(ast.NodeVisitor):
98
147
  context = (
99
148
  ".".join(self._scope_stack) if self._scope_stack else "<module>"
100
149
  )
150
+ if self.tree is not None and self.registry.is_protected(
151
+ name, "variable", node, context, self.tree, self.filepath
152
+ ):
153
+ self.generic_visit(node)
154
+ return
101
155
  self.definitions.append(
102
156
  (name, "variable", node.lineno, node.end_lineno, context)
103
157
  )
@@ -269,6 +323,7 @@ class _ProjectScanState:
269
323
  exports: set[str] = field(default_factory=set)
270
324
  decorated: set[str] = field(default_factory=set)
271
325
  unreachable: list[DeadCodeItem] = field(default_factory=list)
326
+ framework_registry: FrameworkRegistry = field(default_factory=get_default_registry)
272
327
 
273
328
  def process_file(self, py_file: Path) -> None:
274
329
  try:
@@ -278,7 +333,9 @@ class _ProjectScanState:
278
333
  return
279
334
 
280
335
  filepath_str = str(py_file)
281
- def_collector = _DefinitionCollector(filepath_str)
336
+ def_collector = _DefinitionCollector(
337
+ filepath_str, tree=tree, registry=self.framework_registry
338
+ )
282
339
  def_collector.visit(tree)
283
340
  for name, kind, lineno, end_lineno, ctx in def_collector.definitions:
284
341
  self.definitions.append((filepath_str, name, kind, lineno, end_lineno, ctx))
@@ -297,25 +354,6 @@ class _ProjectScanState:
297
354
  class DeadCodeDetector:
298
355
  """Detects dead code across a Python project."""
299
356
 
300
- IGNORE_DIRS = frozenset(
301
- {
302
- ".git",
303
- ".venv",
304
- "venv",
305
- "env",
306
- "__pycache__",
307
- "build",
308
- "dist",
309
- ".tox",
310
- ".mypy_cache",
311
- ".pytest_cache",
312
- ".ruff_cache",
313
- "site-packages",
314
- "node_modules",
315
- ".eggs",
316
- }
317
- )
318
-
319
357
  # Names that should never be flagged as dead code
320
358
  PROTECTED_NAMES = frozenset(
321
359
  {
@@ -418,18 +456,27 @@ class DeadCodeDetector:
418
456
  self,
419
457
  ignore_decorators: set[str] | None = None,
420
458
  ignore_names: set[str] | None = None,
459
+ framework_registry: FrameworkRegistry | None = None,
421
460
  ) -> None:
422
- self.ignore_decorators = (ignore_decorators or set()) | set(
423
- self.FRAMEWORK_DECORATORS
461
+ self.framework_registry = framework_registry or get_default_registry()
462
+ extra_decorators: set[str] = set()
463
+ for p in self.framework_registry.plugins:
464
+ extra_decorators.update(p.get_protected_decorators())
465
+ self.ignore_decorators = (
466
+ (ignore_decorators or set())
467
+ | set(self.FRAMEWORK_DECORATORS)
468
+ | extra_decorators
424
469
  )
425
470
  self.ignore_names = ignore_names or set()
426
471
 
427
- def scan_project(self, root_dir: Path | str) -> DeadCodeReport:
472
+ def scan_project(
473
+ self, root_dir: Path | str, exclude_patterns: Sequence[str] = ()
474
+ ) -> DeadCodeReport:
428
475
  """Scan an entire project directory for dead code."""
429
476
  root = Path(root_dir).resolve()
430
- py_files = self._discover_files(root)
477
+ py_files = self._discover_files(root, exclude_patterns=exclude_patterns)
431
478
 
432
- state = _ProjectScanState()
479
+ state = _ProjectScanState(framework_registry=self.framework_registry)
433
480
  for py_file in py_files:
434
481
  state.process_file(py_file)
435
482
 
@@ -502,14 +549,204 @@ class DeadCodeDetector:
502
549
  return True
503
550
  return self._matches_ignore_pattern(name)
504
551
 
505
- def _discover_files(self, root: Path) -> list[Path]:
506
- """Walk the project tree and collect .py files, respecting ignore dirs."""
507
- files: list[Path] = []
508
- for current_root, dirs, filenames in os.walk(root):
509
- dirs[:] = [
510
- d for d in dirs if d not in self.IGNORE_DIRS and not d.startswith(".")
511
- ]
512
- for fname in filenames:
513
- if fname.endswith(".py"):
514
- files.append(Path(current_root) / fname)
515
- return sorted(files)
552
+ def _discover_files(
553
+ self, root: Path, exclude_patterns: Sequence[str] = ()
554
+ ) -> list[Path]:
555
+ """Walk the project tree and collect .py files, respecting ignore dirs and gitignore."""
556
+ return collect_project_python_files(root, exclude_patterns=exclude_patterns)
557
+
558
+ def fix_source(self, source: str, filename: str = "<stdin>") -> DeadCodeFixResult:
559
+ """Surgically fix dead code in in-memory source."""
560
+ fixer = DeadCodeFixer()
561
+ return fixer.fix(source, filename=filename)
562
+
563
+ def fix_file(
564
+ self, filepath: Path | str, apply_changes: bool = True
565
+ ) -> DeadCodeFixResult:
566
+ """Surgically fix dead code in a file."""
567
+ path = Path(filepath).resolve()
568
+ content = path.read_text(encoding="utf-8", errors="replace")
569
+ res = self.fix_source(content, filename=str(path))
570
+ if apply_changes and res.changed:
571
+ path.write_text(res.code, encoding="utf-8")
572
+ return res
573
+
574
+ def fix_project(
575
+ self, root_dir: Path | str, exclude_patterns: Sequence[str] = ()
576
+ ) -> dict[Path, DeadCodeFixResult]:
577
+ """Surgically fix dead code across all project Python files."""
578
+ root = Path(root_dir).resolve()
579
+ py_files = self._discover_files(root, exclude_patterns=exclude_patterns)
580
+ results: dict[Path, DeadCodeFixResult] = {}
581
+ for pf in py_files:
582
+ res = self.fix_file(pf, apply_changes=True)
583
+ if res.changed:
584
+ results[pf] = res
585
+ return results
586
+
587
+
588
+ @dataclass(slots=True)
589
+ class DeadCodeFixResult:
590
+ """Outcome of attempting to fix dead code in source code."""
591
+
592
+ code: str
593
+ changed: bool
594
+ pruned_items: list[str] = field(default_factory=list)
595
+
596
+
597
+ class _DeadCodePrunerCollector(ast.NodeVisitor):
598
+ """Collects line spans of unreachable code, redundant pass, and dead branches."""
599
+
600
+ def __init__(self) -> None:
601
+ self.deletions: list[tuple[int, int, str]] = []
602
+
603
+ def _is_terminal_jump(self, stmt: ast.AST) -> bool:
604
+ if isinstance(stmt, (ast.Return, ast.Raise, ast.Break, ast.Continue)):
605
+ return True
606
+ if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
607
+ func = stmt.value.func
608
+ if isinstance(func, ast.Name) and func.id in ("exit", "quit"):
609
+ return True
610
+ if (
611
+ isinstance(func, ast.Attribute)
612
+ and isinstance(func.value, ast.Name)
613
+ and func.value.id == "sys"
614
+ and func.attr == "exit"
615
+ ):
616
+ return True
617
+ return False
618
+
619
+ def _inspect_stmts(self, stmts: list[ast.stmt]) -> None:
620
+ terminal_seen = False
621
+ unreachable: list[ast.stmt] = []
622
+ non_doc = [
623
+ s
624
+ for s in stmts
625
+ if not (
626
+ isinstance(s, ast.Expr)
627
+ and isinstance(s.value, ast.Constant)
628
+ and isinstance(s.value.value, str)
629
+ )
630
+ ]
631
+
632
+ for stmt in stmts:
633
+ if terminal_seen:
634
+ unreachable.append(stmt)
635
+ elif self._is_terminal_jump(stmt):
636
+ terminal_seen = True
637
+ elif isinstance(stmt, ast.Pass) and len(non_doc) > 1:
638
+ self.deletions.append(
639
+ (
640
+ stmt.lineno,
641
+ getattr(stmt, "end_lineno", stmt.lineno),
642
+ f"Pruned redundant 'pass' at line {stmt.lineno}",
643
+ )
644
+ )
645
+ elif (
646
+ isinstance(stmt, ast.If)
647
+ and isinstance(stmt.test, ast.Constant)
648
+ and stmt.test.value in (False, 0)
649
+ and not stmt.orelse
650
+ ):
651
+ self.deletions.append(
652
+ (
653
+ stmt.lineno,
654
+ getattr(stmt, "end_lineno", stmt.lineno),
655
+ f"Pruned dead 'if False' branch at line {stmt.lineno}",
656
+ )
657
+ )
658
+ self.visit(stmt)
659
+
660
+ if unreachable:
661
+ start_line = unreachable[0].lineno
662
+ end_line = getattr(unreachable[-1], "end_lineno", unreachable[-1].lineno)
663
+ self.deletions.append(
664
+ (
665
+ start_line,
666
+ end_line,
667
+ f"Pruned {len(unreachable)} unreachable statement(s) at lines {start_line}-{end_line}",
668
+ )
669
+ )
670
+
671
+ def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
672
+ self._inspect_stmts(node.body)
673
+
674
+ visit_AsyncFunctionDef = visit_FunctionDef
675
+
676
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
677
+ self._inspect_stmts(node.body)
678
+
679
+ def visit_If(self, node: ast.If) -> None:
680
+ self._inspect_stmts(node.body)
681
+ if node.orelse:
682
+ self._inspect_stmts(node.orelse)
683
+
684
+ def visit_For(self, node: ast.For | ast.AsyncFor) -> None:
685
+ self._inspect_stmts(node.body)
686
+ if node.orelse:
687
+ self._inspect_stmts(node.orelse)
688
+
689
+ visit_AsyncFor = visit_For
690
+
691
+ def visit_While(self, node: ast.While) -> None:
692
+ self._inspect_stmts(node.body)
693
+ if node.orelse:
694
+ self._inspect_stmts(node.orelse)
695
+
696
+ def visit_Try(self, node: ast.Try) -> None:
697
+ self._inspect_stmts(node.body)
698
+ for h in node.handlers:
699
+ self._inspect_stmts(h.body)
700
+ if node.orelse:
701
+ self._inspect_stmts(node.orelse)
702
+ if node.finalbody:
703
+ self._inspect_stmts(node.finalbody)
704
+
705
+ def visit_With(self, node: ast.With | ast.AsyncWith) -> None:
706
+ self._inspect_stmts(node.body)
707
+
708
+ visit_AsyncWith = visit_With
709
+
710
+ def visit_Match(self, node: ast.AST) -> None:
711
+ for case in getattr(node, "cases", []):
712
+ self._inspect_stmts(case.body)
713
+
714
+
715
+ class DeadCodeFixer:
716
+ """Surgically eliminates unreachable statements, dead branches, and redundant pass statements."""
717
+
718
+ def fix(self, source: str, filename: str = "<stdin>") -> DeadCodeFixResult:
719
+ current_code = source
720
+ all_pruned: list[str] = []
721
+
722
+ for _ in range(2):
723
+ try:
724
+ tree = ast.parse(current_code, filename=filename)
725
+ except SyntaxError:
726
+ break
727
+
728
+ collector = _DeadCodePrunerCollector()
729
+ collector.visit(tree)
730
+ if not collector.deletions:
731
+ break
732
+
733
+ lines = current_code.splitlines(keepends=True)
734
+ sorted_deletions = sorted(
735
+ collector.deletions, key=lambda x: x[0], reverse=True
736
+ )
737
+ for start_line, end_line, desc in sorted_deletions:
738
+ del lines[start_line - 1 : end_line]
739
+ all_pruned.append(desc)
740
+
741
+ candidate = "".join(lines)
742
+ try:
743
+ ast.parse(candidate, filename=filename)
744
+ current_code = candidate
745
+ except SyntaxError:
746
+ break
747
+
748
+ return DeadCodeFixResult(
749
+ code=current_code,
750
+ changed=current_code != source,
751
+ pruned_items=all_pruned,
752
+ )