diff --git a/README.md b/README.md index 2744ef7..0b4188e 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,9 @@ A lightweight Python utility to quickly clone, configure, and build CMake projec - [Configuration](#configuration) - [Usage](#usage) - [Single Repository Mode](#single-repository-mode) - - [Mono-Repo Mode](#mono-repo-mode) - - [Profile Mode](#profile-mode-saved-configurations) + - [Mono-Repo Mode](#mono-repository-mode) + - [Profile Mode](#profile-mode) + - [Config Mode](#config-mode) - [Development](#development) - [License](#license) @@ -75,9 +76,9 @@ pip install git+https://github.com/masonlet/starlet-setup.git Once installed, you can use the `starlet-setup` command from anywhere. +### ⚠️ Command not found? ⚠️
-

⚠️ Command not found? ⚠️

-If you get an error saying the command is not found, you may need to add Python's user scripts directory to your PATH: +If you get an error saying the command is not found, you may need to add Python's user scripts directory to your PATH. **Find your scripts directory**: ```bash @@ -136,9 +137,10 @@ Starlet Setup checks for configuration files in this order: ## Usage +###
-

Single Repository Mode

- +Single Repository Mode + #### Basic Usage ```bash @@ -171,11 +173,14 @@ starlet-setup username/repo --verbose # Custom CMake args starlet-setup username/repo --cmake-arg=-DCMAKE_CXX_COMPILER=clang++ ``` -
+
+ +
+###
-

Mono-Repo Mode

+Mono-Repo Mode #### BUILD_LOCAL Usage Mono-repo mode sets `BUILD_LOCAL=ON` in the root project's CMakeLists.txt. @@ -264,10 +269,14 @@ This structure allows you to: - Build everything together - Debug across module boundaries - Commit changes without digging into build directories + +
+
+###
-

Profile Mode (Saved Configurations)

+Profile Mode (Saved Configurations) #### Managing Profiles ```bash @@ -292,13 +301,51 @@ starlet-setup username/repo --profile myprofile # Use a profile with SSH starlet-setup username/repo --profile myprofile --ssh ``` + +
+ +
+ +### +
+Config Mode (Saved Build Settings) + +#### Managing Configs +```bash +# List all saved configurations +starlet-setup --list-configs + +# Add a new configuration with flags +starlet-setup --config-add myconfig --ssh --build-type Release --no-build + +# Remove a configuration +starlet-setup --config-remove myconfig +``` + +#### Using Configs +```bash +# Use a saved config +starlet-setup username/repo --config myconfig + +# Override specific settings +starlet-setup username/repo --config myconfig --verbose + +# Config with mono-repo mode +starlet-setup username/repo --mono-repo --config myconfig --ssh +``` + +
+
+
## Development + +###
-

Developing starlet-setup

+Development ### Running Tests diff --git a/pyproject.toml b/pyproject.toml index ed0dddc..5624288 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "starlet-setup" -version = "1.0.2" +version = "1.1.0" description = "Quick setup for CMake projects" readme = "README.md" requires-python = ">=3.6" diff --git a/src/starlet_setup/__main__.py b/src/starlet_setup/__main__.py index 1b97f24..4c0b7a3 100644 --- a/src/starlet_setup/__main__.py +++ b/src/starlet_setup/__main__.py @@ -6,7 +6,7 @@ """ from .cli import parse_args -from .config import create_default_config +from .config import create_default_config, list_configs, add_config, remove_config from .profiles import list_profiles, add_profile, remove_profile from .utils import check_prerequisites from .commands import mono_repo_mode, single_repo_mode @@ -19,21 +19,35 @@ def main() -> None: if args.init_config: create_default_config() return - + if args.list_configs: + list_configs(args.config) + return + if args.config_add: + new_config = { + 'ssh': args.ssh, + 'build_type': args.build_type, + 'build_dir': args.build_dir, + 'mono_dir': args.mono_dir, + 'no_build': args.no_build, + 'verbose': args.verbose, + 'cmake_arg': args.cmake_arg or [] + } + add_config(args.config, args.config_add, new_config) + return + if args.config_remove: + remove_config(args.config, args.config_remove) + return if args.list_profiles: list_profiles(args.config) return - if args.profile_add: add_profile(args.config, args.profile_add) return - if args.profile_remove: remove_profile(args.config, args.profile_remove) return - + check_prerequisites(args.verbose) - if args.mono_repo or args.profile: mono_repo_mode(args) else: diff --git a/src/starlet_setup/cli.py b/src/starlet_setup/cli.py index 88e2067..a6eb72a 100644 --- a/src/starlet_setup/cli.py +++ b/src/starlet_setup/cli.py @@ -2,51 +2,14 @@ import argparse from argparse import Namespace +from typing import Any from .config import get_config_value, load_config -def parse_args() -> Namespace: - """ - Parse command-line arguments for Starlet Setup. - - Returns: - Parsed arguments namespace - """ - config, config_path = load_config() - - parser = argparse.ArgumentParser( - description="Starlet Setup - Quick setup script for CMake projects", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - Single Repository Mode: - %(prog)s https://github.com/username/repo.git - %(prog)s git@github.com:username/repo.git - %(prog)s username/repo - %(prog)s username/repo --ssh - %(prog)s username/repo --no-build - %(prog)s username/repo --build-dir build_name --build-type Release - - Mono-repo Repository Mode: - %(prog)s username/repo --mono-repo - %(prog)s username/repo --mono-repo --ssh --mono-dir my_workspace - %(prog)s username/repo --repos user/lib1 user/lib2 user/lib3 - - Profile Repository Mode: - %(prog)s username/repo --profile - %(prog)s username/repo --profile myprofile - - Profile Management: - %(prog)s --list-profiles - %(prog)s --profile-add myprofile user/lib1 user/lib2 user/lib3 - %(prog)s --profile-remove myprofile - - Config: - %(prog)s --init-config - """ - ) - - # Common arguments +def _add_common_args( + parser, + config: dict[str, Any] +) -> None: parser.add_argument( '--ssh', action='store_true', @@ -66,14 +29,31 @@ def parse_args() -> Namespace: help='Additional CMake arguments (e.g., --cmake-arg=-D_BUILD_TESTS=ON). Can be used multiple times.' ) - # Configuration arguments + +def _add_config_management_args(parser) -> None: parser.add_argument( '--init-config', action='store_true', help='Create a default config file in the current directory' ) + parser.add_argument( + '--config-add', + metavar=('NAME'), + help="Add a new config" + ) + parser.add_argument( + '--config-remove', + metavar='NAME', + help='Remove a saved configuration' + ) + parser.add_argument( + '--list-configs', + action='store_true', + help='List all saved configs' + ) - # Profile management arguments + +def _add_profile_management_args(parser) -> None: parser.add_argument( '--profile-add', nargs='+', @@ -91,7 +71,8 @@ def parse_args() -> Namespace: help='List all saved profiles' ) - # Build arguments + +def _add_build_args(parser, config: dict[str, Any]) -> None: parser.add_argument( '-b', '--build-type', choices=['Debug', 'Release', 'RelWithDebInfo', 'MinSizeRel'], @@ -115,14 +96,8 @@ def parse_args() -> Namespace: help='Clean build directory before building' ) - # Repository argument - parser.add_argument( - 'repo', - nargs='?', - help='Repository name (username/repo) or full GitHub URL' - ) - - # Mono-repo repo mode arguments + +def _add_mono_repo_args(parser, config: dict[str, Any]) -> None: parser.add_argument( '--mono-repo', action='store_true', @@ -147,20 +122,79 @@ def parse_args() -> Namespace: help='Use saved profile for library repositories (uses "default" if no name given)' ) + +def parse_args() -> Namespace: + """ + Parse command-line arguments for Starlet Setup. + + Returns: + Parsed arguments namespace + """ + config, config_path = load_config() + + parser = argparse.ArgumentParser( + description="Starlet Setup - Quick setup script for CMake projects", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + Single Repository Mode: + %(prog)s https://github.com/username/repo.git + %(prog)s git@github.com:username/repo.git + %(prog)s username/repo + %(prog)s username/repo --ssh + %(prog)s username/repo --no-build + %(prog)s username/repo --build-dir build_name --build-type Release + + Mono-repo Repository Mode: + %(prog)s username/repo --mono-repo + %(prog)s username/repo --mono-repo --ssh --mono-dir my_workspace + %(prog)s username/repo --repos user/lib1 user/lib2 user/lib3 + + Profile Repository Mode: + %(prog)s username/repo --profile + %(prog)s username/repo --profile myprofile + + Profile Management: + %(prog)s --list-profiles + %(prog)s --profile-add myprofile user/lib1 user/lib2 user/lib3 + %(prog)s --profile-remove myprofile + + Config Mangement: + %(prog)s --init-config + %(prog)s --list-configs + %(prog)s --config-add myconfig + %(prog)s --config-add myconfig --ssh --no-build --build-type Release + %(prog)s --config-remove myconfig + """ + ) + + # Repository argument + parser.add_argument( + 'repo', + nargs='?', + help='Repository name (username/repo) or full GitHub URL' + ) + _add_common_args(parser, config) + _add_config_management_args(parser) + _add_profile_management_args(parser) + _add_build_args(parser, config) + _add_mono_repo_args(parser, config) + 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: + if args.init_config \ + or args.list_configs or args.config_add or args.config_remove \ + or args.list_profiles or args.profile_add or args.profile_remove: return args if not args.repo: parser.error("Repository argument is required") - if args.profile or args.repos: - args.mono_repo = True - if args.repos and args.profile: parser.error("Cannot use both --repos and --profile") + if args.repos or args.profile: + args.mono_repo = True return args \ No newline at end of file diff --git a/src/starlet_setup/config.py b/src/starlet_setup/config.py index 36c6685..69a1589 100644 --- a/src/starlet_setup/config.py +++ b/src/starlet_setup/config.py @@ -1,5 +1,6 @@ """Configuration file management""" +import sys import json from pathlib import Path from typing import Any @@ -45,7 +46,7 @@ def load_config() -> tuple[dict, Path | None]: def save_config( - config: dict, + config: dict[str, Any], config_path: Path | None = None ) -> Path: """ @@ -72,7 +73,11 @@ def save_config( return config_path -def get_config_value(config: dict, key: str, default: Any) -> Any: +def get_config_value( + config: dict[str, Any], + key: str, + default: Any +) -> Any: """ Get a config value with fallback to default. @@ -93,14 +98,16 @@ def get_config_value(config: dict, key: str, default: Any) -> Any: def create_default_config() -> None: """Create a default configuration file.""" default_config = { - "defaults": { - "ssh": False, - "build_type": "Debug", - "build_dir": "build", - "mono_dir": "build_starlet", - "no_build": False, - "verbose": False, - "cmake_arg": [] + "configs": { + "default": { + "ssh": False, + "build_type": "Debug", + "build_dir": "build", + "mono_dir": "build_starlet", + "no_build": False, + "verbose": False, + "cmake_arg": [] + } }, "profiles": { "default": [ @@ -136,4 +143,113 @@ def create_default_config() -> None: 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)") \ No newline at end of file + print(" 2. ~/.starlet-setup.json (home directory)") + + +def add_config( + config: dict[str, Any], + name: str, + new_config: dict[str, Any] +) -> None: + """ + Add a new config to the configuration. + + Args: + config: Configuration dictionary + config_name: Configuration name + """ + if 'configs' not in config: + config['configs'] = {} + + if name in config['configs']: + print(f"Warning: Configuration '{name}' already exists.") + if input("Overwrite? (y/n): ").lower() != 'y': + print("Aborted.") + return + + config['configs'][name] = new_config + + config_path = save_config(config) + + config_new = config['configs'][name] + print(f"Configuration '{name} added successfully to {config_path}") + print(f"Configuration details:") + print(f" SSH: {config_new.get('ssh')}") + print(f" Build Type: {config_new.get('build_type')}") + print(f" Build Directory: {config_new.get('build_dir')}") + print(f" Mono-build Directory: {config_new.get('mono_dir')}") + print(f" No-build flag: {config_new.get('no_build')}") + print(f" Verbose flag: {config_new.get('verbose')}") + cmake_args = config_new.get("cmake_args", []) + if cmake_args: + if len(cmake_args) == 1: + print(f" CMake argument: {cmake_args[0]}") + else: + print(" Cmake arguments: ") + for arg in cmake_args: + print(f" {arg}") + print(f"\nUsage: {Path(sys.argv[0]).name} username/repo --config {name}\n") + + +def remove_config( + config: dict[str, Any], + name: str +) -> None: + """ + Remove a config from the configuration. + + Args: + config: Configuration dictionary + name: Config name to remove + """ + if 'configs' not in config or name not in config['configs']: + print(f"\nWarning: Config '{name}' not found.\n") + return + + config_new = config['configs'][name] + print(f"Config {name}") + print(f"Configuration details:") + print(f" SSH: {config_new.get('ssh')}") + print(f" Build Type: {config_new.get('build_type')}") + print(f" Build Directory: {config_new.get('build_dir')}") + print(f" Mono-build Directory: {config_new.get('mono_dir')}") + print(f" No-build flag: {config_new.get('no_build')}") + print(f" Verbose flag: {config_new.get('verbose')}") + + if input("\nAre you sure you want to remove this config? (y/n): ").lower() != 'y': + print("Aborted.") + return + + del config['configs'][name] + config_path = save_config(config) + print(f"\nConfig '{name}' was successfully removed") + print(f"Configuration saved to: {config_path}\n") + + +def list_configs(config: dict[str, Any]) -> None: + print("Available configs:") + configs = get_config_value(config, 'configs', {}) + + if not configs: + print(" No configurations created.") + print(" Run with --init-config to create a default configuration.") + return + + print("Configurations:") + for name, cfg in configs.items(): + print(f"\n{name}:") + print(f" SSH: {cfg.get('ssh')}") + print(f" Build Type: {cfg.get('build_type')}") + print(f" Build Directory: {cfg.get('build_dir')}") + print(f" Mono-build Directory: {cfg.get('mono_dir')}") + print(f" No-build flag: {cfg.get('no_build')}") + print(f" Verbose flag: {cfg.get('verbose')}") + cmake_args = cfg.get("cmake_args", []) + if not cmake_args: + print() + elif len(cmake_args) == 1: + print(f" CMake argument: {cmake_args[0]}") + else: + print(" CMake arguments:") + for arg in cmake_args: + print(f" {arg}") \ No newline at end of file diff --git a/src/starlet_setup/profiles.py b/src/starlet_setup/profiles.py index 3140043..fc86897 100644 --- a/src/starlet_setup/profiles.py +++ b/src/starlet_setup/profiles.py @@ -2,10 +2,14 @@ import sys from pathlib import Path +from typing import Any from .config import get_config_value, save_config -def add_profile(config: dict, args_list: list[str]) -> None: +def add_profile( + config: dict[str, Any], + args_list: list[str] +) -> None: """ Add a new profile to the configuration. @@ -44,7 +48,10 @@ def add_profile(config: dict, args_list: list[str]) -> None: print(f"\nUsage: {Path(sys.argv[0]).name} username/test-repo --profile {name}") -def remove_profile(config: dict, name: str) -> None: +def remove_profile( + config: dict[str, Any], + name: str +) -> None: """ Remove a profile from the configuration. @@ -68,11 +75,11 @@ def remove_profile(config: dict, name: str) -> None: del config['profiles'][name] config_path = save_config(config) - print(f"Profile '{name}' removed successfully") - print(f"Configuration saved to: {config_path}") + print(f"\nProfile '{name}' removed successfully") + print(f"Configuration saved to: {config_path}\n") -def list_profiles(config: dict) -> None: +def list_profiles(config: dict[str, Any]) -> None: """ List all configured profiles. diff --git a/tests/test_config.py b/tests/test_config.py index a3b100f..7b6af34 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,7 +8,10 @@ load_config, save_config, get_config_value, - create_default_config + create_default_config, + add_config, + remove_config, + list_configs ) @@ -16,7 +19,9 @@ def valid_config(): """Sample valid config.""" return { - "defaults": {"ssh": True, "verbose": False}, + "configs": { + "default": {"ssh": True, "verbose": False} + }, "profiles": {"default": ["repo1", "repo2"]} } @@ -50,7 +55,7 @@ def test_handles_invalid_json(self, tmp_path, monkeypatch, capsys): with open(config_path, 'w') as f: f.write("{invalid json") - config, path = load_config() + config, _ = load_config() assert config == {} captured = capsys.readouterr() assert "Invalid JSON" in captured.out @@ -80,14 +85,14 @@ def test_uses_default_path_when_none_provided(self, tmp_path, valid_config, monk 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 + assert get_config_value(valid_config, "configs.default.ssh", False) is True + assert get_config_value(valid_config, "configs.default.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 + assert get_config_value(valid_config, "configs.default.numbermissing", 42) == 42 def test_handles_non_dict_intermediate_values(self): @@ -107,8 +112,8 @@ def test_creates_config_file(self, tmp_path, monkeypatch): assert config_path.exists() with open(config_path) as f: config = json.load(f) - assert "defaults" in config - assert "profiles" in config + assert 'configs' in config + assert 'profiles' in config def test_prompts_before_overwriting(self, tmp_path, monkeypatch, capsys): @@ -121,4 +126,102 @@ def test_prompts_before_overwriting(self, tmp_path, monkeypatch, capsys): create_default_config() assert "Aborted" in capsys.readouterr().out - assert config_path.read_text() == "{}" \ No newline at end of file + assert config_path.read_text() == "{}" + + +class TestAddConfig: + def test_adds_new_config(self, capsys): + """Should add new config to config.""" + config = {'configs': {}} + + with patch('starlet_setup.config.save_config', return_value=Path('config.json')): + add_config(config, 'myconfig', {'ssh': False, 'verbose': True}) + + assert 'myconfig' in config['configs'] + assert config['configs']['myconfig']['ssh'] is False + assert config['configs']['myconfig']['verbose'] is True + assert "added successfully" in capsys.readouterr().out + + + def test_creates_configs_key_if_missing(self): + """Should create configs dict if not present.""" + config = {} + + with patch('starlet_setup.config.save_config', return_value=Path('config.json')): + add_config(config, 'myconfig', {}) + + assert 'configs' in config + assert 'myconfig' in config['configs'] + + + def test_overwrites_config_when_confirmed(self): + """Should overwrite existing config when user confirms.""" + config = {'configs': {'myconfig': {'ssh': False}}} + + with patch('starlet_setup.config.save_config', return_value=Path('config.json')), \ + patch('builtins.input', return_value='y'): + add_config(config, 'myconfig', {'ssh': True}) + + assert config['configs']['myconfig'].get('ssh') == True + + + def test_aborts_overwrite_when_not_confirmed(self, capsys): + """Should not overwrite when user declines.""" + config = {'configs': {'myconfig': {'ssh': False}}} + + with patch('starlet_setup.config.save_config'), \ + patch('builtins.input', return_value='n'): + add_config(config, 'myconfig', {'ssh': True}) + + assert config['configs']['myconfig'].get('ssh') is False + assert "Aborted" in capsys.readouterr().out + + +class TestRemoveConfig: + def test_removes_existing_config(self): + """Should remove config when confirmed.""" + config = {'configs': {'myconfig': {}}} + + with patch('starlet_setup.config.save_config', return_value=Path('config.json')), \ + patch('builtins.input', return_value='y'): + remove_config(config, 'myconfig') + + assert 'myconfig' not in config['configs'] + + + def test_aborts_removal_when_not_confirmed(self, capsys): + """Should not remove config when declined.""" + config = {'configs': {'myconfig': {}}} + + with patch('builtins.input', return_value='n'): + remove_config(config, 'myconfig') + + assert 'myconfig' in config['configs'] + assert "Aborted" in capsys.readouterr().out + + + def test_handles_nonexistent_config(self, capsys): + """Should warn when config doesn't exist.""" + config = {'configs': {}} + + remove_config(config, 'nonexistent') + assert "not found" in capsys.readouterr().out + + +class TestListConfigs: + def test_list_all_configs(self, capsys): + """Should display all configurations.""" + config = { + 'configs': { + 'config1': {'ssh': True}, + 'config2': {'verbose': True} + } + } + + list_configs(config) + + output = capsys.readouterr().out + assert 'config1' in output + assert 'config2' in output + assert 'SSH: True' in output + assert 'Verbose flag: True' in output diff --git a/tests/test_profiles.py b/tests/test_profiles.py index bb9ac00..4f34e71 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -15,7 +15,7 @@ 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')): + with patch('starlet_setup.config.save_config', return_value=Path('config.json')): add_profile(config, ['myprofile', 'user/repo1', 'user/repo2']) assert 'myprofile' in config['profiles'] @@ -27,7 +27,7 @@ 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')): + with patch('starlet_setup.config.save_config', return_value=Path('config.json')): add_profile(config, ['myprofile', 'user/repo1']) assert 'profiles' in config @@ -38,7 +38,7 @@ 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')), \ + with patch('starlet_setup.config.save_config', return_value=Path('config.json')), \ patch('builtins.input', return_value='y'): add_profile(config, ['myprofile', 'new/repo1', 'new/repo2']) @@ -49,7 +49,7 @@ 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'), \ + with patch('starlet_setup.config.save_config'), \ patch('builtins.input', return_value='n'): add_profile(config, ['myprofile', 'new/repo1']) @@ -62,7 +62,7 @@ def test_errors_on_insufficient_arguments(self): config = {} with pytest.raises(SystemExit): - add_profile(config, ['myprofile']) + add_profile(config, ['myprofile']) class TestRemoveProfile: @@ -70,7 +70,7 @@ 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')), \ + with patch('starlet_setup.config.save_config', return_value=Path('config.json')), \ patch('builtins.input', return_value='y'): remove_profile(config, 'myprofile')