Skip to content

Commit 8f9fcac

Browse files
Add config management with project selection and configuration persistence
Co-authored-by: powelson.matthew <powelson.matthew@gmail.com>
1 parent 7f6964b commit 8f9fcac

8 files changed

Lines changed: 243 additions & 18 deletions

File tree

arm_cli/cli.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import click
22

3+
from arm_cli.config import load_config
34
from arm_cli.container.container import container
45
from arm_cli.self.self import self
56
from arm_cli.system.system import system
67

78

89
@click.version_option()
910
@click.group(context_settings=dict(help_option_names=["-h", "--help"]))
10-
def cli():
11+
@click.pass_context
12+
def cli(ctx):
1113
"""Experimental CLI for deploying robotic applications"""
12-
pass
14+
# Load config and store in context
15+
ctx.ensure_object(dict)
16+
ctx.obj["config"] = load_config()
1317

1418

1519
# Add command groups

arm_cli/config.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import json
2+
from pathlib import Path
3+
4+
import appdirs
5+
from pydantic import BaseModel
6+
7+
8+
class Config(BaseModel):
9+
"""Configuration schema for the CLI."""
10+
11+
active_project: str = ""
12+
13+
14+
def get_config_dir() -> Path:
15+
"""Get the configuration directory for the CLI."""
16+
config_dir = Path(appdirs.user_config_dir("mycli"))
17+
config_dir.mkdir(parents=True, exist_ok=True)
18+
return config_dir
19+
20+
21+
def get_config_file() -> Path:
22+
"""Get the path to the configuration file."""
23+
return get_config_dir() / "config.json"
24+
25+
26+
def load_config() -> Config:
27+
"""Load configuration from file, creating default if it doesn't exist."""
28+
config_file = get_config_file()
29+
30+
if not config_file.exists():
31+
# Create default config
32+
default_config = Config()
33+
save_config(default_config)
34+
return default_config
35+
36+
try:
37+
with open(config_file, "r") as f:
38+
data = json.load(f)
39+
return Config(**data)
40+
except (json.JSONDecodeError, KeyError, TypeError) as e:
41+
# If config is corrupted, create a new one
42+
print(f"Warning: Config file corrupted, creating new default config: " f"{e}")
43+
default_config = Config()
44+
save_config(default_config)
45+
return default_config
46+
47+
48+
def save_config(config: Config) -> None:
49+
"""Save configuration to file."""
50+
config_file = get_config_file()
51+
52+
# Ensure directory exists
53+
config_file.parent.mkdir(parents=True, exist_ok=True)
54+
55+
with open(config_file, "w") as f:
56+
json.dump(config.model_dump(), f, indent=2)

arm_cli/container/container.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77

88
@click.group()
99
def container():
10-
"""Basic tools for managing Docker containers. For more extensive tooling, try lazydocker"""
10+
"""Basic tools for managing Docker containers. For more extensive tooling,
11+
try lazydocker"""
1112
pass
1213

1314

@@ -18,8 +19,10 @@ def get_running_containers():
1819

1920

2021
@container.command("list")
21-
def list_containers():
22+
@click.pass_context
23+
def list_containers(ctx):
2224
"""List all Docker containers"""
25+
config = ctx.obj["config"] # noqa: F841 - config available for future use
2326
containers = get_running_containers()
2427

2528
if containers:
@@ -30,8 +33,10 @@ def list_containers():
3033

3134

3235
@container.command("attach")
33-
def attach_container():
36+
@click.pass_context
37+
def attach_container(ctx):
3438
"""Interactively select a running Docker container and attach to it"""
39+
config = ctx.obj["config"] # noqa: F841 - config available for future use
3540
containers = get_running_containers()
3641

3742
if not containers:
@@ -48,7 +53,7 @@ def attach_container():
4853
]
4954

5055
answers = inquirer.prompt(container_choices)
51-
selected_container_name = answers["container"].split(" ")[0] # Extract container name
56+
selected_container_name = answers["container"].split(" ")[0] # Extract name
5257

5358
print(f"Attaching to {selected_container_name}...")
5459

@@ -61,8 +66,10 @@ def attach_container():
6166

6267

6368
@container.command("restart")
64-
def restart_container():
69+
@click.pass_context
70+
def restart_container(ctx):
6571
"""Interactively select a running Docker container and restart it"""
72+
config = ctx.obj["config"] # noqa: F841 - config available for future use
6673
containers = get_running_containers()
6774

6875
if not containers:
@@ -99,8 +106,10 @@ def restart_container():
99106

100107

