From 3b8d525591b0ad8689c8560624b94d393d901f41 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 7 Aug 2025 12:50:33 +0000 Subject: [PATCH 1/5] Add config management with project selection and configuration persistence Co-authored-by: powelson.matthew --- arm_cli/cli.py | 8 +- arm_cli/config.py | 56 ++++++++++++++ arm_cli/container/container.py | 23 ++++-- arm_cli/self/self.py | 21 ++++- arm_cli/system/setup_utils.py | 2 +- arm_cli/system/system.py | 11 ++- pyproject.toml | 11 +++ tests/test_config.py | 135 +++++++++++++++++++++++++++++++++ 8 files changed, 250 insertions(+), 17 deletions(-) create mode 100644 arm_cli/config.py create mode 100644 tests/test_config.py 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..a4453d7 --- /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("mycli")) + 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..4defe54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,17 @@ dependencies = [ [project.scripts] arm-cli = "arm_cli.cli:cli" +[tool.poetry.dependencies] +python = "^3.8" +setuptools = ">=69.0.0" +appdirs = "*" +beartype = "*" +click = "*" +click-completion = "*" +docker = "*" +inquirer = "*" +pydantic = "*" + [project.optional-dependencies] dev = [ "black==24.8.0", diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..6b3306f --- /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("mycli") + + 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 == "" From aa921fd2b44b1c96caa40dc6a0b8ff9219d9d6f1 Mon Sep 17 00:00:00 2001 From: Matthew Powelson Date: Thu, 14 Aug 2025 11:42:41 -0400 Subject: [PATCH 2/5] Fix config directory --- arm_cli/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arm_cli/config.py b/arm_cli/config.py index a4453d7..65f70a9 100644 --- a/arm_cli/config.py +++ b/arm_cli/config.py @@ -13,7 +13,7 @@ class Config(BaseModel): def get_config_dir() -> Path: """Get the configuration directory for the CLI.""" - config_dir = Path(appdirs.user_config_dir("mycli")) + config_dir = Path(appdirs.user_config_dir("arm-cli")) config_dir.mkdir(parents=True, exist_ok=True) return config_dir From 977e87ea15a24291ed00a330a69db6c59721111f Mon Sep 17 00:00:00 2001 From: Matthew Powelson Date: Thu, 14 Aug 2025 11:43:43 -0400 Subject: [PATCH 3/5] Fix rebase errors --- pyproject.toml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4defe54..11da372 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,17 +21,6 @@ dependencies = [ [project.scripts] arm-cli = "arm_cli.cli:cli" -[tool.poetry.dependencies] -python = "^3.8" -setuptools = ">=69.0.0" -appdirs = "*" -beartype = "*" -click = "*" -click-completion = "*" -docker = "*" -inquirer = "*" -pydantic = "*" - [project.optional-dependencies] dev = [ "black==24.8.0", From cc1796ee3750e8d1bffcb39d993b95739dce536d Mon Sep 17 00:00:00 2001 From: Matthew Powelson Date: Thu, 14 Aug 2025 11:48:08 -0400 Subject: [PATCH 4/5] Updates config directory test assertion. Updates the test assertion for the config directory to reflect the correct CLI tool name. --- tests/test_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_config.py b/tests/test_config.py index 6b3306f..983eb3b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -33,7 +33,7 @@ def test_get_config_dir(self): config_dir = get_config_dir() assert config_dir == Path("/tmp/test_config") - mock_user_config_dir.assert_called_once_with("mycli") + mock_user_config_dir.assert_called_once_with("arm-cli") def test_get_config_file(self): """Test that config file path is correct.""" From 87e2c229c0daaa15c297ed92472f9aaf1b0bbb6e Mon Sep 17 00:00:00 2001 From: Matthew Powelson Date: Thu, 14 Aug 2025 11:50:16 -0400 Subject: [PATCH 5/5] Add missing dependency --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) 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]