ballpython 2.1.0__tar.gz → 2.1.1__tar.gz

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 (63) hide show
  1. {ballpython-2.1.0 → ballpython-2.1.1}/PKG-INFO +1 -3
  2. {ballpython-2.1.0 → ballpython-2.1.1}/ballpython.egg-info/PKG-INFO +1 -3
  3. {ballpython-2.1.0 → ballpython-2.1.1}/ballpython.egg-info/SOURCES.txt +1 -0
  4. {ballpython-2.1.0 → ballpython-2.1.1}/ballpython.egg-info/requires.txt +0 -4
  5. {ballpython-2.1.0 → ballpython-2.1.1}/pycleaner/__init__.py +1 -1
  6. {ballpython-2.1.0 → ballpython-2.1.1}/pycleaner/cli.py +188 -35
  7. {ballpython-2.1.0 → ballpython-2.1.1}/pycleaner/complexity_analyzer.py +11 -8
  8. {ballpython-2.1.0 → ballpython-2.1.1}/pycleaner/dead_code_detector.py +118 -35
  9. {ballpython-2.1.0 → ballpython-2.1.1}/pycleaner/dependency_auditor.py +26 -5
  10. {ballpython-2.1.0 → ballpython-2.1.1}/pycleaner/discovery.py +17 -0
  11. {ballpython-2.1.0 → ballpython-2.1.1}/pycleaner/pipeline.py +99 -11
  12. {ballpython-2.1.0 → ballpython-2.1.1}/pycleaner/security_scanner.py +149 -14
  13. {ballpython-2.1.0 → ballpython-2.1.1}/pycleaner/syntax_healer.py +208 -43
  14. {ballpython-2.1.0 → ballpython-2.1.1}/pycleaner/taint_engine.py +19 -15
  15. {ballpython-2.1.0 → ballpython-2.1.1}/pycleaner/type_checker.py +128 -25
  16. {ballpython-2.1.0 → ballpython-2.1.1}/pycleaner/verifier.py +12 -4
  17. {ballpython-2.1.0 → ballpython-2.1.1}/pyproject.toml +1 -3
  18. ballpython-2.1.1/tests/test_c_to_a_overhaul.py +411 -0
  19. {ballpython-2.1.0 → ballpython-2.1.1}/tests/test_dependency_auditor.py +49 -0
  20. {ballpython-2.1.0 → ballpython-2.1.1}/tests/test_syntax_healer.py +1 -0
  21. {ballpython-2.1.0 → ballpython-2.1.1}/tests/test_type_checker.py +106 -1
  22. {ballpython-2.1.0 → ballpython-2.1.1}/README.md +0 -0
  23. {ballpython-2.1.0 → ballpython-2.1.1}/ballpython/__init__.py +0 -0
  24. {ballpython-2.1.0 → ballpython-2.1.1}/ballpython/__main__.py +0 -0
  25. {ballpython-2.1.0 → ballpython-2.1.1}/ballpython/cli.py +0 -0
  26. {ballpython-2.1.0 → ballpython-2.1.1}/ballpython/py.typed +0 -0
  27. {ballpython-2.1.0 → ballpython-2.1.1}/ballpython.egg-info/dependency_links.txt +0 -0
  28. {ballpython-2.1.0 → ballpython-2.1.1}/ballpython.egg-info/entry_points.txt +0 -0
  29. {ballpython-2.1.0 → ballpython-2.1.1}/ballpython.egg-info/top_level.txt +0 -0
  30. {ballpython-2.1.0 → ballpython-2.1.1}/pycleaner/__main__.py +0 -0
  31. {ballpython-2.1.0 → ballpython-2.1.1}/pycleaner/baseline.py +0 -0
  32. {ballpython-2.1.0 → ballpython-2.1.1}/pycleaner/cache.py +0 -0
  33. {ballpython-2.1.0 → ballpython-2.1.1}/pycleaner/config.py +0 -0
  34. {ballpython-2.1.0 → ballpython-2.1.1}/pycleaner/explanations.py +0 -0
  35. {ballpython-2.1.0 → ballpython-2.1.1}/pycleaner/frameworks/__init__.py +0 -0
  36. {ballpython-2.1.0 → ballpython-2.1.1}/pycleaner/frameworks/plugins.py +0 -0
  37. {ballpython-2.1.0 → ballpython-2.1.1}/pycleaner/import_resolver.py +0 -0
  38. {ballpython-2.1.0 → ballpython-2.1.1}/pycleaner/linter_formatter.py +0 -0
  39. {ballpython-2.1.0 → ballpython-2.1.1}/pycleaner/modernizer.py +0 -0
  40. {ballpython-2.1.0 → ballpython-2.1.1}/pycleaner/py.typed +0 -0
  41. {ballpython-2.1.0 → ballpython-2.1.1}/pycleaner/test_generator.py +0 -0
  42. {ballpython-2.1.0 → ballpython-2.1.1}/pycleaner/typeshed_resolver.py +0 -0
  43. {ballpython-2.1.0 → ballpython-2.1.1}/setup.cfg +0 -0
  44. {ballpython-2.1.0 → ballpython-2.1.1}/tests/test_baseline_and_explain.py +0 -0
  45. {ballpython-2.1.0 → ballpython-2.1.1}/tests/test_cache.py +0 -0
  46. {ballpython-2.1.0 → ballpython-2.1.1}/tests/test_cli.py +0 -0
  47. {ballpython-2.1.0 → ballpython-2.1.1}/tests/test_cli_prove.py +0 -0
  48. {ballpython-2.1.0 → ballpython-2.1.1}/tests/test_complexity_analyzer.py +0 -0
  49. {ballpython-2.1.0 → ballpython-2.1.1}/tests/test_config.py +0 -0
  50. {ballpython-2.1.0 → ballpython-2.1.1}/tests/test_dead_code_detector.py +0 -0
  51. {ballpython-2.1.0 → ballpython-2.1.1}/tests/test_dead_code_fixer.py +0 -0
  52. {ballpython-2.1.0 → ballpython-2.1.1}/tests/test_discovery.py +0 -0
  53. {ballpython-2.1.0 → ballpython-2.1.1}/tests/test_frameworks.py +0 -0
  54. {ballpython-2.1.0 → ballpython-2.1.1}/tests/test_import_resolver.py +0 -0
  55. {ballpython-2.1.0 → ballpython-2.1.1}/tests/test_linter_formatter.py +0 -0
  56. {ballpython-2.1.0 → ballpython-2.1.1}/tests/test_modernizer.py +0 -0
  57. {ballpython-2.1.0 → ballpython-2.1.1}/tests/test_mutation_test_generator.py +0 -0
  58. {ballpython-2.1.0 → ballpython-2.1.1}/tests/test_pipeline.py +0 -0
  59. {ballpython-2.1.0 → ballpython-2.1.1}/tests/test_security_scanner.py +0 -0
  60. {ballpython-2.1.0 → ballpython-2.1.1}/tests/test_taint_engine.py +0 -0
  61. {ballpython-2.1.0 → ballpython-2.1.1}/tests/test_test_generator.py +0 -0
  62. {ballpython-2.1.0 → ballpython-2.1.1}/tests/test_verifier.py +0 -0
  63. {ballpython-2.1.0 → ballpython-2.1.1}/tests/test_verifier_pipeline.py +0 -0