101108
@container.command("stop")
102-
def stop_container():
109+
@click.pass_context
110+
def stop_container(ctx):
103111
"""Interactively select a running Docker container and stop it"""
112+
config = ctx.obj["config"] # noqa: F841 - config available for future use
104113
containers = get_running_containers()
105114

106115
if not containers:
@@ -121,7 +130,7 @@ def stop_container():
121130
print("No container selected.")
122131
return
123132

124-
selected_container_name = answers["container"].split(" ")[0] # Extract container name
133+
selected_container_name = answers["container"].split(" ")[0] # Extract name
125134

126135
print(f"Stopping {selected_container_name}...")
127136

arm_cli/self/self.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
import click
66

7+
from arm_cli.config import save_config
8+
79

810
@click.group()
911
def self():
@@ -20,14 +22,16 @@ def self():
2022
show_default=True,
2123
)
2224
@click.option("-f", "--force", is_flag=True, help="Skip confirmation prompts")
23-
def update(source, force):
25+
@click.pass_context
26+
def update(ctx, source, force):
27+
config = ctx.obj["config"] # noqa: F841 - config available for future use
2428
"""Update arm-cli from PyPI or source"""
2529
if source:
2630
print(f"Installing arm-cli from source at {source}...")
2731

2832
if not force:
2933
if not click.confirm(
30-
"Do you want to install arm-cli from source? This will clear pip cache."
34+
"Do you want to install arm-cli from source? This will clear pip " "cache."
3135
):
3236
print("Update cancelled.")
3337
return
@@ -50,3 +54,19 @@ def update(source, force):
5054

5155
subprocess.run([sys.executable, "-m", "pip", "install", "--upgrade", "arm-cli"], check=True)
5256
print("arm-cli updated successfully!")
57+
58+
59+
@self.command()
60+
@click.option("--project", help="Set the active project")
61+
@click.pass_context
62+
def config(ctx, project):
63+
"""Manage CLI configuration"""
64+
config = ctx.obj["config"]
65+
66+
if project is not None:
67+
config.active_project = project
68+
save_config(config)
69+
print(f"Active project set to: {project}")
70+
else:
71+
print(f"Active project: {config.active_project}")
72+
print("Use --project to set a new active project")

arm_cli/system/setup_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ def setup_data_directories(force=False):
139139
def is_line_in_file(line, filepath) -> bool:
140140
"""Checks if a line is already in a file"""
141141
with open(filepath, "r") as f:
142-
return any(line.strip() in l.strip() for l in f)
142+
return any(line.strip() in file_line.strip() for file_line in f)
143143

144144

145145
def setup_shell(force=False):

arm_cli/system/system.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
import subprocess
2-
31
import click
4-
import docker
5-
import inquirer
62

73
from arm_cli.system.setup_utils import setup_data_directories, setup_shell, setup_xhost
84

@@ -15,7 +11,9 @@ def system():
1511

1612
@system.command()
1713
@click.option("-f", "--force", is_flag=True, help="Skip confirmation prompts")
18-
def setup(force):
14+
@click.pass_context
15+
def setup(ctx, force):
16+
config = ctx.obj["config"] # noqa: F841 - config available for future use
1917
"""Generic setup (will be refined later)"""
2018

2119
setup_xhost(force=force)
@@ -27,5 +25,6 @@ def setup(force):
2725
print("Data directory setup was not completed.")
2826
print("You can run this setup again later with: arm-cli system setup")
2927

30-
# Additional setup code can go here (e.g., starting containers, attaching, etc.)
28+
# Additional setup code can go here (e.g., starting containers,
29+
# attaching, etc.)
3130
pass

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ include = ["arm_cli/system/shell_scripts/*"]
1111
[tool.poetry.dependencies]
1212
python = "^3.8"
1313
setuptools = ">=69.0.0"
14+
appdirs = "*"
1415
beartype = "*"
1516
click = "*"
1617
click-completion = "*"
1718
docker = "*"
1819
inquirer = "*"
20+
pydantic = "*"
1921

2022
[tool.poetry.group.dev.dependencies]
2123
black = "24.8.0"

