Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions arm_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,20 @@
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


@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
Expand Down
56 changes: 56 additions & 0 deletions arm_cli/config.py
Original file line number Diff line number Diff line change
@@ -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)
23 changes: 16 additions & 7 deletions arm_cli/container/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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}...")

Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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}...")

Expand Down
21 changes: 20 additions & 1 deletion arm_cli/self/self.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import click
from click.core import ParameterSource

from arm_cli.config import save_config


@click.group()
def self():
Expand All @@ -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 = "."
Expand All @@ -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
Expand All @@ -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")
2 changes: 1 addition & 1 deletion arm_cli/system/setup_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
11 changes: 5 additions & 6 deletions arm_cli/system/system.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import subprocess

import click
import docker
import inquirer

from arm_cli.system.setup_utils import (
setup_data_directories,
Expand All @@ -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)
Expand All @@ -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
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
135 changes: 135 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -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 == ""