diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..a31ae11 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,25 @@ +name: Tests + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' + + - name: Install dependencies + run: pip install -e .[test] + + - name: Run pytest + run: pytest \ No newline at end of file diff --git a/README.md b/README.md index fc39ed3..75d7543 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,11 @@ # Starlet Setup A lightweight Python utility to quickly clone, configure, and build CMake projects — from single repos to full mono-repos. - +[![Tests](https://github.com/masonlet/starlet-setup/actions/workflows/tests.yml/badge.svg)](https://github.com/masonlet/starlet-setup/actions/workflows/tests.yml) [![PyPI version](https://badge.fury.io/py/starlet-setup.svg)](https://badge.fury.io/py/starlet-setup) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) [![Python 3.6+](https://img.shields.io/badge/python-3.6%2B-blue.svg)]() - ## Table of Contents - [Features](#features) - [Prerequisites](#prerequisites) @@ -16,10 +15,15 @@ A lightweight Python utility to quickly clone, configure, and build CMake projec - [Single Repository Mode](#single-repository-mode) - [Mono-Repo Mode](#mono-repo-mode) - [Profile Mode](#profile-mode-saved-configurations) +- [Development](#development) - [License](#license) + +
+ + ## Features - **Single Repository Mode**: - Clone a GitHub repository with simple `username/repo` syntax @@ -42,8 +46,12 @@ A lightweight Python utility to quickly clone, configure, and build CMake projec - Manage multiple development environments effortlessly - Default profile includes all core Starlet modules + +
+ + ## Prerequisites - Python 3.6+ - Git @@ -94,8 +102,12 @@ Alternatively, you can run the script directly: python -m starlet_setup username/repo ``` + +
+ + ## Configuration Starlet Setup supports persistent configuration through a JSON file, allowing you to save your preferred defaults (e.g., SSH mode, build directory, mono-repo repositories). @@ -111,8 +123,12 @@ Starlet Setup checks for configuration files in this order: - `./.starlet-setup.json` (current directory) - `~/.starlet-setup.json` (home directory) + +
+ + ## Usage ### Single Repository Mode @@ -270,7 +286,44 @@ starlet-setup username/repo --profile myprofile starlet-setup username/repo --profile myprofile --ssh ``` + +
+ + +## Development + +### Running Tests + +#### 1. Clone the Repository +```bash +git clone https://github.com/masonlet/starlet-setup.git +cd starlet-setup +``` + +#### 2. Install in Development Mode +```bash +pip install -e . +``` + +#### 3. Run Tests +```bash +# Run all tests +pytest + +# Run specific test file +pytest tests/test_config.py + +# Run tests with flags +pytest -v +``` + + + +
+ + + ## License -MIT License — see [LICENSE](./LICENSE) for details. +MIT License — see [LICENSE](./LICENSE) for details. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 271b915..ed0dddc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "starlet-setup" -version = "1.0.1" +version = "1.0.2" description = "Quick setup for CMake projects" readme = "README.md" requires-python = ">=3.6" @@ -13,6 +13,11 @@ authors = [ {name = "Mason L'Etoile", email = "masonletoile@hotmail.com"} ] +[project.optional-dependencies] +test = [ + "pytest>=7.0", +] + [project.urls] Homepage = "https://github.com/masonlet/starlet-setup" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..3a87564 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +minversion = 6.0 +addopts = -v +testpaths = tests \ No newline at end of file diff --git a/src/starlet_setup/__main__.py b/src/starlet_setup/__main__.py index 3529d3a..1b97f24 100644 --- a/src/starlet_setup/__main__.py +++ b/src/starlet_setup/__main__.py @@ -11,6 +11,7 @@ from .utils import check_prerequisites from .commands import mono_repo_mode, single_repo_mode + def main() -> None: """Main entry point for Starlet Setup.""" args = parse_args() diff --git a/src/starlet_setup/cli.py b/src/starlet_setup/cli.py index e71efd3..88e2067 100644 --- a/src/starlet_setup/cli.py +++ b/src/starlet_setup/cli.py @@ -4,6 +4,7 @@ from argparse import Namespace from .config import get_config_value, load_config + def parse_args() -> Namespace: """ Parse command-line arguments for Starlet Setup. @@ -11,7 +12,7 @@ def parse_args() -> Namespace: Returns: Parsed arguments namespace """ - config = load_config() + config, config_path = load_config() parser = argparse.ArgumentParser( description="Starlet Setup - Quick setup script for CMake projects", @@ -60,9 +61,9 @@ def parse_args() -> Namespace: ) parser.add_argument( '--cmake-arg', - nargs='*', - default=None, - help='Additional CMake arguments (e.g., --cmake-arg=-D_BUILD_TESTS=ON)' + action='append', + dest='cmake_arg', + help='Additional CMake arguments (e.g., --cmake-arg=-D_BUILD_TESTS=ON). Can be used multiple times.' ) # Configuration arguments @@ -148,6 +149,7 @@ def parse_args() -> Namespace: args = parser.parse_args() args.config = config + args.config_path = config_path if args.init_config or args.list_profiles or args.profile_add or args.profile_remove: return args diff --git a/src/starlet_setup/commands.py b/src/starlet_setup/commands.py index fedff01..585c360 100644 --- a/src/starlet_setup/commands.py +++ b/src/starlet_setup/commands.py @@ -5,7 +5,6 @@ from argparse import Namespace from pathlib import Path from typing import Optional - from .repository import ( resolve_repo_url, get_default_repos, @@ -109,7 +108,6 @@ def single_repo_mode(args: Namespace) -> None: print(f"Project finished in {repo_name}/{args.build_dir}") - def _create_mono_repo_cmakelists(mono_dir: Path, test_repo: str, repos: list[str]): """ Create a root CMakeLists.txt for the mono-repo. diff --git a/src/starlet_setup/config.py b/src/starlet_setup/config.py index 7171d17..36c6685 100644 --- a/src/starlet_setup/config.py +++ b/src/starlet_setup/config.py @@ -1,12 +1,11 @@ """Configuration file management""" - import json from pathlib import Path from typing import Any -def load_config() -> dict: +def load_config() -> tuple[dict, Path | None]: """ Load configuration from file, falling back to defaults. @@ -18,19 +17,37 @@ def load_config() -> dict: Path.home() / '.starlet-setup.json' ] + invalid_count = 0 for config_path in config_locations: if config_path.exists(): try: with open(config_path) as f: - return json.load(f) + return json.load(f), config_path except json.JSONDecodeError as e: print(f"Warning: Invalid JSON in {config_path}: {e}") + invalid_count += 1 + continue + except PermissionError: + print(f"Error: No permission to read the file in {config_path}.") + invalid_count += 1 continue + except Exception as e: + print(f"An unexpected error occurred reading {config_path}: {e}") + invalid_count += 1 + continue + - return {} + if invalid_count != 0: + print(f"Found {invalid_count} config file{'s' if invalid_count != 1 else ''} that had errors") + else: + print("Failed to find config file") + return {}, None -def save_config(config) -> Path: +def save_config( + config: dict, + config_path: Path | None = None +) -> Path: """ Save configuration to a file. @@ -40,13 +57,18 @@ def save_config(config) -> Path: Returns: Path where config was saved """ - config_path = Path('.starlet-setup.json') - if not config_path.exists(): - config_path = Path.home() / '.starlet-setup.json' - - with open(config_path, 'w') as f: - json.dump(config, f, indent=2) - + if config_path is None: + config_path = Path('.starlet-setup.json') + + try: + with open(config_path, 'w') as f: + json.dump(config, f, indent=2) + except PermissionError: + print(f"Error: No permission to write to {config_path}") + raise + except Exception as e: + print(f"An unexpected error occurred writing {config_path}: {e}") + raise return config_path @@ -56,16 +78,15 @@ def get_config_value(config: dict, key: str, default: Any) -> Any: Args: config: Configuration dictionary - key: Dot-seperated key path (e.g, 'defaults.ssh') + key: Dot-separated key path (e.g, 'defaults.ssh') default: Default value if key not found """ parts = key.split('.') value = config for part in parts: - if isinstance(value, dict) and part in value: - value = value[part] - else: + if not isinstance(value, dict) or part not in value: return default + value = value[part] return value @@ -101,11 +122,18 @@ def create_default_config() -> None: print("Aborted.") return - with open(config_path, 'w') as f: - json.dump(default_config, f, indent=2) + try: + with open(config_path, 'w') as f: + json.dump(default_config, f, indent=2) + except PermissionError: + print(f"Error: No permission to write to {config_path}") + return + except Exception as e: + print(f"An unexpected error occurred writing {config_path}: {e}") + return print(f"Created config file: {config_path.absolute()}") print("Edit this file to customize your defaults.") print("\nConfig files are checked in this order:") print(" 1. ./.starlet-setup.json (current directory)") - print(" 2. ~/.starlet-setup.json (home directory)") + print(" 2. ~/.starlet-setup.json (home directory)") \ No newline at end of file diff --git a/src/starlet_setup/repository.py b/src/starlet_setup/repository.py index 1f3230a..b8bdd79 100644 --- a/src/starlet_setup/repository.py +++ b/src/starlet_setup/repository.py @@ -4,6 +4,7 @@ from .config import get_config_value from .utils import run_command + def resolve_repo_url(repo_input: str, use_ssh: bool=False) -> str: """ Convert repository input to full URL. @@ -78,4 +79,4 @@ def clone_repository( run_command(['git', 'clone', repo_url], cwd=target_dir, verbose=verbose) except SystemExit: print(f" Failed to clone {repo_path}") - raise + raise \ No newline at end of file diff --git a/src/starlet_setup/utils.py b/src/starlet_setup/utils.py index c012afa..f212fd1 100644 --- a/src/starlet_setup/utils.py +++ b/src/starlet_setup/utils.py @@ -33,8 +33,8 @@ def check_prerequisites(verbose: bool=False) -> None: def run_command( cmd: list[str], - cwd: Optional[Union[str, Path]]=None, - verbose: bool=False + cwd: Optional[Union[str, Path]] = None, + verbose: bool = False ) -> subprocess.CompletedProcess: """ Run a shell command with proper error handling diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..d999b9c --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,105 @@ +"""Tests for CLI module.""" + +import pytest +from unittest.mock import patch +from starlet_setup.cli import parse_args + + +class TestParseArgs: + def test_parses_basic_repository(self): + """Should parse repository argument.""" + with patch('sys.argv', ['prog', 'user/repo']): + args = parse_args() + assert args.repo == 'user/repo' + assert args.ssh is False + assert args.verbose is False + + + def test_applies_config_defaults(self): + """Should use config values as default.""" + config = { + "defaults": {"ssh": True, "verbose": True, "build_type": "Release"} + } + with patch('starlet_setup.cli.load_config', return_value=(config,None)), \ + patch('sys.argv', ['prog', 'user/repo']): + args = parse_args() + assert args.ssh is True + assert args.verbose is True + assert args.build_type == "Release" + + + def test_command_line_overrides_config(self): + """Should allow CLI args to override config defaults.""" + config = {"defaults": {"ssh": False}} + with patch('starlet_setup.cli.load_config', return_value=(config, None)), \ + patch('sys.argv', ['prog', 'user/repo', '--ssh']): + args = parse_args() + assert args.ssh is True + + + def test_requires_repo_argument(self): + """Should error when repo not provided and no special flags.""" + with patch('sys.argv', ['prog']), pytest.raises(SystemExit): + parse_args() + + + def test_allows_config_management_without_repo(self): + """Should allow profile commands without repo argument.""" + with patch('sys.argv', ['prog', '--list-profiles']): + args = parse_args() + assert args.list_profiles is True + + + def test_enables_mono_repo_with_repos_flag(self): + """Should enable mono-repo mode when --repos specified.""" + with patch('sys.argv', ['prog', 'user/repo', '--repos', 'lib1', 'lib2']): + args = parse_args() + assert args.mono_repo is True + assert args.repos == ['lib1', 'lib2'] + + + def test_enables_mono_repo_with_profile_flag(self): + """Should enable mono-repo mode when --profile specified.""" + with patch('sys.argv', ['prog', 'user/repo', '--profile']): + args = parse_args() + assert args.mono_repo is True + assert args.profile == 'default' + + + def test_profile_uses_default_when_no_name_given(self): + """Should use 'default' profile name when none specified.""" + with patch('sys.argv', ['prog', 'user/repo', '--profile']): + args = parse_args() + assert args.profile == 'default' + + + def test_errors_on_both_repos_and_profile(self): + """Should error when both --repos and --profile specified.""" + with patch('sys.argv', ['prog', 'user/repo', '--repos', 'lib1', '--profile']), \ + pytest.raises(SystemExit): + parse_args() + + + def test_parses_build_options(self): + """Should parse build configuration options.""" + with patch('sys.argv', ['prog', 'user/repo', '-b', 'Release', '-d', 'mybuild', '--no-build']): + args = parse_args() + assert args.build_type == 'Release' + assert args.build_dir == 'mybuild' + assert args.no_build is True + + + def test_parses_cmake_arguments(self): + """Should parse additional CMake arguments.""" + with patch('sys.argv', ['prog', 'user/repo', '--cmake-arg=-DTEST=ON', '--cmake-arg=-DDEBUG=OFF']): + args = parse_args() + assert args.cmake_arg == ['-DTEST=ON', '-DDEBUG=OFF'] + + + def test_attaches_config_to_args(self): + """Should attach loaded config to args namespace.""" + config = {"defaults": {}} + with patch('starlet_setup.cli.load_config', return_value=(config, None)), \ + patch('sys.argv', ['prog', 'user/repo']): + args = parse_args() + assert args.config == config \ No newline at end of file diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..fe3feb8 --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,211 @@ +"""Tests for commands module.""" + +from argparse import Namespace +import pytest +from unittest.mock import patch +from starlet_setup.commands import ( + single_repo_mode, + mono_repo_mode, + _create_mono_repo_cmakelists +) + + +class TestSingleRepoMode: + def test_clones_and_builds_new_repo(self, tmp_path, monkeypatch): + """Should clone and build repository when it doesn't exist.""" + monkeypatch.chdir(tmp_path) + args = Namespace ( + repo='user/repo', + ssh=False, + verbose=False, + build_dir='build', + build_type='Debug', + clean=False, + no_build=False, + cmake_arg=None, + config={} + ) + + def create_repo_on_clone(*args, **kwargs): + if 'clone' in str(args): + (tmp_path / 'repo').mkdir() + + with patch('starlet_setup.commands.run_command', side_effect=create_repo_on_clone) as mock_run: + single_repo_mode(args) + assert mock_run.call_count >= 2 + assert any ('git' in str(c) and 'clone' in str(c) for c in mock_run.call_args_list) + + + def test_updates_existing_repo_when_confirmed(self, tmp_path, monkeypatch): + """Should update existing repository when user confirms.""" + monkeypatch.chdir(tmp_path) + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + args = Namespace( + repo='user/repo', + ssh=False, + verbose=False, + build_dir='build', + build_type='Debug', + clean=False, + no_build=False, + cmake_arg=None, + config={} + ) + + with patch('starlet_setup.commands.run_command') as mock_run, \ + patch('builtins.input', return_value='y'): + single_repo_mode(args) + assert any('pull' in str(c) for c in mock_run.call_args_list) + + + def test_skips_build_when_no_build_flag_set(self, tmp_path, monkeypatch): + """Should skip build step when no_build is True.""" + monkeypatch.chdir(tmp_path) + args = Namespace( + repo='user/repo', + ssh=False, + verbose=False, + build_dir='build', + build_type='Debug', + clean=False, + no_build=True, + cmake_arg=None, + config={} + ) + + with patch('starlet_setup.commands.run_command') as mock_run, \ + patch('builtins.input', return_value='n'): + (tmp_path / 'repo').mkdir() + single_repo_mode(args) + assert not any('--build' in str(c) for c in mock_run.call_args_list) + + + def test_uses_ssh_when_requested(self, tmp_path, monkeypatch): + """Should use SSH URL when ssh flag is True.""" + monkeypatch.chdir(tmp_path) + args = Namespace( + repo='user/repo', + ssh=True, + verbose=False, + build_dir='build', + build_type='Debug', + clean=False, + no_build=True, + cmake_arg=None, + config={} + ) + + def create_repo_on_clone(*args, **kwargs): + if 'clone' in str(args): + (tmp_path / 'repo').mkdir() + + with patch('starlet_setup.commands.run_command', side_effect=create_repo_on_clone) as mock_run: + single_repo_mode(args) + clone_call = [c for c in mock_run.call_args_list if 'clone' in str(c)][0] + assert 'git@github.com' in str(clone_call) + + +class TestMonoRepoMode: + def test_clones_multiple_repos(self, tmp_path, monkeypatch): + """Should clone all specified repositories.""" + monkeypatch.chdir(tmp_path) + args = Namespace ( + repo='user/test-repo', + repos=['user/lib1', 'user/lib2'], + profile=None, + ssh=False, + verbose=False, + mono_dir='mono', + no_build=False, + cmake_arg=None, + config={} + ) + + with patch('starlet_setup.commands.clone_repository') as mock_clone, \ + patch('starlet_setup.commands.run_command'): + mono_repo_mode(args) + assert mock_clone.call_count >= 3 + + + def test_uses_profile_repos(self, tmp_path, monkeypatch): + """Should use repositories from specified profile.""" + monkeypatch.chdir(tmp_path) + config = { + 'profiles': { + 'myprofile': ['user/lib1', 'user/lib2'] + } + } + args = Namespace( + repo='user/test-repo', + repos=None, + profile='myprofile', + ssh=False, + verbose=False, + mono_dir='mono', + no_build=False, + cmake_arg=None, + config=config + ) + + with patch('starlet_setup.commands.clone_repository') as mock_clone, \ + patch('starlet_setup.commands.run_command'): + mono_repo_mode(args) + assert mock_clone.call_count >= 3 + + + def test_errors_on_missing_profile(self, tmp_path, monkeypatch): + """Should exit when specified profile doesn't exist.""" + monkeypatch.chdir(tmp_path) + args = Namespace ( + repo='user/test-repo', + repos=None, + profile='nonexistent', + ssh=False, + verbose=False, + mono_dir='mono', + no_build=False, + cmake_arg=None, + config={'profiles': {}} + ) + with pytest.raises(SystemExit): + mono_repo_mode(args) + + + def test_creates_cmakelists(self, tmp_path, monkeypatch): + """Should create root CMakeLists.txt.""" + monkeypatch.chdir(tmp_path) + args = Namespace( + repo='user/test-repo', + repos=['user/lib1'], + profile=None, + ssh=False, + verbose=False, + mono_dir='mono', + no_build=False, + cmake_arg=None, + config={} + ) + + with patch('starlet_setup.commands.clone_repository'), \ + patch('starlet_setup.commands.run_command'): + mono_repo_mode(args) + + cmake_file = tmp_path / 'mono' / 'CMakeLists.txt' + assert cmake_file.exists() + + +class TestCreateMonoRepoCMakeLists: + def test_creates_cmakelists_with_repos(self, tmp_path): + """Should create CMakeLists.txt with all repositories.""" + repos = ['user/lib1', 'user/lib2'] + _create_mono_repo_cmakelists(tmp_path, 'test-repo', repos) + + cmake_file = tmp_path / 'CMakeLists.txt' + assert cmake_file.exists() + + content = cmake_file.read_text() + assert 'lib1' in content + assert 'lib2' in content + assert 'test-repo' in content \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..a3b100f --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,124 @@ +"""Tests for config module.""" + +import json +from pathlib import Path +import pytest +from unittest.mock import patch +from starlet_setup.config import ( + load_config, + save_config, + get_config_value, + create_default_config +) + + +@pytest.fixture +def valid_config(): + """Sample valid config.""" + return { + "defaults": {"ssh": True, "verbose": False}, + "profiles": {"default": ["repo1", "repo2"]} + } + + +class TestLoadConfig: + def test_loads_from_current_directory(self, tmp_path, valid_config, monkeypatch): + """Should prioritize current directory config.""" + monkeypatch.chdir(tmp_path) + config_path = tmp_path / ".starlet-setup.json" + with open(config_path, 'w') as f: + json.dump(valid_config, f) + + config, path = load_config() + assert config == valid_config + assert path.resolve() == config_path.resolve() + + + def test_returns_empty_dict_when_no_config_exists(self, tmp_path, monkeypatch, capsys): + """Should return empty dict when no config found.""" + monkeypatch.chdir(tmp_path) + config, path = load_config() + assert config == {} + assert path is None + assert "Failed to find config file" in capsys.readouterr().out + + + def test_handles_invalid_json(self, tmp_path, monkeypatch, capsys): + """Should handle malformed JSON.""" + monkeypatch.chdir(tmp_path) + config_path = tmp_path / ".starlet-setup.json" + with open(config_path, 'w') as f: + f.write("{invalid json") + + config, path = load_config() + assert config == {} + captured = capsys.readouterr() + assert "Invalid JSON" in captured.out + + +class TestSaveConfig: + def test_saves_config_successfully(self, tmp_path, valid_config): + """Should write config to specified path.""" + config_path = tmp_path / "test-config.json" + result_path = save_config(valid_config, config_path) + + assert result_path == config_path + with open(config_path) as f: + saved_config = json.load(f) + assert saved_config == valid_config + + + def test_uses_default_path_when_none_provided(self, tmp_path, valid_config, monkeypatch): + """Should use default to .starlet-setup.json in current directory.""" + monkeypatch.chdir(tmp_path) + result_path = save_config(valid_config) + + assert result_path == Path('.starlet-setup.json') + assert (tmp_path / ".starlet-setup.json").exists() + + +class TestGetConfigValue: + def test_retrieves_nested_value(self, valid_config): + """Should navigate dot-separated keys.""" + assert get_config_value(valid_config, "defaults.ssh", False) is True + assert get_config_value(valid_config, "defaults.verbose", True) is False + + + def test_returns_default_when_key_missing(self, valid_config): + """Should return default for non-existent keys.""" + assert get_config_value(valid_config, "fake.key", "default") == "default" + assert get_config_value(valid_config, "defaults.numbermissing", 42) == 42 + + + def test_handles_non_dict_intermediate_values(self): + """Should return default when path encounters non-dict.""" + config = {"key": "string_value"} + assert get_config_value(config, "key.nested", "default") == "default" + + +class TestCreateDefaultConfig: + def test_creates_config_file(self, tmp_path, monkeypatch): + """Should create default config file.""" + monkeypatch.chdir(tmp_path) + with patch('builtins.input', return_value='y'): + create_default_config() + + config_path = tmp_path / ".starlet-setup.json" + assert config_path.exists() + with open(config_path) as f: + config = json.load(f) + assert "defaults" in config + assert "profiles" in config + + + def test_prompts_before_overwriting(self, tmp_path, monkeypatch, capsys): + """Should ask permission before overwriting existing config.""" + monkeypatch.chdir(tmp_path) + config_path = tmp_path / ".starlet-setup.json" + config_path.write_text("{}") + + with patch('builtins.input', return_value='n'): + create_default_config() + + assert "Aborted" in capsys.readouterr().out + assert config_path.read_text() == "{}" \ No newline at end of file diff --git a/tests/test_profiles.py b/tests/test_profiles.py new file mode 100644 index 0000000..bb9ac00 --- /dev/null +++ b/tests/test_profiles.py @@ -0,0 +1,131 @@ +"""Tests for profiles module.""" + +from pathlib import Path +import pytest +from unittest.mock import patch +from starlet_setup.profiles import ( + add_profile, + remove_profile, + list_profiles +) + + +class TestAddProfile: + def test_adds_new_profile(self, capsys): + """Should add new profile to config.""" + config = {'profiles': {}} + + with patch('starlet_setup.profiles.save_config', return_value=Path('config.json')): + add_profile(config, ['myprofile', 'user/repo1', 'user/repo2']) + + assert 'myprofile' in config['profiles'] + assert config['profiles']['myprofile'] == ['user/repo1', 'user/repo2'] + assert "added successfully" in capsys.readouterr().out + + + def test_creates_profiles_key_if_missing(self): + """Should create profiles dict if not present.""" + config = {} + + with patch('starlet_setup.profiles.save_config', return_value=Path('config.json')): + add_profile(config, ['myprofile', 'user/repo1']) + + assert 'profiles' in config + assert 'myprofile' in config['profiles'] + + + def test_overwrites_existing_profile_when_confirmed(self): + """Should overwrite existing profile when user confirms.""" + config = {'profiles': {'myprofile': ['old/repo']}} + + with patch('starlet_setup.profiles.save_config', return_value=Path('config.json')), \ + patch('builtins.input', return_value='y'): + add_profile(config, ['myprofile', 'new/repo1', 'new/repo2']) + + assert config['profiles']['myprofile'] == ['new/repo1', 'new/repo2'] + + + def test_aborts_overwrite_when_not_confirmed(self, capsys): + """Should not overwrite when user declines.""" + config = {'profiles': {'myprofile': ['old/repo']}} + + with patch('starlet_setup.profiles.save_config'), \ + patch('builtins.input', return_value='n'): + add_profile(config, ['myprofile', 'new/repo1']) + + assert config['profiles']['myprofile'] == ['old/repo'] + assert "Aborted" in capsys.readouterr().out + + + def test_errors_on_insufficient_arguments(self): + """Should exit when not enough arguments provided.""" + config = {} + + with pytest.raises(SystemExit): + add_profile(config, ['myprofile']) + + +class TestRemoveProfile: + def test_removes_existing_profile(self): + """Should remove profile when confirmed.""" + config = {'profiles': {'myprofile': ['user/repo1', 'user/repo2']}} + + with patch('starlet_setup.profiles.save_config', return_value=Path('config.json')), \ + patch('builtins.input', return_value='y'): + remove_profile(config, 'myprofile') + + assert 'myprofile' not in config['profiles'] + + + def test_aborts_removal_when_not_confirmed(self, capsys): + """Should not remove profile when declined.""" + config = {'profiles': {'myprofile': ['user/repo1', 'user/repo2']}} + + with patch('builtins.input', return_value='n'): + remove_profile(config, 'myprofile') + + assert 'myprofile' in config['profiles'] + assert "Aborted" in capsys.readouterr().out + + + def test_handles_nonexistent_profile(self, capsys): + """Should warn when profile doesn't exist.""" + config = {'profiles': {}} + + remove_profile(config, 'nonexistent') + assert "not found" in capsys.readouterr().out + + +class TestListProfiles: + def test_lists_all_profiles(self, capsys): + """Should display all configured profiles.""" + config = { + 'profiles': { + 'profile1': ['user/repo1', 'user/repo2'], + 'profile2': ['user/repo3'] + } + } + + list_profiles(config) + + output = capsys.readouterr().out + assert 'profile1' in output + assert 'profile2' in output + assert 'user/repo1' in output + assert 'user/repo3' in output + + + def test_handles_empty_profiles(self, capsys): + """Should show message when no profiles configured.""" + config = {'profiles': {}} + + list_profiles(config) + + assert "No profiles configured" in capsys.readouterr().out + + + def test_handles_missing_profiles_key(self, capsys): + """Should handle config without profiles key.""" + config = {} + list_profiles(config) + assert "No profiles configured" in capsys.readouterr().out \ No newline at end of file diff --git a/tests/test_repository.py b/tests/test_repository.py new file mode 100644 index 0000000..e7b9a04 --- /dev/null +++ b/tests/test_repository.py @@ -0,0 +1,94 @@ +"""Tests for repository module.""" + +import pytest +from unittest.mock import patch +from starlet_setup.repository import ( + resolve_repo_url, + get_default_repos, + clone_repository +) + +class TestResolveRepoUrl: + def test_returns_full_url_unchanged(self): + """Should return full URLs as-is.""" + https_url = "https://github.com/user/repo.git" + assert resolve_repo_url(https_url) == https_url + + git_url = "git@github.com:user/repo.git" + assert resolve_repo_url(git_url) == git_url + + + def test_converts_shorthand_to_https(self): + """Should convert username/repo to HTTPS.""" + result = resolve_repo_url("masonlet/starlet-math") + assert result == "https://github.com/masonlet/starlet-math.git" + + + def test_converts_shorthand_to_ssh(self): + """Should convert username/repo to SSH when requested.""" + result = resolve_repo_url("masonlet/starlet-math", use_ssh=True) + assert result == "git@github.com:masonlet/starlet-math.git" + + +class TestGetDefaultRepos: + def test_returns_repos_from_config(self): + """Should use profiles.default from config when available.""" + config = { + "profiles": { + "default": ["user/repo1", "user/repo2"] + } + } + result = get_default_repos(config) + assert result == ["user/repo1", "user/repo2"] + + + def test_returns_hardcoded_defaults_when_config_empty(self): + """Should fall back to built-in Starlet repos.""" + result = get_default_repos({}) + assert "masonlet/starlet-math" in result + assert "masonlet/starlet-engine" in result + assert len(result) == 7 + + +class TestCloneRepository: + def test_clones_repository_successfully(self, tmp_path, capsys): + """Should clone repo to target directory.""" + with patch('starlet_setup.repository.run_command') as mock_run: + clone_repository("user/repo", tmp_path, use_ssh=False, verbose=False) + + mock_run.assert_called_once() + command_args, command_kwargs = mock_run.call_args + assert command_args[0][:2] == ['git', 'clone'] + assert 'https://github.com/user/repo.git' in command_args[0] + assert command_kwargs['cwd'] == tmp_path + + assert "Cloning repo" in capsys.readouterr().out + + + def test_skips_existing_directory(self, tmp_path, capsys): + """Should skip cloning if directory exists.""" + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + with patch('starlet_setup.repository.run_command') as mock_run: + clone_repository("user/repo", tmp_path, use_ssh=False, verbose=False) + mock_run.assert_not_called() + + assert "already exists" in capsys.readouterr().out + + + def test_uses_ssh_when_requested(self, tmp_path): + """Should use SSH when use_ssh is True.""" + with patch('starlet_setup.repository.run_command') as mock_run: + clone_repository("user/repo", tmp_path, use_ssh=True, verbose=False) + + command_args, _ = mock_run.call_args + assert 'git@github.com:user/repo.git' in command_args[0] + + + def test_raises_on_clone_failure(self, tmp_path): + """Should propagate SystemExit when git clone fails.""" + with patch('starlet_setup.repository.run_command') as mock_run, \ + pytest.raises(SystemExit): + mock_run.side_effect = SystemExit(1) + clone_repository("user/repo", tmp_path, use_ssh=False, verbose=False) \ No newline at end of file diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..eac77cd --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,87 @@ +"""Tests for utils module.""" + +import subprocess +import pytest +from unittest.mock import patch, Mock +from starlet_setup.utils import ( + check_prerequisites, + run_command +) + + +class TestCheckPrerequisites: + def test_passes_when_tools_installed(self): + """Should complete without error when git and cmake exist.""" + with patch('shutil.which', return_value='/usr/bin/git'): + check_prerequisites() + + + def test_exits_when_tools_missing(self, capsys): + """Should exit with error message when tools missing.""" + with patch('shutil.which', return_value=None), pytest.raises(SystemExit) as exc_info: + check_prerequisites() + + assert exc_info.value.code == 1 + assert "Missing required tools: git, cmake" in capsys.readouterr().out + + + def test_shows_found_tools_in_verbose_mode(self, capsys): + """Should print found tools when verbose enabled.""" + with patch('shutil.which', return_value='/usr/bin/tool'): + check_prerequisites(verbose=True) + + output = capsys.readouterr().out + assert "Found git" in output + assert "Found cmake" in output + + +class TestRunCommand: + def test_executes_command_successfully(self): + """Should run command and return CompletedProcess.""" + with patch('subprocess.run') as mock_run: + mock_run.return_value = Mock(stdout="output", returncode=0) + result = run_command(['echo', 'test']) + mock_run.assert_called_once() + assert result.returncode == 0 + + + def test_exits_on_command_failure(self, capsys): + """Should exit when command returns non-zero.""" + with patch('subprocess.run') as mock_run, pytest.raises(SystemExit) as exc_info: + mock_run.side_effect = subprocess.CalledProcessError( + 1, ['false'], stderr="error" + ) + run_command(['false']) + + assert exc_info.value.code == 1 + assert "Error running command" in capsys.readouterr().out + + + def test_exits_on_command_not_found(self, capsys): + """Should exit when command does not exist.""" + with patch('subprocess.run') as mock_run, pytest.raises(SystemExit) as exc_info: + mock_run.side_effect = FileNotFoundError() + run_command(['nonexistent']) + + assert exc_info.value.code == 1 + assert "Command not found: nonexistent" in capsys.readouterr().out + + + def test_uses_working_directory(self, tmp_path): + """Should execute command in specific directory.""" + with patch('subprocess.run') as mock_run: + mock_run.return_value = Mock(returncode=0) + run_command(['ls'], cwd=tmp_path) + + assert mock_run.call_args[1]['cwd'] == tmp_path + + + def test_prints_details_in_verbose_mode(self, capsys): + """Should show command and directory in verbose mode.""" + with patch('subprocess.run') as mock_run: + mock_run.return_value = Mock(stdout="output", returncode=0) + run_command(['echo', 'test'], cwd='/tmp', verbose=True) + + output = capsys.readouterr().out + assert "Running: echo test" in output + assert "in directory: /tmp" in output \ No newline at end of file