From c4ef4da7d636dd64e2e07d983fa312164af95ada Mon Sep 17 00:00:00 2001 From: Masonlet Date: Thu, 13 Nov 2025 22:03:53 -0500 Subject: [PATCH 1/7] Add unit tests for config module - Add pytest as test dependency - Configure pytest with minversion and test paths - Implement tests for config.py covering: - Config file loading from multiple locations - JSON validation and error handling - Config save operations with path defaults - Nested key navigation with get_config_value - Interactive config creation with overwrite protection --- README.md | 58 +++++++++++++++- pyproject.toml | 5 ++ pytest.ini | 4 ++ src/starlet_setup/__main__.py | 2 + src/starlet_setup/cli.py | 5 +- src/starlet_setup/commands.py | 3 +- src/starlet_setup/config.py | 65 ++++++++++++----- src/starlet_setup/profiles.py | 1 + src/starlet_setup/repository.py | 4 +- src/starlet_setup/utils.py | 5 +- tests/__init__.py | 0 tests/test_config.py | 119 ++++++++++++++++++++++++++++++++ 12 files changed, 244 insertions(+), 27 deletions(-) create mode 100644 pytest.ini create mode 100644 tests/__init__.py create mode 100644 tests/test_config.py diff --git a/README.md b/README.md index fc39ed3..9f6cde3 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,10 @@ # Starlet Setup A lightweight Python utility to quickly clone, configure, and build CMake projects — from single repos to full mono-repos. - [![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 +14,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 +45,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 +101,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 +122,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 +285,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..779439e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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..a70466a 100644 --- a/src/starlet_setup/__main__.py +++ b/src/starlet_setup/__main__.py @@ -5,12 +5,14 @@ Supports single repository setup and mono-repo setup of projects. """ + from .cli import parse_args from .config import create_default_config from .profiles import list_profiles, add_profile, remove_profile 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..e21f106 100644 --- a/src/starlet_setup/cli.py +++ b/src/starlet_setup/cli.py @@ -1,9 +1,11 @@ """Command-line argument parsing.""" + import argparse 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 +13,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", @@ -148,6 +150,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..0a3102a 100644 --- a/src/starlet_setup/commands.py +++ b/src/starlet_setup/commands.py @@ -1,11 +1,11 @@ """Command handlers for single and mono-repo modes.""" + import sys import shutil from argparse import Namespace from pathlib import Path from typing import Optional - from .repository import ( resolve_repo_url, get_default_repos, @@ -109,7 +109,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..c6acf8b 100644 --- a/src/starlet_setup/config.py +++ b/src/starlet_setup/config.py @@ -6,7 +6,7 @@ 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 +18,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 +58,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 +79,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 +123,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/profiles.py b/src/starlet_setup/profiles.py index 3140043..f2d7715 100644 --- a/src/starlet_setup/profiles.py +++ b/src/starlet_setup/profiles.py @@ -1,5 +1,6 @@ """Profile management for repository configurations.""" + import sys from pathlib import Path from .config import get_config_value, save_config diff --git a/src/starlet_setup/repository.py b/src/starlet_setup/repository.py index 1f3230a..7faa170 100644 --- a/src/starlet_setup/repository.py +++ b/src/starlet_setup/repository.py @@ -1,9 +1,11 @@ """Repository functions including cloning and URL resolution""" + from pathlib import Path 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 +80,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..993cc97 100644 --- a/src/starlet_setup/utils.py +++ b/src/starlet_setup/utils.py @@ -1,5 +1,6 @@ """Utility functions for Starlet Setup.""" + import sys import shutil import subprocess @@ -33,8 +34,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_config.py b/tests/test_config.py new file mode 100644 index 0000000..b664aeb --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,119 @@ +"""Tests for config module.""" + +import json +import pytest +from pathlib import Path +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 From 78355c0610502c3c8cd536552bd7c5f2de0a3140 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Thu, 13 Nov 2025 23:09:43 -0500 Subject: [PATCH 2/7] Add unit tests for utils module - Implement tests for utils.py covering: - Prerequisites checking for git and cmake - Tool detection with verbose output - Command execution with error handling --- src/starlet_setup/__main__.py | 1 - src/starlet_setup/cli.py | 1 - src/starlet_setup/commands.py | 1 - src/starlet_setup/config.py | 1 - src/starlet_setup/profiles.py | 1 - src/starlet_setup/repository.py | 1 - src/starlet_setup/utils.py | 1 - tests/test_config.py | 3 +- tests/test_utils.py | 81 +++++++++++++++++++++++++++++++++ 9 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 tests/test_utils.py diff --git a/src/starlet_setup/__main__.py b/src/starlet_setup/__main__.py index a70466a..1b97f24 100644 --- a/src/starlet_setup/__main__.py +++ b/src/starlet_setup/__main__.py @@ -5,7 +5,6 @@ Supports single repository setup and mono-repo setup of projects. """ - from .cli import parse_args from .config import create_default_config from .profiles import list_profiles, add_profile, remove_profile diff --git a/src/starlet_setup/cli.py b/src/starlet_setup/cli.py index e21f106..1a5a97f 100644 --- a/src/starlet_setup/cli.py +++ b/src/starlet_setup/cli.py @@ -1,6 +1,5 @@ """Command-line argument parsing.""" - import argparse from argparse import Namespace from .config import get_config_value, load_config diff --git a/src/starlet_setup/commands.py b/src/starlet_setup/commands.py index 0a3102a..585c360 100644 --- a/src/starlet_setup/commands.py +++ b/src/starlet_setup/commands.py @@ -1,6 +1,5 @@ """Command handlers for single and mono-repo modes.""" - import sys import shutil from argparse import Namespace diff --git a/src/starlet_setup/config.py b/src/starlet_setup/config.py index c6acf8b..36c6685 100644 --- a/src/starlet_setup/config.py +++ b/src/starlet_setup/config.py @@ -1,6 +1,5 @@ """Configuration file management""" - import json from pathlib import Path from typing import Any diff --git a/src/starlet_setup/profiles.py b/src/starlet_setup/profiles.py index f2d7715..3140043 100644 --- a/src/starlet_setup/profiles.py +++ b/src/starlet_setup/profiles.py @@ -1,6 +1,5 @@ """Profile management for repository configurations.""" - import sys from pathlib import Path from .config import get_config_value, save_config diff --git a/src/starlet_setup/repository.py b/src/starlet_setup/repository.py index 7faa170..b8bdd79 100644 --- a/src/starlet_setup/repository.py +++ b/src/starlet_setup/repository.py @@ -1,6 +1,5 @@ """Repository functions including cloning and URL resolution""" - from pathlib import Path from .config import get_config_value from .utils import run_command diff --git a/src/starlet_setup/utils.py b/src/starlet_setup/utils.py index 993cc97..f212fd1 100644 --- a/src/starlet_setup/utils.py +++ b/src/starlet_setup/utils.py @@ -1,6 +1,5 @@ """Utility functions for Starlet Setup.""" - import sys import shutil import subprocess diff --git a/tests/test_config.py b/tests/test_config.py index b664aeb..fc7440f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,8 +1,9 @@ """Tests for config module.""" import json -import pytest from pathlib import Path + +import pytest from unittest.mock import patch from starlet_setup.config import ( load_config, diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..e7dcabe --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,81 @@ +"""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 From e11c8cb1ee6b440fd3feecf4d7778bf25e453ebe Mon Sep 17 00:00:00 2001 From: Masonlet Date: Thu, 13 Nov 2025 23:24:48 -0500 Subject: [PATCH 3/7] Add unit tests for repository module - Implement unit tests for repository.py covering: - URL resolution for HTTPS and SSH protocols - HTTPS/SSH protocol selection - Default repository list from config and fallback - Repository cloning with directory existence checks - Clone failure handling --- tests/test_repository.py | 87 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/test_repository.py diff --git a/tests/test_repository.py b/tests/test_repository.py new file mode 100644 index 0000000..b08ec85 --- /dev/null +++ b/tests/test_repository.py @@ -0,0 +1,87 @@ +"""Tests for repository module.""" + +from pathlib import Path + +import pytest +from unittest.mock import patch, Mock +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 From 13fd025bbab67ca87e4edc744cba8b6330401305 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Thu, 13 Nov 2025 23:48:22 -0500 Subject: [PATCH 4/7] Add unit tests for CLI modules - Implement unit tests for cli.py covering: - Basic repository argument parsing - Repository requirement validation - Config defaults and CLI overriding defaults - Profile and config management without repo - Mono-repo mode activation via --repos and --profile - Mutual exclusivity of --repos and --profile - Default profile name handling - CMake arguments parsing --- src/starlet_setup/cli.py | 6 +-- tests/test_cli.py | 94 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 tests/test_cli.py diff --git a/src/starlet_setup/cli.py b/src/starlet_setup/cli.py index 1a5a97f..88e2067 100644 --- a/src/starlet_setup/cli.py +++ b/src/starlet_setup/cli.py @@ -61,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 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..6a86117 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,94 @@ +"""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 From 1efd4d23f5e026ff30b54406fd41713954504ae9 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Fri, 14 Nov 2025 00:37:15 -0500 Subject: [PATCH 5/7] Add unit tests for commands module - Implement tests for commands.py covering: - Single repo cloning and building for new repos - Existing repo updates with user configuration - Build skipping with no_build flag - SSH URL usage when requested - Multi-repo cloning in mono-repo mode - Profile-based repository selection - Error handling for missing profile - Monorepo CMakeLists.txt generation - CMakeLists validation with repo names --- tests/test_commands.py | 212 +++++++++++++++++++++++++++++++++++++++ tests/test_repository.py | 3 +- 2 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 tests/test_commands.py diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..cb87ba9 --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,212 @@ +"""Tests for commands module.""" + +from pathlib import Path +from argparse import Namespace + +import pytest +from unittest.mock import patch, Mock, call +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_repository.py b/tests/test_repository.py index b08ec85..22d34b1 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -82,6 +82,7 @@ def test_uses_ssh_when_requested(self, tmp_path): 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): + 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 From 3338d8bbd88d8197cb96866718b346be2b984ede Mon Sep 17 00:00:00 2001 From: Masonlet Date: Fri, 14 Nov 2025 01:03:45 -0500 Subject: [PATCH 6/7] Add unit tests for profiles module - Implement tests for profiles.py covering: - Profile addition with new and existing names - Profiles dict creation when missing - Overwrite confirmation for existing profiles - Overwrite abort when declined - Insufficient arguments error handling - Profile removal with confirmation - Profile removal abort when declined - Nonexistent profile handling - Profile listing with multiple profiles - Empty profiles message - Missing profiles key handling --- tests/test_cli.py | 11 ++++ tests/test_commands.py | 7 +-- tests/test_config.py | 6 +- tests/test_profiles.py | 131 +++++++++++++++++++++++++++++++++++++++ tests/test_repository.py | 12 +++- tests/test_utils.py | 8 ++- 6 files changed, 166 insertions(+), 9 deletions(-) create mode 100644 tests/test_profiles.py diff --git a/tests/test_cli.py b/tests/test_cli.py index 6a86117..d999b9c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -14,6 +14,7 @@ def test_parses_basic_repository(self): assert args.ssh is False assert args.verbose is False + def test_applies_config_defaults(self): """Should use config values as default.""" config = { @@ -26,6 +27,7 @@ def test_applies_config_defaults(self): 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}} @@ -34,17 +36,20 @@ def test_command_line_overrides_config(self): 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']): @@ -52,6 +57,7 @@ def test_enables_mono_repo_with_repos_flag(self): 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']): @@ -59,18 +65,21 @@ def test_enables_mono_repo_with_profile_flag(self): 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']): @@ -79,12 +88,14 @@ def test_parses_build_options(self): 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": {}} diff --git a/tests/test_commands.py b/tests/test_commands.py index cb87ba9..fe3feb8 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,16 +1,15 @@ """Tests for commands module.""" -from pathlib import Path from argparse import Namespace - import pytest -from unittest.mock import patch, Mock, call +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.""" @@ -30,7 +29,7 @@ def test_clones_and_builds_new_repo(self, tmp_path, monkeypatch): 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 diff --git a/tests/test_config.py b/tests/test_config.py index fc7440f..a3b100f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,7 +2,6 @@ import json from pathlib import Path - import pytest from unittest.mock import patch from starlet_setup.config import ( @@ -34,6 +33,7 @@ def test_loads_from_current_directory(self, tmp_path, valid_config, monkeypatch) 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) @@ -67,6 +67,7 @@ def test_saves_config_successfully(self, tmp_path, valid_config): 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) @@ -82,11 +83,13 @@ def test_retrieves_nested_value(self, valid_config): 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"} @@ -107,6 +110,7 @@ def test_creates_config_file(self, tmp_path, monkeypatch): 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) 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 index 22d34b1..e7b9a04 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -1,9 +1,7 @@ """Tests for repository module.""" -from pathlib import Path - import pytest -from unittest.mock import patch, Mock +from unittest.mock import patch from starlet_setup.repository import ( resolve_repo_url, get_default_repos, @@ -19,16 +17,19 @@ def test_returns_full_url_unchanged(self): 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.""" @@ -40,6 +41,7 @@ def test_returns_repos_from_config(self): 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({}) @@ -47,6 +49,7 @@ def test_returns_hardcoded_defaults_when_config_empty(self): 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.""" @@ -61,6 +64,7 @@ def test_clones_repository_successfully(self, tmp_path, capsys): 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" @@ -72,6 +76,7 @@ def test_skips_existing_directory(self, tmp_path, capsys): 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: @@ -80,6 +85,7 @@ def test_uses_ssh_when_requested(self, tmp_path): 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, \ diff --git a/tests/test_utils.py b/tests/test_utils.py index e7dcabe..eac77cd 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +1,6 @@ """Tests for utils module.""" import subprocess - import pytest from unittest.mock import patch, Mock from starlet_setup.utils import ( @@ -16,6 +15,7 @@ def test_passes_when_tools_installed(self): 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: @@ -24,6 +24,7 @@ def test_exits_when_tools_missing(self, capsys): 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'): @@ -33,6 +34,7 @@ def test_shows_found_tools_in_verbose_mode(self, capsys): assert "Found git" in output assert "Found cmake" in output + class TestRunCommand: def test_executes_command_successfully(self): """Should run command and return CompletedProcess.""" @@ -42,6 +44,7 @@ def test_executes_command_successfully(self): 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: @@ -53,6 +56,7 @@ def test_exits_on_command_failure(self, capsys): 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: @@ -62,6 +66,7 @@ def test_exits_on_command_not_found(self, capsys): 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: @@ -70,6 +75,7 @@ def test_uses_working_directory(self, 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: From 247a5cde040c17a7959fc67c933979e4b86d142b Mon Sep 17 00:00:00 2001 From: Masonlet Date: Fri, 14 Nov 2025 01:18:34 -0500 Subject: [PATCH 7/7] GitHub Actions workflow --- .github/workflows/tests.yml | 25 +++++++++++++++++++++++++ README.md | 1 + pyproject.toml | 2 +- 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/tests.yml 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 9f6cde3..75d7543 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # 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)]() diff --git a/pyproject.toml b/pyproject.toml index 779439e..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"