diff --git a/arm_cli/cli.py b/arm_cli/cli.py index 2da5b6a..04c1511 100644 --- a/arm_cli/cli.py +++ b/arm_cli/cli.py @@ -8,6 +8,7 @@ from arm_cli import __version__ from arm_cli.config import load_config from arm_cli.container.container import container +from arm_cli.projects.projects import projects from arm_cli.self.self import self from arm_cli.system.system import system @@ -24,6 +25,7 @@ def cli(ctx): # Add command groups cli.add_command(container) +cli.add_command(projects) cli.add_command(self) cli.add_command(system) diff --git a/arm_cli/config.py b/arm_cli/config.py index 65f70a9..f51c39a 100644 --- a/arm_cli/config.py +++ b/arm_cli/config.py @@ -1,14 +1,34 @@ import json +import shutil from pathlib import Path +from typing import Dict, List, Optional import appdirs from pydantic import BaseModel +class ProjectConfig(BaseModel): + """Configuration schema for individual projects.""" + + name: str + description: Optional[str] = None + project_directory: Optional[str] = None + docker_compose_file: Optional[str] = None + data_directory: Optional[str] = None + + +class AvailableProject(BaseModel): + """Schema for available project entry.""" + + name: str + path: str + + class Config(BaseModel): """Configuration schema for the CLI.""" active_project: str = "" + available_projects: List[AvailableProject] = [] def get_config_dir() -> Path: @@ -23,6 +43,134 @@ def get_config_file() -> Path: return get_config_dir() / "config.json" +def get_default_project_config_path() -> Path: + """Get the path to the default project configuration in the repository.""" + # First try to find it relative to the current file (development) + current_file = Path(__file__) + dev_path = current_file.parent.parent / "resources" / "default_project_config.json" + + if dev_path.exists(): + return dev_path + + # If not found in development, try to find it in the installed package + try: + import arm_cli + + package_dir = Path(arm_cli.__file__).parent + # When installed, the resources directory is at the root level of the package + installed_path = package_dir.parent / "resources" / "default_project_config.json" + + if installed_path.exists(): + return installed_path + except ImportError: + pass + + # Fallback: try to find it in the current working directory + cwd_path = Path.cwd() / "resources" / "default_project_config.json" + if cwd_path.exists(): + return cwd_path + + raise FileNotFoundError("Could not find default_project_config.json in any expected location") + + +def copy_default_project_config() -> Path: + """Copy the default project config from repository to user config directory.""" + config_dir = get_config_dir() + default_config_path = get_default_project_config_path() + user_config_path = config_dir / "default_project_config.json" + + if not default_config_path.exists(): + raise FileNotFoundError(f"Default project config not found at {default_config_path}") + + shutil.copy2(default_config_path, user_config_path) + return user_config_path + + +def load_project_config(project_path: str) -> ProjectConfig: + """Load a project configuration from file.""" + config_path = Path(project_path) + + # If it's a relative path, make it relative to the config directory + if not config_path.is_absolute(): + config_path = get_config_dir() / config_path + + if not config_path.exists(): + raise FileNotFoundError(f"Project config not found at {config_path}") + + with open(config_path, "r") as f: + data = json.load(f) + return ProjectConfig(**data) + + +def add_project_to_list(config: Config, project_path: str, project_name: str) -> None: + """Add a project to the available projects list and set as active.""" + # Remove existing entry if it exists + config.available_projects = [p for p in config.available_projects if p.path != project_path] + + # Add new entry + project_entry = AvailableProject(name=project_name, path=project_path) + config.available_projects.append(project_entry) + + # Set as active project + config.active_project = project_path + + +def get_available_projects(config: Config) -> List[AvailableProject]: + """Get the list of available projects.""" + # If no projects are available, ensure the default project is added + if not config.available_projects: + try: + default_path = copy_default_project_config() + default_project_config = load_project_config(str(default_path)) + add_project_to_list(config, str(default_path), default_project_config.name) + except Exception as e: + print(f"Error setting up default project: {e}") + + return config.available_projects + + +def activate_project(config: Config, project_identifier: str) -> Optional[ProjectConfig]: + """Activate a project by path or name.""" + # First try to find by exact path + for project in config.available_projects: + if project.path == project_identifier: + config.active_project = project.path + save_config(config) + return load_project_config(project.path) + + # Try to find by name + for project in config.available_projects: + if project.name.lower() == project_identifier.lower(): + config.active_project = project.path + save_config(config) + return load_project_config(project.path) + + return None + + +def remove_project_from_list(config: Config, project_identifier: str) -> bool: + """Remove a project from the available projects list by path or name.""" + # First try to find by exact path + for project in config.available_projects: + if project.path == project_identifier: + # If this is the active project, clear the active project + if config.active_project == project.path: + config.active_project = "" + config.available_projects.remove(project) + return True + + # Try to find by name + for project in config.available_projects: + if project.name.lower() == project_identifier.lower(): + # If this is the active project, clear the active project + if config.active_project == project.path: + config.active_project = "" + config.available_projects.remove(project) + return True + + return False + + def load_config() -> Config: """Load configuration from file, creating default if it doesn't exist.""" config_file = get_config_file() @@ -39,7 +187,7 @@ def load_config() -> Config: 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}") + print(f"Warning: Config file corrupted, creating new default config: {e}") default_config = Config() save_config(default_config) return default_config @@ -54,3 +202,46 @@ def save_config(config: Config) -> None: with open(config_file, "w") as f: json.dump(config.model_dump(), f, indent=2) + + +def get_active_project_config(config: Config) -> Optional[ProjectConfig]: + """Get the active project configuration.""" + if not config.active_project: + # No active project set, copy default and set it + try: + default_path = copy_default_project_config() + config.active_project = str(default_path) + + # Also add the default project to available projects if not already there + default_project_config = load_project_config(str(default_path)) + add_project_to_list(config, str(default_path), default_project_config.name) + + return default_project_config + except Exception as e: + print(f"Error setting up default project config: {e}") + return None + + try: + return load_project_config(config.active_project) + except FileNotFoundError: + print(f"Active project config not found at {config.active_project}") + return None + except IsADirectoryError: + print(f"Active project config path is a directory: {config.active_project}") + return None + + +def print_no_projects_message() -> None: + """Print the standard message when no projects are available.""" + print("No projects available. Use 'arm-cli projects init ' to add a project.") + + +def print_available_projects(config: Config) -> None: + """Print the list of available projects.""" + available_projects = get_available_projects(config) + if available_projects: + print("Available projects:") + for i, proj in enumerate(available_projects, 1): + print(f" {i}. {proj.name} ({proj.path})") + else: + print_no_projects_message() diff --git a/arm_cli/container/container.py b/arm_cli/container/container.py index c1180cf..eac6fde 100644 --- a/arm_cli/container/container.py +++ b/arm_cli/container/container.py @@ -8,7 +8,7 @@ @click.group() def container(): """Basic tools for managing Docker containers. For more extensive tooling, - try lazydocker""" + try lazydocker (brew install jesseduffield/lazydocker/lazydocker)""" pass diff --git a/arm_cli/projects/__init__.py b/arm_cli/projects/__init__.py new file mode 100644 index 0000000..6568b84 --- /dev/null +++ b/arm_cli/projects/__init__.py @@ -0,0 +1,3 @@ +# Projects module for ARM CLI + +from . import activate, info, init, list, remove diff --git a/arm_cli/projects/activate.py b/arm_cli/projects/activate.py new file mode 100644 index 0000000..242ca91 --- /dev/null +++ b/arm_cli/projects/activate.py @@ -0,0 +1,77 @@ +from typing import Optional + +import click +import inquirer + +from arm_cli.config import ( + activate_project, + get_active_project_config, + get_available_projects, + print_available_projects, + print_no_projects_message, +) + + +def _activate(ctx, project: Optional[str] = None): + """Activate a project from available projects""" + config = ctx.obj["config"] + + # If no project specified, show interactive list + if project is None: + available_projects = get_available_projects(config) + + if not available_projects: + print("No projects available. Setting up default project...") + project_config = get_active_project_config(config) + if project_config: + print(f"Activated default project: {project_config.name}") + print(f"Project directory: {project_config.project_directory}") + else: + print("Failed to set up default project.") + print_no_projects_message() + return + + # Create choices for inquirer + choices = [] + for proj in available_projects: + active_indicator = " *" if proj.path == config.active_project else "" + choices.append(f"{proj.name}{active_indicator}") + + # Create the question + questions = [ + inquirer.List( + "project", message="Select a project to activate", choices=choices, carousel=True + ) + ] + + try: + answers = inquirer.prompt(questions) + if answers is None: + print("Cancelled.") + return + + # Extract project name (remove the active indicator if present) + selected_choice = answers["project"] + project = selected_choice.replace(" *", "") + + except KeyboardInterrupt: + print("\nCancelled.") + return + + # Try to activate the project + # At this point, project is guaranteed to be a string + assert project is not None # type guard + project_config = activate_project(config, project) + + if project_config: + print(f"Activated project: {project_config.name}") + print(f"Project directory: {project_config.project_directory}") + else: + print(f"Project '{project}' not found in available projects") + print_available_projects(config) + + +# Create the command object +activate = click.command(name="activate")( + click.argument("project", required=False)(click.pass_context(_activate)) +) diff --git a/arm_cli/projects/info.py b/arm_cli/projects/info.py new file mode 100644 index 0000000..96f1877 --- /dev/null +++ b/arm_cli/projects/info.py @@ -0,0 +1,57 @@ +import click + +from arm_cli.config import get_active_project_config + + +def _info(ctx, field): + """Show information about the active project""" + config = ctx.obj["config"] + + # Get the active project configuration + project_config = get_active_project_config(config) + if not project_config: + print("No active project configured.") + return + + # If --field is specified, extract and print only that field + if field: + # Convert field name to attribute name (e.g., "project_directory" -> project_directory) + field = field.lower().replace(" ", "_") + + # Get all public attributes (not starting with _) + available_attrs = [ + attr + for attr in dir(project_config) + if not attr.startswith("_") and not callable(getattr(project_config, attr)) + ] + + if field in available_attrs: + value = getattr(project_config, field) + if value: + print(value) + else: + print("", end="") # Print empty string for empty values + else: + print(f"Unknown field: {field}", file=click.get_text_stream("stderr")) + print( + f"Available fields: {', '.join(available_attrs)}", + file=click.get_text_stream("stderr"), + ) + return + else: + # Print all fields as before + print(f"Active Project: {project_config.name}") + if project_config.description: + print(f"Description: {project_config.description}") + if project_config.project_directory: + print(f"Project Directory: {project_config.project_directory}") + if project_config.docker_compose_file: + print(f"Docker Compose File: {project_config.docker_compose_file}") + if project_config.data_directory: + print(f"Data Directory: {project_config.data_directory}") + + +# Create the command object +info = click.command(name="info")( + click.option("--field", help="Extract a specific field value")(click.pass_context(_info)) +) diff --git a/arm_cli/projects/init.py b/arm_cli/projects/init.py new file mode 100644 index 0000000..842c4b1 --- /dev/null +++ b/arm_cli/projects/init.py @@ -0,0 +1,70 @@ +import sys +from pathlib import Path +from typing import Optional + +import click + +from arm_cli.config import ( + add_project_to_list, + copy_default_project_config, + load_project_config, + save_config, +) + + +def _init(ctx, project_path: str, name: Optional[str] = None): + """Initialize a new project from an existing directory""" + config = ctx.obj["config"] + + project_path_obj = Path(project_path).resolve() + + if name is None: + name = project_path_obj.name + + # Check if project config already exists + project_config_file = project_path_obj / "project_config.json" + + if project_config_file.exists(): + print(f"Project config already exists at {project_config_file}") + print("Loading existing project configuration...") + project_config = load_project_config(str(project_config_file)) + else: + # Copy default config and customize it + try: + default_config_path = copy_default_project_config() + with open(default_config_path, "r") as f: + import json + + default_data = json.load(f) + + # Update with project-specific information + default_data["name"] = name + default_data["project_directory"] = str(project_path_obj) + + # Save the new project config + with open(project_config_file, "w") as f: + json.dump(default_data, f, indent=2) + + project_config = load_project_config(str(project_config_file)) + print(f"Created new project configuration at {project_config_file}") + + except Exception as e: + print(f"Error creating project configuration: {e}") + sys.exit(1) + + # Add to available projects and set as active + add_project_to_list(config, str(project_config_file), project_config.name) + save_config(config) + + print(f"Project '{project_config.name}' initialized and set as active") + print(f"Project directory: {project_config.project_directory}") + + +# Create the command object +init = click.command(name="init")( + click.argument("project_path", type=click.Path(exists=True, file_okay=False, dir_okay=True))( + click.option("--name", help="Name for the project (defaults to directory name)")( + click.pass_context(_init) + ) + ) +) diff --git a/arm_cli/projects/list.py b/arm_cli/projects/list.py new file mode 100644 index 0000000..286fbb3 --- /dev/null +++ b/arm_cli/projects/list.py @@ -0,0 +1,24 @@ +import click + +from arm_cli.config import get_available_projects, print_no_projects_message + + +def _list(ctx): + """List all available projects""" + config = ctx.obj["config"] + available_projects = get_available_projects(config) + + if not available_projects: + print_no_projects_message() + return + + print("Available Projects:") + for i, project in enumerate(available_projects, 1): + active_indicator = " *" if project.path == config.active_project else "" + print(f" {i}. {project.name}{active_indicator}") + print(f" Path: {project.path}") + print() + + +# Create the command object +list = click.command(name="ls")(click.pass_context(_list)) diff --git a/arm_cli/projects/projects.py b/arm_cli/projects/projects.py new file mode 100644 index 0000000..dc0370b --- /dev/null +++ b/arm_cli/projects/projects.py @@ -0,0 +1,29 @@ +import click + +# Import the modules and access the command objects +import arm_cli.projects.activate +import arm_cli.projects.info +import arm_cli.projects.init +import arm_cli.projects.list +import arm_cli.projects.remove + +# Get the command objects +activate = arm_cli.projects.activate.activate +info = arm_cli.projects.info.info +init = arm_cli.projects.init.init +ls_cmd = arm_cli.projects.list.list +remove = arm_cli.projects.remove.remove + + +@click.group() +def projects(): + """Manage ARM projects""" + pass + + +# Register all project commands +projects.add_command(init) +projects.add_command(activate) +projects.add_command(ls_cmd) +projects.add_command(info) +projects.add_command(remove) diff --git a/arm_cli/projects/remove.py b/arm_cli/projects/remove.py new file mode 100644 index 0000000..d8d289f --- /dev/null +++ b/arm_cli/projects/remove.py @@ -0,0 +1,116 @@ +from typing import Optional + +import click +import inquirer + +from arm_cli.config import ( + get_available_projects, + print_available_projects, + print_no_projects_message, + remove_project_from_list, + save_config, +) + + +def _remove(ctx, project: Optional[str] = None): + """Remove a project from available projects""" + config = ctx.obj["config"] + + # If no project specified, show interactive list + if project is None: + available_projects = get_available_projects(config) + + if not available_projects: + print("No projects available to remove.") + return + + # Create choices for inquirer + choices = [] + for proj in available_projects: + active_indicator = " *" if proj.path == config.active_project else "" + choices.append(f"{proj.name}{active_indicator}") + + # Create the question + questions = [ + inquirer.List( + "project", message="Select a project to remove", choices=choices, carousel=True + ) + ] + + try: + answers = inquirer.prompt(questions) + if answers is None: + print("Cancelled.") + return + + # Extract project name (remove the active indicator if present) + selected_choice = answers["project"] + project = selected_choice.replace(" *", "") + + except KeyboardInterrupt: + print("\nCancelled.") + return + + # Try to remove the project + # At this point, project is guaranteed to be a string + assert project is not None # type guard + + # Check if this is the active project + available_projects = get_available_projects(config) + is_active = False + for proj in available_projects: + if proj.name.lower() == project.lower(): + is_active = proj.path == config.active_project + break + + # Confirm removal, especially for active project + if is_active: + confirm_questions = [ + inquirer.Confirm( + "confirm", + message=f"Are you sure you want to remove the active project '{project}'? This will clear the active project.", + default=False, + ) + ] + + try: + confirm_answers = inquirer.prompt(confirm_questions) + if confirm_answers is None or not confirm_answers["confirm"]: + print("Removal cancelled.") + return + except KeyboardInterrupt: + print("\nCancelled.") + return + else: + confirm_questions = [ + inquirer.Confirm( + "confirm", + message=f"Are you sure you want to remove project '{project}'?", + default=False, + ) + ] + + try: + confirm_answers = inquirer.prompt(confirm_questions) + if confirm_answers is None or not confirm_answers["confirm"]: + print("Removal cancelled.") + return + except KeyboardInterrupt: + print("\nCancelled.") + return + + # Remove the project + if remove_project_from_list(config, project): + save_config(config) + print(f"Removed project: {project}") + if is_active: + print("Active project has been cleared.") + else: + print(f"Project '{project}' not found in available projects") + print_available_projects(config) + + +# Create the command object +remove = click.command(name="remove")( + click.argument("project", required=False)(click.pass_context(_remove)) +) diff --git a/arm_cli/self/self.py b/arm_cli/self/self.py index 5d97954..44790af 100644 --- a/arm_cli/self/self.py +++ b/arm_cli/self/self.py @@ -1,11 +1,17 @@ import os import subprocess import sys +from pathlib import Path import click from click.core import ParameterSource -from arm_cli.config import save_config +from arm_cli.config import ( + get_active_project_config, + get_config_dir, + load_project_config, + save_config, +) @click.group() @@ -60,16 +66,78 @@ def update(ctx, source, force): @self.command() -@click.option("--project", help="Set the active project") +@click.option("--project", help="Set the active project config file") +@click.option("--list-projects", is_flag=True, help="List all available project config files") +@click.option("--show-project", is_flag=True, help="Show current project configuration") @click.pass_context -def config(ctx, project): +def config(ctx, project, list_projects, show_project): """Manage CLI configuration""" config = ctx.obj["config"] + if list_projects: + config_dir = get_config_dir() + print("Available project config files:") + for config_file in config_dir.glob("*.json"): + if config_file.name != "config.json": # Skip the main config file + active_marker = " (active)" if str(config_file) == config.active_project else "" + print(f" {config_file.name}{active_marker}") + + # Try to load and show description + try: + project_config = load_project_config(str(config_file)) + if project_config.description: + print(f" Description: {project_config.description}") + except Exception: + print(f" (Could not load config)") + return + + if show_project: + project_config = get_active_project_config(config) + if project_config: + print(f"Active project: {project_config.name}") + print(f"Config file: {config.active_project}") + print(f"Description: {project_config.description or 'No description'}") + print(f"Docker compose file: {project_config.docker_compose_file or 'Not specified'}") + print(f"Data directory: {project_config.data_directory or 'Not specified'}") + + if project_config.resources: + print("Resources:") + for resource_name, resource_path in project_config.resources.items(): + print(f" {resource_name}: {resource_path}") + + if project_config.skills: + print("Skills:") + for skill_name, skill_path in project_config.skills.items(): + print(f" {skill_name}: {skill_path}") + + if project_config.monitoring: + print("Monitoring:") + for service_name, service_url in project_config.monitoring.items(): + print(f" {service_name}: {service_url}") + else: + print("No active project configuration found.") + return + if project is not None: - config.active_project = project + # Check if the project file exists + project_path = Path(project) + if not project_path.is_absolute(): + project_path = get_config_dir() / project_path + + if not project_path.exists(): + print(f"Error: Project config file '{project}' not found.") + print("Available project config files:") + config_dir = get_config_dir() + for config_file in config_dir.glob("*.json"): + if config_file.name != "config.json": + print(f" {config_file.name}") + return + + config.active_project = str(project_path) save_config(config) print(f"Active project set to: {project}") else: - print(f"Active project: {config.active_project}") + print(f"Active project: {config.active_project or 'None (will use default)'}") print("Use --project to set a new active project") + print("Use --list-projects to see all available project config files") + print("Use --show-project to see current project configuration") diff --git a/arm_cli/system/setup_utils.py b/arm_cli/system/setup_utils.py index 6663bfd..3c3be35 100644 --- a/arm_cli/system/setup_utils.py +++ b/arm_cli/system/setup_utils.py @@ -94,9 +94,13 @@ def check_sudo_privileges(): return False -def check_data_directories_setup(): +def check_data_directories_setup(data_directory="/DATA"): """Check if data directories are already properly set up""" - data_dirs = ["/DATA/influxdb2", "/DATA/images", "/DATA/node_exporter"] + data_dirs = [ + os.path.join(data_directory, "influxdb2"), + os.path.join(data_directory, "images"), + os.path.join(data_directory, "node_exporter"), + ] current_uid = os.getuid() current_gid = os.getgid() @@ -122,11 +126,11 @@ def check_data_directories_setup(): return True -def setup_data_directories(force=False): +def setup_data_directories(force=False, data_directory="/DATA"): """Setup data directories for the ARM system""" try: # Check if directories are already properly set up - if check_data_directories_setup(): + if check_data_directories_setup(data_directory): print("Data directories are already properly set up.") return True @@ -140,7 +144,11 @@ def setup_data_directories(force=False): # Ask user for confirmation print("This will create the following directories:") - data_dirs = ["/DATA/influxdb2", "/DATA/images", "/DATA/node_exporter"] + data_dirs = [ + os.path.join(data_directory, "influxdb2"), + os.path.join(data_directory, "images"), + os.path.join(data_directory, "node_exporter"), + ] for directory in data_dirs: print(f" - {directory}") print("And set appropriate ownership and permissions.") diff --git a/arm_cli/system/shell_scripts/shell_addins.sh b/arm_cli/system/shell_scripts/shell_addins.sh index 100fa25..da032cc 100644 --- a/arm_cli/system/shell_scripts/shell_addins.sh +++ b/arm_cli/system/shell_scripts/shell_addins.sh @@ -36,6 +36,9 @@ setup_alias() { if [[ $- == *i* ]]; then # Only define alias in interactive shells alias "$alias_name"="$cli_path" complete -o default -F _arm_cli_completion "$alias_name" 2>/dev/null || true + + # Add cdp alias to change to project directory + alias cdp='cd "$(arm-cli projects info --field "project_directory" | sed "s|^~|$HOME|")"' fi fi } diff --git a/arm_cli/system/system.py b/arm_cli/system/system.py index ff8f272..8c72822 100644 --- a/arm_cli/system/system.py +++ b/arm_cli/system/system.py @@ -1,5 +1,6 @@ import click +from arm_cli.config import get_active_project_config from arm_cli.system.setup_utils import ( setup_data_directories, setup_docker_group, @@ -18,9 +19,18 @@ def system(): @click.option("-f", "--force", is_flag=True, help="Skip confirmation prompts") @click.pass_context def setup(ctx, force): - config = ctx.obj["config"] # noqa: F841 - config available for future use + config = ctx.obj["config"] """Generic setup (will be refined later)""" + # Load project configuration + project_config = get_active_project_config(config) + if project_config: + print(f"Setting up system for project: {project_config.name}") + if project_config.description: + print(f"Description: {project_config.description}") + else: + print("No active project configuration found. Using default settings.") + setup_xhost(force=force) setup_shell(force=force) @@ -31,7 +41,11 @@ def setup(ctx, force): print("You can run this setup again later with: arm-cli system setup") # Setup data directories (may require sudo) - if not setup_data_directories(force=force): + data_directory = "/DATA" # Default fallback + if project_config and project_config.data_directory: + data_directory = project_config.data_directory + + if not setup_data_directories(force=force, data_directory=data_directory): print("Data directory setup was not completed.") print("You can run this setup again later with: arm-cli system setup") diff --git a/feature_roadmap.md b/feature_roadmap.md index 15a1172..8af6b7f 100644 --- a/feature_roadmap.md +++ b/feature_roadmap.md @@ -1,6 +1,6 @@ Probably mostly in order -1) Global config - store active session info -2) Project config - store path for `cdc`, data dirs, etc +1) Fully flesh out the project configs and how that plays into system setup +2) Add `arm-cli system install-tools` for easy configuration of new machines 3) Add additional tab completes 4) Additional integration tests on project setup 5) Deployment tools - possibly using ansible or maybe save that for later diff --git a/pyproject.toml b/pyproject.toml index bd64f08..761dff0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ include = ["arm_cli*"] [tool.setuptools.package-data] "arm_cli.system.shell_scripts" = ["*.sh", "*.zsh", "*.fish"] +"arm_cli" = ["../resources/*.json"] [tool.setuptools_scm] write_to = "arm_cli/_version.py" diff --git a/resources/default_project_config.json b/resources/default_project_config.json new file mode 100644 index 0000000..ce1cc4c --- /dev/null +++ b/resources/default_project_config.json @@ -0,0 +1,7 @@ +{ + "name": "default", + "description": "Default ARM project configuration", + "docker_compose_file": "docker-compose.yml", + "data_directory": "/DATA", + "project_directory": "~" +} diff --git a/tests/test_config.py b/tests/test_config.py index 983eb3b..a204c6a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,7 +3,15 @@ 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 +from arm_cli.config import ( + Config, + ProjectConfig, + get_config_dir, + get_config_file, + load_config, + load_project_config, + save_config, +) class TestConfig: @@ -21,7 +29,7 @@ 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"} + assert data == {"active_project": "test-project", "available_projects": []} class TestConfigFunctions: @@ -58,7 +66,7 @@ def test_save_config(self): with open(config_file, "r") as f: data = json.load(f) - assert data == {"active_project": "test-project"} + assert data == {"active_project": "test-project", "available_projects": []} def test_load_config_new_file(self): """Test that new config file is created when it doesn't exist.""" @@ -79,7 +87,7 @@ def test_load_config_new_file(self): # Verify file contents with open(config_file, "r") as f: data = json.load(f) - assert data == {"active_project": ""} + assert data == {"active_project": "", "available_projects": []} def test_load_config_existing_file(self): """Test that existing config file is loaded correctly.""" @@ -116,7 +124,7 @@ def test_load_config_corrupted_file(self): # Verify file was overwritten with valid JSON with open(config_file, "r") as f: data = json.load(f) - assert data == {"active_project": ""} + assert data == {"active_project": "", "available_projects": []} def test_load_config_missing_fields(self): """Test that config with missing fields is handled gracefully.""" @@ -133,3 +141,68 @@ def test_load_config_missing_fields(self): # Should use default values for missing fields assert config.active_project == "" + + +class TestProjectConfig: + def test_project_config_default_values(self): + """Test that ProjectConfig has correct default values.""" + config = ProjectConfig(name="test-project") + assert config.name == "test-project" + assert config.description is None + assert config.project_directory is None + assert config.docker_compose_file is None + assert config.data_directory is None + + def test_project_config_with_values(self): + """Test that ProjectConfig can be created with custom values.""" + config = ProjectConfig( + name="test-project", + description="Test project", + project_directory="/tmp/project", + docker_compose_file="docker-compose.yml", + data_directory="/DATA", + ) + assert config.name == "test-project" + assert config.description == "Test project" + assert config.project_directory == "/tmp/project" + assert config.docker_compose_file == "docker-compose.yml" + assert config.data_directory == "/DATA" + + def test_project_config_model_dump(self): + """Test that ProjectConfig can be serialized to dict.""" + config = ProjectConfig( + name="test-project", description="Test project", project_directory="/tmp/project" + ) + data = config.model_dump() + expected = { + "name": "test-project", + "description": "Test project", + "project_directory": "/tmp/project", + "docker_compose_file": None, + "data_directory": None, + } + assert data == expected + + def test_load_default_project_config(self): + """Test that the actual default project config JSON can be loaded without Pydantic errors.""" + from arm_cli.config import get_default_project_config_path + + # Load the actual default config file + config_path = get_default_project_config_path() + + # Load the JSON data + with open(config_path, "r") as f: + data = json.load(f) + + # Create a temporary model with extra="forbid" to catch any schema mismatches + # If someone modifies the JSON and forgets to update the Pydantic model, this will fail + from pydantic import ConfigDict + + class StrictProjectConfig(ProjectConfig): + model_config = ConfigDict(extra="forbid") + + project_config = StrictProjectConfig.model_validate(data) + + # Just verify it loaded successfully (the JSON is the source of truth for values) + assert isinstance(project_config, ProjectConfig) + assert project_config.name is not None diff --git a/tests/unit/projects/test_projects.py b/tests/unit/projects/test_projects.py new file mode 100644 index 0000000..6c2d6ac --- /dev/null +++ b/tests/unit/projects/test_projects.py @@ -0,0 +1,210 @@ +import json +import tempfile +from pathlib import Path +from unittest.mock import patch + +import click +import pytest +from click.testing import CliRunner + +from arm_cli.config import Config, ProjectConfig +from arm_cli.projects.projects import projects + + +@pytest.fixture +def runner(): + """Fixture to provide a CliRunner instance.""" + return CliRunner() + + +@pytest.fixture +def temp_project_config(): + """Create a temporary project configuration.""" + config_data = { + "name": "test-project", + "description": "Test project for unit tests", + "project_directory": "/tmp/test-project", + "docker_compose_file": "docker-compose.yml", + "data_directory": "/DATA", + } + return config_data + + +@pytest.fixture +def mock_config(temp_project_config): + """Create a mock config with an active project.""" + config = Config(active_project="/tmp/test-project-config.json") + return config + + +def test_projects_info_no_active_project(runner): + """Test that info command handles no active project gracefully.""" + with patch("arm_cli.projects.info.get_active_project_config") as mock_get_config: + mock_get_config.return_value = None + + result = runner.invoke(projects, ["info"], obj={"config": Config()}) + + assert result.exit_code == 0 + assert "No active project configured" in result.output + + +def test_projects_info_with_project(runner, mock_config, temp_project_config): + """Test that info command displays project information correctly.""" + project_config = ProjectConfig(**temp_project_config) + + with patch("arm_cli.projects.info.get_active_project_config") as mock_get_config: + mock_get_config.return_value = project_config + + result = runner.invoke(projects, ["info"], obj={"config": mock_config}) + + assert result.exit_code == 0 + assert "Active Project: test-project" in result.output + assert "Description: Test project for unit tests" in result.output + assert "Project Directory: /tmp/test-project" in result.output + assert "Docker Compose File: docker-compose.yml" in result.output + assert "Data Directory: /DATA" in result.output + + +def test_projects_info_minimal_project(runner, mock_config): + """Test that info command works with minimal project configuration.""" + project_config = ProjectConfig(name="minimal-project") + + with patch("arm_cli.projects.info.get_active_project_config") as mock_get_config: + mock_get_config.return_value = project_config + + result = runner.invoke(projects, ["info"], obj={"config": mock_config}) + + assert result.exit_code == 0 + assert "Active Project: minimal-project" in result.output + + +def test_projects_info_field_option(runner, mock_config, temp_project_config): + """Test that info command with --field extracts specific field values.""" + project_config = ProjectConfig(**temp_project_config) + + with patch("arm_cli.projects.info.get_active_project_config") as mock_get_config: + mock_get_config.return_value = project_config + + # Test each field + result = runner.invoke(projects, ["info", "--field", "name"], obj={"config": mock_config}) + assert result.exit_code == 0 + assert result.output.strip() == "test-project" + + result = runner.invoke( + projects, ["info", "--field", "description"], obj={"config": mock_config} + ) + assert result.exit_code == 0 + assert result.output.strip() == "Test project for unit tests" + + result = runner.invoke( + projects, ["info", "--field", "project_directory"], obj={"config": mock_config} + ) + assert result.exit_code == 0 + assert result.output.strip() == "/tmp/test-project" + + result = runner.invoke( + projects, ["info", "--field", "docker_compose_file"], obj={"config": mock_config} + ) + assert result.exit_code == 0 + assert result.output.strip() == "docker-compose.yml" + + result = runner.invoke( + projects, ["info", "--field", "data_directory"], obj={"config": mock_config} + ) + assert result.exit_code == 0 + assert result.output.strip() == "/DATA" + + +def test_projects_info_field_with_spaces(runner, mock_config, temp_project_config): + """Test that info command handles field names with spaces correctly.""" + project_config = ProjectConfig(**temp_project_config) + + with patch("arm_cli.projects.info.get_active_project_config") as mock_get_config: + mock_get_config.return_value = project_config + + # Test field names with spaces (should be converted to underscores) + result = runner.invoke( + projects, ["info", "--field", "Project Directory"], obj={"config": mock_config} + ) + assert result.exit_code == 0 + assert result.output.strip() == "/tmp/test-project" + + result = runner.invoke( + projects, ["info", "--field", "Docker Compose File"], obj={"config": mock_config} + ) + assert result.exit_code == 0 + assert result.output.strip() == "docker-compose.yml" + + +def test_projects_info_field_case_insensitive(runner, mock_config, temp_project_config): + """Test that info command handles case-insensitive field names.""" + project_config = ProjectConfig(**temp_project_config) + + with patch("arm_cli.projects.info.get_active_project_config") as mock_get_config: + mock_get_config.return_value = project_config + + # Test different case variations + result = runner.invoke( + projects, ["info", "--field", "PROJECT_DIRECTORY"], obj={"config": mock_config} + ) + assert result.exit_code == 0 + assert result.output.strip() == "/tmp/test-project" + + result = runner.invoke( + projects, ["info", "--field", "Project_Directory"], obj={"config": mock_config} + ) + assert result.exit_code == 0 + assert result.output.strip() == "/tmp/test-project" + + +def test_projects_info_field_empty_value(runner, mock_config): + """Test that info command handles empty field values correctly.""" + project_config = ProjectConfig(name="test", description=None, project_directory=None) + + with patch("arm_cli.projects.info.get_active_project_config") as mock_get_config: + mock_get_config.return_value = project_config + + result = runner.invoke( + projects, ["info", "--field", "description"], obj={"config": mock_config} + ) + assert result.exit_code == 0 + assert result.output.strip() == "" # Empty string for None values + + +def test_projects_info_field_unknown_field(runner, mock_config, temp_project_config): + """Test that info command handles unknown field names gracefully.""" + project_config = ProjectConfig(**temp_project_config) + + with patch("arm_cli.projects.info.get_active_project_config") as mock_get_config: + mock_get_config.return_value = project_config + + result = runner.invoke( + projects, ["info", "--field", "nonexistent_field"], obj={"config": mock_config} + ) + assert result.exit_code == 0 # Click doesn't exit with error code for stderr output + assert "Unknown field: nonexistent_field" in result.output + assert "Available fields:" in result.output + + +def test_projects_info_field_no_active_project(runner): + """Test that info command with --field handles no active project gracefully.""" + with patch("arm_cli.projects.info.get_active_project_config") as mock_get_config: + mock_get_config.return_value = None + + result = runner.invoke(projects, ["info", "--field", "name"], obj={"config": Config()}) + + assert result.exit_code == 0 + assert "No active project configured" in result.output + + +def test_projects_help(runner): + """Test that projects command shows help.""" + result = runner.invoke(projects, ["--help"]) + + assert result.exit_code == 0 + assert "Manage ARM projects" in result.output + assert "activate" in result.output + assert "info" in result.output + assert "init" in result.output + assert "ls" in result.output + assert "remove" in result.output