@@ -1,14 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ballpython
3
- Version: 2.1.0
3
+ Version: 2.1.1
4
4
  Summary: The Ultimate Static Python Intelligence, Healing, Type Verification, and Security Suite
5
5
  Author: Developer
6
6
  License-Expression: MIT
7
7
  Requires-Python: >=3.10
8
8
  Description-Content-Type: text/markdown
9
9
  Requires-Dist: ruff>=0.1.0
10
- Requires-Dist: rich>=13.0.0
11
- Requires-Dist: tomli>=1.1.0; python_version < "3.11"
12
10
  Provides-Extra: dev
13
11
  Requires-Dist: pytest>=8.0.0; extra == "dev"
14
12
  Requires-Dist: black>=24.0.0; extra == "dev"
@@ -1,14 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ballpython
3
- Version: 2.1.0
3
+ Version: 2.1.1
4
4
  Summary: The Ultimate Static Python Intelligence, Healing, Type Verification, and Security Suite
5
5
  Author: Developer
6
6
  License-Expression: MIT
7
7
  Requires-Python: >=3.10
8
8
  Description-Content-Type: text/markdown
9
9
  Requires-Dist: ruff>=0.1.0
10
- Requires-Dist: rich>=13.0.0
11
- Requires-Dist: tomli>=1.1.0; python_version < "3.11"
12
10
  Provides-Extra: dev