tests/test_config.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import json
2+
import tempfile
3+
from pathlib import Path
4+
from unittest.mock import patch
5+
6+
from arm_cli.config import Config, get_config_dir, get_config_file, load_config, save_config
7+
8+
9+
class TestConfig:
10+
def test_config_default_values(self):
11+
"""Test that Config has correct default values."""
12+
config = Config()
13+
assert config.active_project == ""
14+
15+
def test_config_with_values(self):
16+
"""Test that Config can be created with custom values."""
17+
config = Config(active_project="test-project")
18+
assert config.active_project == "test-project"
19+
20+
def test_config_model_dump(self):
21+
"""Test that Config can be serialized to dict."""
22+
config = Config(active_project="test-project")
23+
data = config.model_dump()
24+
assert data == {"active_project": "test-project"}
25+
26+
27+
class TestConfigFunctions:
28+
def test_get_config_dir(self):
29+
"""Test that config directory is created correctly."""
30+
with patch("arm_cli.config.appdirs.user_config_dir") as mock_user_config_dir:
31+
mock_user_config_dir.return_value = "/tmp/test_config"
32+
33+
config_dir = get_config_dir()
34+
35+
assert config_dir == Path("/tmp/test_config")
36+
mock_user_config_dir.assert_called_once_with("mycli")
37+
38+
def test_get_config_file(self):
39+
"""Test that config file path is correct."""
40+
with patch("arm_cli.config.get_config_dir") as mock_get_config_dir:
41+
mock_get_config_dir.return_value = Path("/tmp/test_config")
42+
43+
config_file = get_config_file()
44+
45+
assert config_file == Path("/tmp/test_config/config.json")
46+
47+
def test_save_config(self):
48+
"""Test that config can be saved to file."""
49+
with tempfile.TemporaryDirectory() as temp_dir:
50+
with patch("arm_cli.config.get_config_file") as mock_get_config_file:
51+
config_file = Path(temp_dir) / "config.json"
52+
mock_get_config_file.return_value = config_file
53+
54+
config = Config(active_project="test-project")
55+
save_config(config)
56+
57+
assert config_file.exists()
58+
with open(config_file, "r") as f:
59+
data = json.load(f)
60+
61+
assert data == {"active_project": "test-project"}
62+
63+
def test_load_config_new_file(self):
64+
"""Test that new config file is created when it doesn't exist."""
65+
with tempfile.TemporaryDirectory() as temp_dir:
66+
with patch("arm_cli.config.get_config_file") as mock_get_config_file:
67+
config_file = Path(temp_dir) / "config.json"
68+
mock_get_config_file.return_value = config_file
69+
70+
# File doesn't exist initially
71+
assert not config_file.exists()
72+
73+
config = load_config()
74+
75+
# File should be created with default values
76+
assert config_file.exists()
77+
assert config.active_project == ""
78+
79+
# Verify file contents
80+
with open(config_file, "r") as f:
81+
data = json.load(f)
82+
assert data == {"active_project": ""}
83+
84+
def test_load_config_existing_file(self):
85+
"""Test that existing config file is loaded correctly."""
86+
with tempfile.TemporaryDirectory() as temp_dir:
87+
with patch("arm_cli.config.get_config_file") as mock_get_config_file:
88+
config_file = Path(temp_dir) / "config.json"
89+
mock_get_config_file.return_value = config_file
90+
91+
# Create existing config file
92+
existing_data = {"active_project": "existing-project"}
93+
with open(config_file, "w") as f:
94+
json.dump(existing_data, f)
95+
96+
config = load_config()
97+
98+
assert config.active_project == "existing-project"
99+
100+
def test_load_config_corrupted_file(self):
101+
"""Test that corrupted config file is handled gracefully."""
102+
with tempfile.TemporaryDirectory() as temp_dir:
103+
with patch("arm_cli.config.get_config_file") as mock_get_config_file:
104+
config_file = Path(temp_dir) / "config.json"
105+
mock_get_config_file.return_value = config_file
106+
107+
# Create corrupted config file
108+
with open(config_file, "w") as f:
109+
f.write("invalid json content")
110+
111+
config = load_config()
112+
113+
# Should create new default config
114+
assert config.active_project == ""
115+
116+
# Verify file was overwritten with valid JSON
117+
with open(config_file, "r") as f:
118+
data = json.load(f)
119+
assert data == {"active_project": ""}
120+
121+
def test_load_config_missing_fields(self):
122+
"""Test that config with missing fields is handled gracefully."""
123+
with tempfile.TemporaryDirectory() as temp_dir:
124+
with patch("arm_cli.config.get_config_file") as mock_get_config_file:
125+
config_file = Path(temp_dir) / "config.json"
126+
mock_get_config_file.return_value = config_file
127+
128+
# Create config file with missing fields
129+
with open(config_file, "w") as f:
130+
json.dump({}, f)
131+
132+
config = load_config()
133+
134+
# Should use default values for missing fields
135+
assert config.active_project == ""

0 commit comments

Comments
 (0)