diff --git a/arm_cli/cli.py b/arm_cli/cli.py index 5b2ee9c..2da5b6a 100644 --- a/arm_cli/cli.py +++ b/arm_cli/cli.py @@ -6,6 +6,7 @@ import click from arm_cli import __version__ +from arm_cli.config import load_config from arm_cli.container.container import container from arm_cli.self.self import self from arm_cli.system.system import system @@ -13,9 +14,12 @@ @click.version_option(version=__version__) @click.group(context_settings=dict(help_option_names=["-h", "--help"])) -def cli(): +@click.pass_context +def cli(ctx): """Experimental CLI for deploying robotic applications""" - pass + # Load config and store in context + ctx.ensure_object(dict) + ctx.obj["config"] = load_config() # Add command groups diff --git a/arm_cli/config.py b/arm_cli/config.py new file mode 100644 index 0000000..65f70a9 --- /dev/null +++ b/arm_cli/config.py @@ -0,0 +1,56 @@ +import json +from pathlib import Path + +import appdirs +from pydantic import BaseModel + + +class Config(BaseModel): + """Configuration schema for the CLI.""" + + active_project: str = "" + + +def get_config_dir() -> Path: + """Get the configuration directory for the CLI.""" + config_dir = Path(appdirs.user_config_dir("arm-cli")) + config_dir.mkdir(parents=True, exist_ok=True) + return config_dir + + +def get_config_file() -> Path: + """Get the path to the configuration file.""" + return get_config_dir() / "config.json" + + +def load_config() -> Config: + """Load configuration from file, creating default if it doesn't exist.""" + config_file = get_config_file() + + if not config_file.exists(): + # Create default config + default_config = Config() + save_config(default_config) + return default_config + + try: + with open(config_file, "r") as f: + data = json.load(f) + return Config(**data) + except (json.JSONDecodeError, KeyError, TypeError) as e: + # If config is corrupted, create a new one + print(f"Warning: Config file corrupted, creating new default config: " f"{e}") + default_config = Config() + save_config(default_config) + return default_config + + +def save_config(config: Config) -> None: + """Save configuration to file.""" + config_file = get_config_file() + + # Ensure directory exists + config_file.parent.mkdir(parents=True, exist_ok=True) + + with open(config_file, "w") as f: + json.dump(config.model_dump(), f, indent=2) diff --git a/arm_cli/container/container.py b/arm_cli/container/container.py index 456ec92..c1180cf 100644 --- a/arm_cli/container/container.py +++ b/arm_cli/container/container.py @@ -7,7 +7,8 @@ @click.group() def container(): - """Basic tools for managing Docker containers. For more extensive tooling, try lazydocker""" + """Basic tools for managing Docker containers. For more extensive tooling, + try lazydocker""" pass @@ -18,8 +19,10 @@ def get_running_containers(): @container.command("list") -def list_containers(): +@click.pass_context +def list_containers(ctx): """List all Docker containers""" + config = ctx.obj["config"] # noqa: F841 - config available for future use containers = get_running_containers() if containers: @@ -30,8 +33,10 @@ def list_containers(): @container.command("attach") -def attach_container(): +@click.pass_context +def attach_container(ctx): """Interactively select a running Docker container and attach to it""" + config = ctx.obj["config"] # noqa: F841 - config available for future use containers = get_running_containers() if not containers: @@ -48,7 +53,7 @@ def attach_container(): ] answers = inquirer.prompt(container_choices) - selected_container_name = answers["container"].split(" ")[0] # Extract container name + selected_container_name = answers["container"].split(" ")[0] # Extract name print(f"Attaching to {selected_container_name}...") @@ -61,8 +66,10 @@ def attach_container(): @container.command("restart") -def restart_container(): +@click.pass_context +def restart_container(ctx): """Interactively select a running Docker container and restart it""" + config = ctx.obj["config"] # noqa: F841 - config available for future use containers = get_running_containers() if not containers: @@ -99,8 +106,10 @@ def restart_container(): @container.command("stop") -def stop_container(): +@click.pass_context +def stop_container(ctx): """Interactively select a running Docker container and stop it""" + config = ctx.obj["config"] # noqa: F841 - config available for future use containers = get_running_containers() if not containers: @@ -121,7 +130,7 @@ def stop_container(): print("No container selected.") return - selected_container_name = answers["container"].split(" ")[0] # Extract container name + selected_container_name = answers["container"].split(" ")[0] # Extract name print(f"Stopping {selected_container_name}...") diff --git a/arm_cli/self/self.py b/arm_cli/self/self.py index 46e6410..5d97954 100644 --- a/arm_cli/self/self.py +++ b/arm_cli/self/self.py @@ -5,6 +5,8 @@ import click from click.core import ParameterSource +from arm_cli.config import save_config + @click.group() def self(): @@ -22,6 +24,7 @@ def self(): @click.option("-f", "--force", is_flag=True, help="Skip confirmation prompts") @click.pass_context def update(ctx, source, force): + config = ctx.obj["config"] # noqa: F841 - config available for future use """Update arm-cli from PyPI or source""" if source is None and ctx.get_parameter_source("source") == ParameterSource.COMMANDLINE: source = "." @@ -31,7 +34,7 @@ def update(ctx, source, force): if not force: if not click.confirm( - "Do you want to install arm-cli from source? This will clear pip cache." + "Do you want to install arm-cli from source? This will clear pip " "cache." ): print("Update cancelled.") return @@ -54,3 +57,19 @@ def update(ctx, source, force): subprocess.run([sys.executable, "-m", "pip", "install", "--upgrade", "arm-cli"], check=True) print("arm-cli updated successfully!") + + +@self.command() +@click.option("--project", help="Set the active project") +@click.pass_context +def config(ctx, project): + """Manage CLI configuration""" + config = ctx.obj["config"] + + if project is not None: + config.active_project = project + save_config(config) + print(f"Active project set to: {project}") + else: + print(f"Active project: {config.active_project}") + print("Use --project to set a new active project") diff --git a/arm_cli/system/setup_utils.py b/arm_cli/system/setup_utils.py index 2d3d216..6663bfd 100644 --- a/arm_cli/system/setup_utils.py +++ b/arm_cli/system/setup_utils.py @@ -236,7 +236,7 @@ def setup_docker_group(force=False): def is_line_in_file(line, filepath) -> bool: """Checks if a line is already in a file""" with open(filepath, "r") as f: - return any(line.strip() in l.strip() for l in f) + return any(line.strip() in file_line.strip() for file_line in f) def setup_shell(force=False): diff --git a/arm_cli/system/system.py b/arm_cli/system/system.py index a167f80..ff8f272 100644 --- a/arm_cli/system/system.py +++ b/arm_cli/system/system.py @@ -1,8 +1,4 @@ -import subprocess - import click -import docker -import inquirer from arm_cli.system.setup_utils import ( setup_data_directories, @@ -20,7 +16,9 @@ def system(): @system.command() @click.option("-f", "--force", is_flag=True, help="Skip confirmation prompts") -def setup(force): +@click.pass_context +def setup(ctx, force): + config = ctx.obj["config"] # noqa: F841 - config available for future use """Generic setup (will be refined later)""" setup_xhost(force=force) @@ -37,5 +35,6 @@ def setup(force): print("Data directory setup was not completed.") print("You can run this setup again later with: arm-cli system setup") - # Additional setup code can go here (e.g., starting containers, attaching, etc.) + # Additional setup code can go here (e.g., starting containers, + # attaching, etc.) pass diff --git a/pyproject.toml b/pyproject.toml index 11da372..bd64f08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,11 +11,13 @@ authors = [{ name = "Matthew Powelson" }] requires-python = ">=3.8" dynamic = ["version"] dependencies = [ + "appdirs", "beartype", "click", "click-completion", "docker", "inquirer", + "pydantic", ] [project.scripts] diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..983eb3b --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,135 @@ +import json +import tempfile +from pathlib import Path +from unittest.mock import patch + +from arm_cli.config import Config, get_config_dir, get_config_file, load_config, save_config + + +class TestConfig: + def test_config_default_values(self): + """Test that Config has correct default values.""" + config = Config() + assert config.active_project == "" + + def test_config_with_values(self): + """Test that Config can be created with custom values.""" + config = Config(active_project="test-project") + assert config.active_project == "test-project" + + def test_config_model_dump(self): + """Test that Config can be serialized to dict.""" + config = Config(active_project="test-project") + data = config.model_dump() + assert data == {"active_project": "test-project"} + + +class TestConfigFunctions: + def test_get_config_dir(self): + """Test that config directory is created correctly.""" + with patch("arm_cli.config.appdirs.user_config_dir") as mock_user_config_dir: + mock_user_config_dir.return_value = "/tmp/test_config" + + config_dir = get_config_dir() + + assert config_dir == Path("/tmp/test_config") + mock_user_config_dir.assert_called_once_with("arm-cli") + + def test_get_config_file(self): + """Test that config file path is correct.""" + with patch("arm_cli.config.get_config_dir") as mock_get_config_dir: + mock_get_config_dir.return_value = Path("/tmp/test_config") + + config_file = get_config_file() + + assert config_file == Path("/tmp/test_config/config.json") + + def test_save_config(self): + """Test that config can be saved to file.""" + with tempfile.TemporaryDirectory() as temp_dir: + with patch("arm_cli.config.get_config_file") as mock_get_config_file: + config_file = Path(temp_dir) / "config.json" + mock_get_config_file.return_value = config_file + + config = Config(active_project="test-project") + save_config(config) + + assert config_file.exists() + with open(config_file, "r") as f: + data = json.load(f) + + assert data == {"active_project": "test-project"} + + def test_load_config_new_file(self): + """Test that new config file is created when it doesn't exist.""" + with tempfile.TemporaryDirectory() as temp_dir: + with patch("arm_cli.config.get_config_file") as mock_get_config_file: + config_file = Path(temp_dir) / "config.json" + mock_get_config_file.return_value = config_file + + # File doesn't exist initially + assert not config_file.exists() + + config = load_config() + + # File should be created with default values + assert config_file.exists() + assert config.active_project == "" + + # Verify file contents + with open(config_file, "r") as f: + data = json.load(f) + assert data == {"active_project": ""} + + def test_load_config_existing_file(self): + """Test that existing config file is loaded correctly.""" + with tempfile.TemporaryDirectory() as temp_dir: + with patch("arm_cli.config.get_config_file") as mock_get_config_file: + config_file = Path(temp_dir) / "config.json" + mock_get_config_file.return_value = config_file + + # Create existing config file + existing_data = {"active_project": "existing-project"} + with open(config_file, "w") as f: + json.dump(existing_data, f) + + config = load_config() + + assert config.active_project == "existing-project" + + def test_load_config_corrupted_file(self): + """Test that corrupted config file is handled gracefully.""" + with tempfile.TemporaryDirectory() as temp_dir: + with patch("arm_cli.config.get_config_file") as mock_get_config_file: + config_file = Path(temp_dir) / "config.json" + mock_get_config_file.return_value = config_file + + # Create corrupted config file + with open(config_file, "w") as f: + f.write("invalid json content") + + config = load_config() + + # Should create new default config + assert config.active_project == "" + + # Verify file was overwritten with valid JSON + with open(config_file, "r") as f: + data = json.load(f) + assert data == {"active_project": ""} + + def test_load_config_missing_fields(self): + """Test that config with missing fields is handled gracefully.""" + with tempfile.TemporaryDirectory() as temp_dir: + with patch("arm_cli.config.get_config_file") as mock_get_config_file: + config_file = Path(temp_dir) / "config.json" + mock_get_config_file.return_value = config_file + + # Create config file with missing fields + with open(config_file, "w") as f: + json.dump({}, f) + + config = load_config() + + # Should use default values for missing fields + assert config.active_project == ""