diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd4c7f9..aa85067 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,11 +16,56 @@ jobs: python-version: "3.11" - uses: astral-sh/setup-uv@v5 - - name: Install dependencies - run: uv pip install --system -e . ruff mypy + - name: Install dev dependencies + run: uv pip install --system -e ".[dev]" - - name: Lint with ruff - run: ruff check src/ + - name: Lint with ruff (src + tests) + run: ruff check . + + - name: Check formatting with ruff + run: ruff format --check . - name: Type check with mypy run: mypy src/ + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - uses: astral-sh/setup-uv@v5 + + - name: Install dev dependencies + run: uv pip install --system -e ".[dev]" + + - name: Run tests with pytest + run: pytest + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.11" + - uses: astral-sh/setup-uv@v5 + + - name: Install build dependencies + run: uv pip install --system -e ".[dev]" + + - name: Build source distribution and wheel + run: uv build + + - name: Verify build artifacts + run: | + ls -l dist/ + # Smoke test: install the wheel and run the CLI entrypoint + uv pip install --system dist/*.whl + syskit version + syskit --help | head -5 diff --git a/README.md b/README.md index 8840306..d226770 100644 --- a/README.md +++ b/README.md @@ -50,11 +50,19 @@ syskit backup ## 🛠 Development ```bash -# Install dev dependencies +# Install dev dependencies (includes ruff, mypy, pytest, build) uv pip install -e ".[dev]" # Run locally python -m syskit.cli --help + +# Lint + format + type check +ruff check . +ruff format . +mypy src/ + +# Run tests (works on any OS thanks to mocks) +pytest ``` ## Roadmap diff --git a/pyproject.toml b/pyproject.toml index a0eff02..85fceac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,14 @@ Homepage = "https://github.com/ledutheo/syskit" Repository = "https://github.com/ledutheo/syskit" Issues = "https://github.com/ledutheo/syskit/issues" +[project.optional-dependencies] +dev = [ + "ruff>=0.11.0", + "mypy>=1.15.0", + "pytest>=8.3.0", + "build>=1.2.0", +] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -37,3 +45,9 @@ select = ["E", "F", "I"] [tool.mypy] python_version = "3.10" strict = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py", "*_test.py"] +addopts = "-q --tb=short" +filterwarnings = ["ignore::DeprecationWarning"] diff --git a/src/syskit/__init__.py b/src/syskit/__init__.py index 563636a..09d01dd 100644 --- a/src/syskit/__init__.py +++ b/src/syskit/__init__.py @@ -1,3 +1,9 @@ """syskit - Modern CLI toolkit for Arch/Manjaro users.""" -__version__ = "0.1.0" +from importlib.metadata import PackageNotFoundError, version + +try: + __version__ = version("syskit") +except PackageNotFoundError: + # Fallback during development when not installed + __version__ = "0.1.0" diff --git a/src/syskit/cli.py b/src/syskit/cli.py index a544674..08c9ff1 100644 --- a/src/syskit/cli.py +++ b/src/syskit/cli.py @@ -12,6 +12,8 @@ from rich.panel import Panel from rich.table import Table +from syskit import __version__ + app = typer.Typer( name="syskit", help="Modern CLI toolkit for Arch Linux and Manjaro users", @@ -203,7 +205,7 @@ def backup( @app.command() def version() -> None: """Show syskit version.""" - console.print("syskit version: [bold cyan]0.1.0[/bold cyan]") + console.print(f"syskit version: [bold cyan]{__version__}[/bold cyan]") if __name__ == "__main__": diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..41aa6c0 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,252 @@ +"""Tests for syskit CLI using Typer's CliRunner and mocks.""" + +from __future__ import annotations + +import subprocess +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from syskit.cli import app, run_command + +runner = CliRunner() + + +def test_version_command(): + """Basic version command outputs the version string.""" + result = runner.invoke(app, ["version"]) + assert result.exit_code == 0 + assert "syskit version: 0.1.0" in result.output + + +def test_help(): + """--help should list available commands.""" + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "syskit" in result.output + assert "info" in result.output + assert "clean" in result.output + assert "update" in result.output + assert "search" in result.output + assert "backup" in result.output + assert "version" in result.output + + +@patch("syskit.cli.platform") +@patch("syskit.cli.run_command") +@patch("builtins.open") +def test_info_command(mock_open, mock_run_command, mock_platform): + """info command displays system info using platform and mocked commands.""" + # Platform mocks + mock_platform.node.return_value = "testhost" + mock_platform.release.return_value = "6.12.1-arch1-1" + mock_platform.machine.return_value = "x86_64" + mock_platform.python_version.return_value = "3.12.3" + + # /etc/os-release mock + mock_file = MagicMock() + mock_file.__enter__.return_value = ['PRETTY_NAME="Arch Linux"\n'] + mock_open.return_value = mock_file + + # Command mocks + mock_run_command.side_effect = [ + "up 1 day, 2 hours", # uptime + ( + " total used free shared " + "buff/cache available\n" + "Mem: 31Gi 12Gi 15Gi 123Mi 4.2Gi 18Gi" + ), # free -h + ] + + result = runner.invoke(app, ["info"]) + assert result.exit_code == 0 + assert "System Information" in result.output + assert "testhost" in result.output + assert "Arch Linux" in result.output + assert "up 1 day" in result.output + assert "12Gi" in result.output # from memory parsing + + +@patch("syskit.cli.run_command") +def test_clean_dry_run(mock_run): + """clean --dry-run should list actions without executing or prompting.""" + mock_run.return_value = "orphan1\norphan2\norphan3" # pacman -Qtdq + + result = runner.invoke(app, ["clean", "--dry-run"]) + assert result.exit_code == 0 + assert "System Cleanup" in result.output + assert "Dry run mode" in result.output + assert "orphan packages" in result.output + assert "pacman package cache" in result.output + # Should not have called the real cleanup commands + assert not any("sudo" in str(c) for c in mock_run.call_args_list) + + +@patch("syskit.cli.typer.confirm") +@patch("syskit.cli.run_command") +def test_clean_with_confirm_no(mock_run, mock_confirm): + """clean (no dry-run) with user saying no aborts.""" + mock_run.return_value = "" # no orphans + mock_confirm.return_value = False + + result = runner.invoke(app, ["clean"]) + assert result.exit_code == 0 + assert "Aborted" in result.output + # Real cleanup commands not executed + assert "sudo pacman" not in " ".join(str(c) for c in mock_run.call_args_list) + + +@patch("syskit.cli.typer.confirm") +@patch("syskit.cli.run_command") +def test_clean_with_confirm_yes(mock_run, mock_confirm): + """clean with confirm yes runs the cleanup commands.""" + mock_run.return_value = "" + mock_confirm.return_value = True + + result = runner.invoke(app, ["clean"]) + assert result.exit_code == 0 + assert "Running cleanup" in result.output + assert "Cleanup completed" in result.output + # Check that sudo commands were "called" + calls = [str(c) for c in mock_run.call_args_list] + assert any("pacman" in c and "-Sc" in c for c in calls) + assert any("journalctl" in c for c in calls) + + +@patch("syskit.cli.shutil.which") +@patch("syskit.cli.run_command") +def test_update_with_aur_helper(mock_run, mock_which): + """update detects yay/paru and runs it.""" + mock_which.side_effect = lambda x: x == "paru" # only paru present + + result = runner.invoke(app, ["update"]) + assert result.exit_code == 0 + assert "Updating official packages" in result.output + assert "Updating AUR packages with paru" in result.output + assert "System update completed" in result.output + + # pacman + paru called + calls = [str(c) for c in mock_run.call_args_list] + assert any("pacman" in c and "-Syu" in c for c in calls) + assert any("paru" in c and "-Syu" in c for c in calls) + + +@patch("syskit.cli.shutil.which") +@patch("syskit.cli.run_command") +def test_update_no_aur_helper(mock_run, mock_which): + """update without AUR helper still succeeds and warns.""" + mock_which.return_value = None + + result = runner.invoke(app, ["update"]) + assert result.exit_code == 0 + assert "No AUR helper found (yay/paru)" in result.output + assert "System update completed" in result.output + + +@patch("syskit.cli.run_command") +def test_search_with_results(mock_run): + """search parses pacman -Ss output into a nice table.""" + mock_run.return_value = ( + "extra/firefox 128.0-1 [installed]\n" + " Fast, Private and Safe Web Browser\n" + "community/vscode 1.90-1\n" + " Code editor\n" + ) + + result = runner.invoke(app, ["search", "firefox"]) + assert result.exit_code == 0 + assert "Searching for: firefox" in result.output + assert "firefox" in result.output + assert "Fast, Private" in result.output + + +@patch("syskit.cli.run_command") +def test_search_no_results(mock_run): + """search with no/error output shows friendly message.""" + mock_run.return_value = "Error: nothing found" + + result = runner.invoke(app, ["search", "nonexistentpkg999"]) + assert result.exit_code == 0 + assert "No results found" in result.output + + +@patch("syskit.cli.run_command") +def test_backup_success(mock_run, tmp_path): + """backup creates parent dir (via code) and reports size on success.""" + # Use a temp output so we don't touch real ~ + backup_file = tmp_path / "backups" / "test-backup.tar.gz" + # Simulate successful tar (run_command returns no "Error") + mock_run.return_value = "" + + # Create the file so .stat() works in the success branch + backup_file.parent.mkdir(parents=True) + backup_file.write_bytes(b"x" * 1234567) # ~1.2 MB + + # Patch the default in the Option? Easier: invoke with explicit --output + result = runner.invoke(app, ["backup", "--output", str(backup_file)]) + assert result.exit_code == 0 + assert "Creating backup" in result.output + assert "Backup created" in result.output + assert "MB" in result.output + + +@patch("syskit.cli.run_command") +def test_backup_failure(mock_run, tmp_path): + """backup shows error message when run_command fails.""" + backup_file = tmp_path / "backup-fail.tar.gz" + mock_run.return_value = "Error: tar failed" + + result = runner.invoke(app, ["backup", "--output", str(backup_file)]) + assert result.exit_code == 0 + assert "Backup failed" in result.output + + +def test_run_command_success(): + """run_command returns stripped stdout on success.""" + with patch("subprocess.run") as mock_run: + mock_result = MagicMock() + mock_result.stdout = " hello \n world \n" + mock_run.return_value = mock_result + out = run_command(["echo", "hello"]) + assert out == "hello \n world" + mock_run.assert_called_once() + + +def test_run_command_called_process_error(): + """run_command returns Error: ... on CalledProcessError.""" + with patch("subprocess.run") as mock_run: + err = subprocess.CalledProcessError(1, ["false"]) + err.stderr = "some error output" + mock_run.side_effect = err + out = run_command(["false"]) + assert out.startswith("Error: some error output") + + +def test_run_command_file_not_found(): + """run_command handles missing binary gracefully.""" + with patch("subprocess.run", side_effect=FileNotFoundError()): + out = run_command(["nonexistentcmd"]) + assert "Command not found: nonexistentcmd" in out + + +@pytest.mark.parametrize( + "cmd", + [ + "info", + "clean", + "update", + "search foo", + "backup --output /tmp/x.tar.gz", + "version", + ], +) +def test_all_commands_have_help_text(cmd): + """Smoke test: every command is registered and has a docstring/help.""" + # Just invoking --help on subcommand or the command itself + args = cmd.split() + ["--help"] + result = runner.invoke(app, args) + # Some commands show their own help or the global; exit 0 or 2 is fine for --help + assert ( + "Usage:" in result.output or "help" in result.output.lower() or result.exit_code in (0, 2) + )