13
11
  Requires-Dist: pytest>=8.0.0; extra == "dev"
14
12
  Requires-Dist: black>=24.0.0; extra == "dev"
@@ -36,6 +36,7 @@ pycleaner/verifier.py
36
36
  pycleaner/frameworks/__init__.py
37
37
  pycleaner/frameworks/plugins.py
38
38
  tests/test_baseline_and_explain.py
39
+ tests/test_c_to_a_overhaul.py
39
40
  tests/test_cache.py
40
41
  tests/test_cli.py
41
42
  tests/test_cli_prove.py
@@ -1,8 +1,4 @@
1
1
  ruff>=0.1.0
2
- rich>=13.0.0
3
-
4
- [:python_version < "3.11"]
5
- tomli>=1.1.0
6
2
 
7
3
  [dev]
8
4
  pytest>=8.0.0
@@ -18,7 +18,7 @@ from pycleaner.discovery import (
18
18
  is_protected_file,
19
19
  )
20
20
 
21
- __version__ = "2.0.5"
21
+ __version__ = "2.1.1"
22
22
  __all__ = [
23
23
  "DEFAULT_IGNORED_DIRS",
24
24
  "PROTECTED_FILE_PATTERNS",
@@ -555,7 +555,12 @@ def _create_printer(console: Any) -> Any:
555
555
  else:
556
556
  import re
557
557
 
558
- cleaned = re.sub(r"\[/?[a-z ]*\]", "", msg)
558
+ cleaned = re.sub(
559
+ r"(?<!\\)\[/?(?:bold|dim|italic|underline|blink|reverse|strike|black|red|green|yellow|blue|magenta|cyan|white)(?: [a-z]+)*\]",
560
+ "",
561
+ msg,
562
+ )
563
+ cleaned = cleaned.replace("\\[", "[").replace("\\]", "]")
559
564
  print(cleaned)
560
565
 
561
566
  return print_msg
@@ -684,8 +689,13 @@ def main(argv: Sequence[str] | None = None) -> int:
684
689
  @dataclass
685
690
  class _FixBatchState:
686
691
  changed_count: int = 0
692
+ would_change_count: int = 0
693
+ actually_written_count: int = 0
694
+ partially_updated_count: int = 0
695
+ partially_would_change_count: int = 0
687
696
  error_count: int = 0
688
697
  diagnostics: list[dict[str, str]] = field(default_factory=list)
698
+ security_findings: list[Any] = field(default_factory=list)
689
699
  proven_count: int = 0
690
700
  suggested_count: int = 0
691
701
  refused_count: int = 0
@@ -729,6 +739,12 @@ def _report_file_modifications(
729
739
  print_msg(" - Lint: fixed errors and pruned unused imports", style="blue")
730
740
  if result.format_changed:
731
741
  print_msg(" - Format: applied PEP 8 formatting", style="blue")
742
+ for sf in getattr(result, "security_findings", []):
743
+ if getattr(sf, "severity", None) in ("CRITICAL", "HIGH"):
744
+ print_msg(
745
+ f" - Unresolved Security [{sf.severity}]: {sf.message} (line {sf.lineno})",
746
+ style="bold red",
747
+ )
732
748
 
733
749
 
734
750
  def _accumulate_result(
@@ -741,6 +757,8 @@ def _accumulate_result(
741
757
  print_msg, console, is_json = io_ctx
742
758
  if result.diagnostics:
743
759
  state.diagnostics.extend(result.diagnostics)
760
+ if getattr(result, "security_findings", None):
761
+ state.security_findings.extend(result.security_findings)
744
762
 
745
763
  if result.proof_receipts:
746
764
  state.proof_receipts.extend(result.proof_receipts)
@@ -766,12 +784,63 @@ def _accumulate_result(
766
784
 
767
785
  if result.error:
768
786
  state.error_count += 1
787
+ is_partial = (result.changed or bool(result.syntax_repairs)) and (
788
+ result.verification_tier != VerificationTier.TIER_C_REFUSED
789
+ )
790
+ if is_partial:
791
+ state.changed_count += 1
792
+ if opts.apply_changes:
793
+ state.partially_updated_count += 1
794
+ else:
795
+ state.partially_would_change_count += 1
796
+
797
+ if not is_json and not opts.in_progress:
798
+ action = (
799
+ "Partially cleaned"
800
+ if opts.apply_changes
801
+ else "Would partially modify"
802
+ )
803
+ print_msg(f"[yellow]{action}:[/yellow] {py_file.name}")
804
+ for repair in result.syntax_repairs:
805
+ print_msg(f" - Syntax: {repair}", style="cyan")
806
+ for mod in result.modernize_transforms:
807
+ print_msg(f" - Modernize: {mod}", style="green")
808
+ for dc in result.dead_code_pruned:
809
+ print_msg(f" - Dead-code: {dc}", style="yellow")
810
+ for imp in result.resolved_imports:
811
+ print_msg(f" - Import: {imp}", style="magenta")
812
+ if result.lint_changed:
813
+ print_msg(
814
+ " - Lint: fixed errors and pruned unused imports", style="blue"
815
+ )
816
+ if result.format_changed:
817
+ print_msg(" - Format: applied PEP 8 formatting", style="blue")
818
+ for sf in getattr(result, "security_findings", []):
819
+ if getattr(sf, "severity", None) in ("CRITICAL", "HIGH"):
820
+ print_msg(
821
+ f" - Unresolved Security [{sf.severity}]: {sf.message} (line {sf.lineno})",
822
+ style="bold red",
823
+ )
824
+ if opts.show_diff and result.diff:
825
+ _render_fix_diff(result.diff, console)
826
+ else:
827
+ if not is_json and not opts.in_progress:
828
+ for sf in getattr(result, "security_findings", []):
829
+ if getattr(sf, "severity", None) in ("CRITICAL", "HIGH"):
830
+ print_msg(
831
+ f" - Unresolved Security [{sf.severity}]: {sf.message} (line {sf.lineno})",
832
+ style="bold red",
833
+ )
834
+
769
835
  if not is_json:
770
836
  print_msg(f"[red]ERROR in {py_file.name}:[/red] {result.error}")
771
837
  return
772
838
 
773
839
  if result.changed:
774
840
  state.changed_count += 1
841
+ state.would_change_count += 1
842
+ if opts.apply_changes and not result.error:
843
+ state.actually_written_count += 1
775
844
  if not is_json and not opts.in_progress:
776
845
  _report_file_modifications(result, py_file, opts.apply_changes, print_msg)
777
846
  if opts.show_diff and result.diff:
@@ -952,8 +1021,14 @@ def _compute_fix_exit_code(
952
1021
  return 1
953
1022
  if is_prove_cmd:
954
1023
  return 0 if state.error_count == 0 else 1
1024
+ has_unresolved_security = any(
1025
+ getattr(sf, "severity", None) in ("CRITICAL", "HIGH")
1026
+ for sf in state.security_findings
1027
+ )
1028
+ if has_unresolved_security:
1029
+ return 1
955
1030
  if not apply_changes and (
956
- state.changed_count > 0
1031
+ state.would_change_count > 0
957
1032
  or state.error_count > 0
958
1033
  or bool(audit_report.missing_packages)
959
1034
  ):
@@ -1012,7 +1087,11 @@ def _cmd_fix(
1012
1087
 
1013
1088
  if is_json:
1014
1089
  print(json.dumps(state.diagnostics, indent=2))
1015
- return 0 if state.error_count == 0 else 1
1090
+ has_sec_issues = any(
1091
+ getattr(sf, "severity", None) in ("CRITICAL", "HIGH")
1092
+ for sf in state.security_findings
1093
+ )
1094
+ return 1 if (state.error_count > 0 or has_sec_issues) else 0
1016
1095
 
1017
1096
  audit_report = _run_fix_audit(
1018
1097
  root_dir,
@@ -1027,10 +1106,64 @@ def _cmd_fix(
1027
1106
  or getattr(audit_report, "fixed_pyproject", False)
1028
1107
  ):
1029
1108
  state.changed_count += 1
1109
+ state.would_change_count += 1
1110
+ state.actually_written_count += 1
1111
+
1112
+ if not opts.apply_changes:
1113
+ if state.partially_would_change_count > 0:
1114
+ err_label = (
1115
+ "1 remaining error"
1116
+ if state.error_count == 1
1117
+ else f"{state.error_count} remaining errors"
1118
+ )
1119
+ if state.would_change_count > 0:
1120
+ print_msg(
1121
+ f"\n[bold]Summary: {len(py_files)} inspected, {state.would_change_count} would change, {state.partially_would_change_count} would partially change, 0 written, {err_label}.[/bold]"
1122
+ )
1123
+ else:
1124
+ print_msg(
1125
+ f"\n[bold]Summary: {len(py_files)} inspected, {state.partially_would_change_count} would partially change, 0 written, {err_label}.[/bold]"
1126
+ )
1127
+ else:
1128
+ print_msg(
1129
+ f"\n[bold]Summary: {len(py_files)} inspected, {state.would_change_count} would change, 0 written, {state.error_count} errors.[/bold]"
1130
+ )
1131
+ else:
1132
+ if state.partially_updated_count > 0:
1133
+ err_label = (
1134
+ "1 remaining error"
1135
+ if state.error_count == 1
1136
+ else f"{state.error_count} remaining errors"
1137
+ )
1138
+ if state.actually_written_count > 0:
1139
+ print_msg(
1140
+ f"\n[bold]Summary: {len(py_files)} inspected, {state.actually_written_count} updated, {state.partially_updated_count} partially updated, {err_label}.[/bold]"
1141
+ )
1142
+ else:
1143
+ print_msg(
1144
+ f"\n[bold]Summary: {len(py_files)} inspected, {state.partially_updated_count} partially updated, {err_label}.[/bold]"
1145
+ )
1146
+ else:
1147
+ print_msg(
1148
+ f"\n[bold]Summary: {len(py_files)} inspected, {state.actually_written_count} updated, {state.error_count} errors.[/bold]"
1149
+ )
1030
1150
 
1031
- print_msg(
1032
- f"\n[bold]Summary: {len(py_files)} inspected, {state.changed_count} updated, {state.error_count} errors.[/bold]"
1033
- )
1151
+ critical_or_high_findings = [
1152
+ sf
1153
+ for sf in state.security_findings
1154
+ if getattr(sf, "severity", None) in ("CRITICAL", "HIGH")
1155
+ ]
1156
+ if critical_or_high_findings and not is_json:
1157
+ print_msg(
1158
+ f"\n[bold red]Unresolved Security Findings ({len(critical_or_high_findings)} issue(s)):[/bold red]"
1159
+ )
1160
+ for sf in critical_or_high_findings:
1161
+ f_path = getattr(sf, "filepath", getattr(sf, "file", "<unknown>"))
1162
+ print_msg(
1163
+ f" [bold red]- [{sf.severity}] {f_path}:{sf.lineno}: {sf.message}[/bold red]"
1164
+ )
1165
+ if getattr(sf, "suggestion", None):
1166
+ print_msg(f" [dim]Suggestion: {sf.suggestion}[/dim]")
1034
1167
 
1035
1168
  if pipeline.verify_proofs:
1036
1169
  total_proven_callables = sum(
@@ -1187,7 +1320,7 @@ def _cmd_baseline(
1187
1320
  fingerprints: list[BaselineFingerprint] = []
1188
1321
 
1189
1322
  detector = DeadCodeDetector()
1190
- dead_code_report = detector.scan_project(root_dir)
1323
+ dead_code_report = detector.scan_files(py_files)
1191
1324
  for item in dead_code_report.items:
1192
1325
  if item.kind == "unused-import":
1193
1326
  rule = "DC002"
@@ -1206,7 +1339,7 @@ def _cmd_baseline(
1206
1339
  )
1207
1340
 
1208
1341
  scanner = SecurityScanner()
1209
- sec_report = scanner.scan_project(root_dir)
1342
+ sec_report = scanner.scan_files(py_files)
1210
1343
  for finding in sec_report.findings:
1211
1344
  fingerprints.append(
1212
1345
  BaselineManager.create_fingerprint(
@@ -1219,7 +1352,7 @@ def _cmd_baseline(
1219
1352
  )
1220
1353
 
1221
1354
  analyzer = ComplexityAnalyzer()
1222
- comp_report = analyzer.analyze_project(root_dir)
1355
+ comp_report = analyzer.analyze_files(py_files)
1223
1356
  thresholds = (
1224
1357
  getattr(args, "max_cyclomatic", config.max_cyclomatic_complexity),
1225
1358
  getattr(args, "max_cognitive", config.max_cognitive_complexity),
@@ -1373,9 +1506,10 @@ def _cmd_cache(
1373
1506
  console: Any,
1374
1507
  ) -> int:
1375
1508
  """Manage and inspect the content-addressable verification and AST cache."""
1376
- db_path = getattr(args, "db", None) or getattr(
1509
+ raw_db_path = getattr(args, "db", None) or getattr(
1377
1510
  config, "cache_db_path", ".pycleaner/cache.db"
1378
1511
  )
1512
+ db_path: str = str(raw_db_path) if raw_db_path else ".pycleaner/cache.db"
1379
1513
  cache = ContentAddressableCache(db_path=db_path)
1380
1514
 
1381
1515
  if getattr(args, "clear", False):
@@ -1445,7 +1579,18 @@ def _render_audit_cli_output(
1445
1579
 
1446
1580
  def _cmd_audit(args: argparse.Namespace, config: PyCleanerConfig, print_msg) -> int:
1447
1581
  """Dependency audit command."""
1448
- root_dir = find_project_root(args.paths[0] if args.paths else None)
1582
+ targets = getattr(args, "paths", ["."])
1583
+ target_path = Path(targets[0]).resolve() if targets else Path.cwd()
1584
+ root_dir = find_project_root(target_path)
1585
+ explicit_files = (
1586
+ [
1587
+ Path(p).resolve()
1588
+ for p in targets
1589
+ if Path(p).is_file() and Path(p).suffix == ".py"
1590
+ ]
1591
+ if targets
1592
+ else None
1593
+ )
1449
1594
 
1450
1595
  is_check = getattr(args, "check", False)
1451
1596
  fix_flag = getattr(args, "fix", False) or getattr(args, "fix_deps", False)
@@ -1454,6 +1599,7 @@ def _cmd_audit(args: argparse.Namespace, config: PyCleanerConfig, print_msg) ->
1454
1599
  audit_report = auditor.audit(
1455
1600
  fix=fix_flag and not is_check,
1456
1601
  prune_unused=prune_flag and not is_check,
1602
+ target_files=explicit_files or None,
1457
1603
  )
1458
1604
 
1459
1605
  if getattr(args, "json", False):
@@ -1475,7 +1621,7 @@ def _render_scan_json(report: Any) -> int:
1475
1621
  for f in report.findings
1476
1622
  ]
1477
1623
  print(json.dumps(findings, indent=2))
1478
- return 1 if report.critical_count > 0 else 0
1624
+ return 1 if (report.critical_count > 0 or report.high_count > 0) else 0
1479
1625
 
1480
1626
 
1481
1627
  def _render_scan_table(report: Any, console: Any, target_base: str) -> None:
@@ -1526,23 +1672,24 @@ def _render_scan_cli_summary(
1526
1672
  f"\n Total: {report.count} finding(s) - "
1527
1673
  f"[red]{report.critical_count} critical[/red], [red]{report.high_count} high[/red]"
1528
1674
  )
1529
- return 1 if report.critical_count > 0 else 0
1675
+ return 1 if (report.critical_count > 0 or report.high_count > 0) else 0
1530
1676
 
1531
1677
 
1532
1678
  def _cmd_scan(
1533
1679
  args: argparse.Namespace, config: PyCleanerConfig, print_msg, console
1534
1680
  ) -> int:
1535
1681
  """Security scan command."""
1536
- root_dir = Path(args.paths[0]).resolve() if args.paths else Path.cwd()
1537
- if not root_dir.is_dir():
1538
- root_dir = root_dir.parent
1682
+ targets = getattr(args, "paths", ["."])
1683
+ target_path = Path(targets[0]).resolve() if targets else Path.cwd()
1684
+ root_dir = find_project_root(target_path)
1685
+ py_files = discover_python_files(args.paths, config=config, root=root_dir)
1539
1686
 
1540
1687
  severity = getattr(args, "severity", config.security_severity_threshold)
1541
1688
  scanner = SecurityScanner(
1542
1689
  severity_threshold=severity,
1543
1690
  ignore_rules=set(config.ignore_security_rules),
1544
1691
  )
1545
- report = scanner.scan_project(root_dir, exclude_patterns=config.exclude)
1692
+ report = scanner.scan_files(py_files)
1546
1693
 
1547
1694
  target_base = args.paths[0] if args.paths else "."
1548
1695
  if getattr(args, "json", False):
@@ -1636,12 +1783,13 @@ def _cmd_complexity(
1636
1783
  args: argparse.Namespace, config: PyCleanerConfig, print_msg, console
1637
1784
  ) -> int:
1638
1785
  """Complexity analysis command."""
1639
- root_dir = Path(args.paths[0]).resolve() if args.paths else Path.cwd()
1640
- if not root_dir.is_dir():
1641
- root_dir = root_dir.parent
1786
+ targets = getattr(args, "paths", ["."])
1787
+ target_path = Path(targets[0]).resolve() if targets else Path.cwd()
1788
+ root_dir = find_project_root(target_path)
1789
+ py_files = discover_python_files(args.paths, config=config, root=root_dir)
1642
1790
 
1643
1791
  analyzer = ComplexityAnalyzer()
1644
- report = analyzer.analyze_project(root_dir, exclude_patterns=config.exclude)
1792
+ report = analyzer.analyze_files(py_files)
1645
1793
 
1646
1794
  thresholds = (
1647
1795
  getattr(args, "max_cyclomatic", config.max_cyclomatic_complexity),
@@ -1711,17 +1859,20 @@ def _cmd_dead_code(
1711
1859
  args: argparse.Namespace, config: PyCleanerConfig, print_msg, console
1712
1860
  ) -> int:
1713
1861
  """Dead code detection command."""
1714
- root_dir = Path(args.paths[0]).resolve() if args.paths else Path.cwd()
1715
- if not root_dir.is_dir():
1716
- root_dir = root_dir.parent
1862
+ targets = getattr(args, "paths", ["."])
1863
+ target_path = Path(targets[0]).resolve() if targets else Path.cwd()
1864
+ root_dir = find_project_root(target_path)
1865
+ py_files = discover_python_files(args.paths, config=config, root=root_dir)
1717
1866
 
1718
1867
  detector = DeadCodeDetector(
1719
1868
  ignore_decorators=set(config.ignore_decorators),
1720
1869
  ignore_names=set(config.ignore_names),
1721
1870
  )
1722
1871
  if getattr(args, "fix", False):
1723
- print_msg(f"[bold green]Pruning dead code across {root_dir}...[/bold green]")
1724
- fix_results = detector.fix_project(root_dir, exclude_patterns=config.exclude)
1872
+ print_msg(
1873
+ f"[bold green]Pruning dead code across {len(py_files)} file(s)...[/bold green]"
1874
+ )
1875
+ fix_results = detector.fix_files(py_files)
1725
1876
  total_pruned = sum(len(res.pruned_items) for res in fix_results.values())
1726
1877
  print_msg(
1727
1878
  f"[green]Successfully fixed {len(fix_results)} file(s), pruned {total_pruned} dead code item(s).[/green]"
@@ -1734,7 +1885,7 @@ def _cmd_dead_code(
1734
1885
  print_msg(f" [cyan]{rel_p}[/cyan]: {len(res.pruned_items)} pruned")
1735
1886
  return 0
1736
1887
 
1737
- report = detector.scan_project(root_dir, exclude_patterns=config.exclude)
1888
+ report = detector.scan_files(py_files)
1738
1889
 
1739
1890
  if getattr(args, "json", False):
1740
1891
  return _render_dead_code_json(report.items)
@@ -1814,12 +1965,13 @@ def _cmd_types(
1814
1965
  args: argparse.Namespace, config: PyCleanerConfig, print_msg, console
1815
1966
  ) -> int:
1816
1967
  """Bidirectional type checking and inference with Typeshed stubs."""
1817
- root_dir = Path(args.paths[0]).resolve() if args.paths else Path.cwd()
1818
- if not root_dir.is_dir():
1819
- root_dir = root_dir.parent
1968
+ targets = getattr(args, "paths", ["."])
1969
+ target_path = Path(targets[0]).resolve() if targets else Path.cwd()
1970
+ root_dir = find_project_root(target_path)
1971
+ py_files = discover_python_files(args.paths, config=config, root=root_dir)
1820
1972
 
1821
1973
  checker = TypeChecker(strict=config.strict_types)
1822
- report = checker.check_project(root_dir, exclude_patterns=config.exclude)
1974
+ report = checker.check_files(py_files)
1823
1975
 
1824
1976
  target_base = args.paths[0] if args.paths else "."
1825
1977
  if getattr(args, "json", False):
@@ -1908,12 +2060,13 @@ def _cmd_taint(
1908
2060
  args: argparse.Namespace, config: PyCleanerConfig, print_msg, console
1909
2061
  ) -> int:
1910
2062
  """Interprocedural SAST dataflow and taint vulnerability analysis."""
1911
- root_dir = Path(args.paths[0]).resolve() if args.paths else Path.cwd()
1912
- if not root_dir.is_dir():
1913
- root_dir = root_dir.parent
2063
+ targets = getattr(args, "paths", ["."])
2064
+ target_path = Path(targets[0]).resolve() if targets else Path.cwd()
2065
+ root_dir = find_project_root(target_path)
2066
+ py_files = discover_python_files(args.paths, config=config, root=root_dir)
1914
2067
 
1915
2068
  engine = TaintEngine()
1916
- report = engine.scan_path(root_dir, exclude_patterns=config.exclude)
2069
+ report = engine.scan_files(py_files)
1917
2070
 
1918
2071
  target_base = args.paths[0] if args.paths else "."
1919
2072
  if getattr(args, "json", False):
@@ -354,17 +354,12 @@ class ComplexityAnalyzer:
354
354
  except OSError:
355
355
  return None
356
356
 
357
- def analyze_project(
358
- self, root_dir: Path | str, exclude_patterns: Sequence[str] = ()
359
- ) -> ComplexityReport:
360
- """Analyze complexity across all Python files in a project."""
361
- root = Path(root_dir).resolve()
357
+ def analyze_files(self, target_files: Sequence[Path]) -> ComplexityReport:
358
+ """Analyze complexity across a specific sequence of Python files without walking directories."""
362
359
  all_functions: list[ComplexityMetrics] = []
363
360
  files_scanned = 0
364
361
 
365
- for fpath in collect_project_python_files(
366
- root, exclude_patterns=exclude_patterns
367
- ):
362
+ for fpath in target_files:
368
363
  try:
369
364
  content = fpath.read_text(encoding="utf-8", errors="replace")
370
365
  metrics = self.analyze_source(content, filename=str(fpath)).functions
@@ -376,6 +371,14 @@ class ComplexityAnalyzer:
376
371
  all_functions.sort(key=lambda f: f.cyclomatic, reverse=True)
377
372
  return ComplexityReport(functions=all_functions, files_scanned=files_scanned)
378
373
 
374
+ def analyze_project(
375
+ self, root_dir: Path | str, exclude_patterns: Sequence[str] = ()
376
+ ) -> ComplexityReport:
377
+ """Analyze complexity across all Python files in a project."""
378
+ root = Path(root_dir).resolve()
379
+ py_files = collect_project_python_files(root, exclude_patterns=exclude_patterns)
380
+ return self.analyze_files(py_files)
381
+
379
382
  @staticmethod
380
383
  def _count_function_args(node: ast.FunctionDef | ast.AsyncFunctionDef) -> int:
381
384
  all_args = node.args.posonlyargs + node.args.args + node.args.kwonlyargs