diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index bf47aeb..1eb7699 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ classifiers = [ ] dependencies = [ "tomli>=2.0.0; python_version < '3.11'", + "click>=8.1.0", "send2trash>=1.8.2", ] diff --git a/src/devklean/__main__.py b/src/devklean/__main__.py index a44d33d..148fce9 100644 --- a/src/devklean/__main__.py +++ b/src/devklean/__main__.py @@ -1,4 +1,4 @@ -from devklean.cli import main +from devklean.cli.main import main if __name__ == "__main__": main() diff --git a/src/devklean/cli/__init__.py b/src/devklean/cli/__init__.py index 97e5b66..acfa9b7 100644 --- a/src/devklean/cli/__init__.py +++ b/src/devklean/cli/__init__.py @@ -1,3 +1,11 @@ -from devklean.cli.main import main +from __future__ import annotations + +from importlib import import_module __all__ = ["main"] + + +def __getattr__(name: str): + if name == "main": + return import_module("devklean.cli.main").main + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/devklean/cli/commands/__init__.py b/src/devklean/cli/commands/__init__.py index cfa7679..e684911 100644 --- a/src/devklean/cli/commands/__init__.py +++ b/src/devklean/cli/commands/__init__.py @@ -1,4 +1,13 @@ -from devklean.cli.commands.clean import run_clean -from devklean.cli.commands.scan import run_scan +from __future__ import annotations + +from importlib import import_module __all__ = ["run_clean", "run_scan"] + + +def __getattr__(name: str): + if name == "run_clean": + return import_module("devklean.cli.commands.clean").run_clean + if name == "run_scan": + return import_module("devklean.cli.commands.scan").run_scan + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/devklean/cli/commands/clean.py b/src/devklean/cli/commands/clean.py index 543d569..6b15136 100644 --- a/src/devklean/cli/commands/clean.py +++ b/src/devklean/cli/commands/clean.py @@ -9,11 +9,17 @@ exceeds_threshold, ) from devklean.config.models import AppConfig -from devklean.deletion import SafetyValidator, delete_items +from devklean.deletion.safety import SafetyValidator from devklean.models import CleanableItem from devklean.output.base import Renderer +def delete_items(*args, **kwargs): + from devklean.deletion import delete_items as _delete_items + + return _delete_items(*args, **kwargs) + + def _confirm_deletion( renderer: Renderer, count: int, diff --git a/src/devklean/cli/main.py b/src/devklean/cli/main.py index 92e68ec..cfac6d7 100644 --- a/src/devklean/cli/main.py +++ b/src/devklean/cli/main.py @@ -2,6 +2,8 @@ import sys +import click + from devklean.cli.dispatcher import dispatch from devklean.cli.parser import build_parser, resolve_bare_invocation from devklean.config import ConfigManager @@ -62,5 +64,5 @@ def main() -> None: sys.exit(exit_code) except KeyboardInterrupt: # 130 is the Unix convention for a SIGINT-terminated process (128 + 2). - print("\nAborted.", file=sys.stderr) + click.echo("\nAborted.", file=sys.stderr) sys.exit(130) diff --git a/src/devklean/deletion/__init__.py b/src/devklean/deletion/__init__.py index 80409e6..2864cc4 100644 --- a/src/devklean/deletion/__init__.py +++ b/src/devklean/deletion/__init__.py @@ -1,10 +1,6 @@ from __future__ import annotations -from devklean.deletion.integrity import IntegrityReport, check_integrity -from devklean.deletion.metadata import MetadataManager -from devklean.deletion.paths import get_deletion_metadata_dir -from devklean.deletion.safety import SafetyValidator, SafetyViolation -from devklean.deletion.trash import delete_items +from importlib import import_module __all__ = [ "IntegrityReport", @@ -15,3 +11,21 @@ "delete_items", "get_deletion_metadata_dir", ] + + +def __getattr__(name: str): + if name == "IntegrityReport": + return import_module("devklean.deletion.integrity").IntegrityReport + if name == "check_integrity": + return import_module("devklean.deletion.integrity").check_integrity + if name == "MetadataManager": + return import_module("devklean.deletion.metadata").MetadataManager + if name == "get_deletion_metadata_dir": + return import_module("devklean.deletion.paths").get_deletion_metadata_dir + if name == "SafetyValidator": + return import_module("devklean.deletion.safety").SafetyValidator + if name == "SafetyViolation": + return import_module("devklean.deletion.safety").SafetyViolation + if name == "delete_items": + return import_module("devklean.deletion.trash").delete_items + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/devklean/output/__init__.py b/src/devklean/output/__init__.py index 65f1000..da8f076 100644 --- a/src/devklean/output/__init__.py +++ b/src/devklean/output/__init__.py @@ -1,5 +1,15 @@ -from devklean.output.base import Renderer -from devklean.output.json import JsonRenderer -from devklean.output.text import TextRenderer +from __future__ import annotations + +from importlib import import_module __all__ = ["JsonRenderer", "Renderer", "TextRenderer"] + + +def __getattr__(name: str): + if name == "Renderer": + return import_module("devklean.output.base").Renderer + if name == "JsonRenderer": + return import_module("devklean.output.json").JsonRenderer + if name == "TextRenderer": + return import_module("devklean.output.text").TextRenderer + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/devklean/output/console.py b/src/devklean/output/console.py index 52a31be..b90e5fc 100644 --- a/src/devklean/output/console.py +++ b/src/devklean/output/console.py @@ -4,6 +4,8 @@ import sys from typing import TextIO +import click + from devklean.output.theme import Palette, get_theme # Semantic symbols — fixed across every command and renderer. @@ -73,7 +75,7 @@ def paint(self, text: str, role: str) -> str: def _line(self, symbol: str, role: str, message: str) -> None: prefix = self.paint(symbol, role) - print(f"{prefix} {message}", file=self._stream) + click.echo(f"{prefix} {message}", file=self._stream) def success(self, message: str) -> None: self._line(SYM_SUCCESS, "success", message) @@ -88,7 +90,7 @@ def info(self, message: str) -> None: self._line(SYM_INFO, "info", message) def detail(self, message: str) -> None: - print(self.paint(message, "detail"), file=self._stream) + click.echo(self.paint(message, "detail"), file=self._stream) def plain(self, message: str = "") -> None: - print(message, file=self._stream) + click.echo(message, file=self._stream) diff --git a/src/devklean/tui.py b/src/devklean/tui.py index 4d90adf..8c4d5ca 100644 --- a/src/devklean/tui.py +++ b/src/devklean/tui.py @@ -5,13 +5,19 @@ confirm_large_deletion, exceeds_threshold, ) -from devklean.deletion import SafetyValidator, delete_items +from devklean.deletion.safety import SafetyValidator from devklean.formatting import format_size, truncate from devklean.models import CleanableItem from devklean.output.base import Renderer from devklean.output.sorting import items_by_size_desc +def delete_items(*args, **kwargs): + from devklean.deletion import delete_items as _delete_items + + return _delete_items(*args, **kwargs) + + def interactive_ui(stdscr, items: list[CleanableItem], dry_run: bool) -> list[int] | None: import curses # Unix-only; imported lazily so this module loads on Windows. diff --git a/tests/test_windows_guard.py b/tests/test_windows_guard.py index dec5a85..beea530 100644 --- a/tests/test_windows_guard.py +++ b/tests/test_windows_guard.py @@ -10,6 +10,7 @@ from __future__ import annotations import io +import os import subprocess import sys from argparse import Namespace @@ -52,10 +53,12 @@ def test_tui_module_imports_cold_without_curses() -> None: crash every command on Windows) and an import-order/circular-import regression that an in-process import would mask. """ + env = {**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parents[1] / "src")} result = subprocess.run( [sys.executable, "-c", "import devklean.tui; print(devklean.tui.run_interactive.__name__)"], capture_output=True, text=True, + env=env, ) assert result.returncode == 0, result.stderr assert "run_interactive" in result.stdout