diff --git a/aeonsync/cli.py b/aeonsync/cli.py index a04dec8..1b9f684 100644 --- a/aeonsync/cli.py +++ b/aeonsync/cli.py @@ -6,12 +6,14 @@ import typer from rich.console import Console +from rich.table import Table from aeonsync.config import ( + config_manager, + BackupConfig, DEFAULT_REMOTE, DEFAULT_RETENTION_PERIOD, DEFAULT_SOURCE_DIRS, - BackupConfig, ) from aeonsync.core import AeonSync @@ -86,8 +88,8 @@ def sync( ): """Create a backup of specified sources to the remote destination.""" try: - config = get_backup_config(ctx, sources, retention, dry_run) - aeonsync = AeonSync(config) + backup_config = get_backup_config(ctx, sources, retention, dry_run) + aeonsync = AeonSync(backup_config) with console.status("[bold green]Performing backup..."): aeonsync.sync() console.print("[bold green]Backup completed successfully.") @@ -114,8 +116,8 @@ def restore( """Restore a specific file from a backup.""" try: # Use an empty list for sources, it will be populated with defaults in get_backup_config - config = get_backup_config(ctx, [], DEFAULT_RETENTION_PERIOD, False) - aeonsync = AeonSync(config) + backup_config = get_backup_config(ctx, [], DEFAULT_RETENTION_PERIOD, False) + aeonsync = AeonSync(backup_config) if interactive: aeonsync.restore.restore_interactive() @@ -134,8 +136,8 @@ def restore( def list_backups(ctx: typer.Context): """List all available backups with their metadata.""" try: - config = get_backup_config(ctx, [], DEFAULT_RETENTION_PERIOD, False) - aeonsync = AeonSync(config) + backup_config = get_backup_config(ctx, [], DEFAULT_RETENTION_PERIOD, False) + aeonsync = AeonSync(backup_config) aeonsync.list_backups() except Exception as e: logger.error("Failed to list backups: %s", str(e), exc_info=True) @@ -143,5 +145,92 @@ def list_backups(ctx: typer.Context): raise typer.Exit(code=1) +@app.command() +def config( # pylint: disable=too-many-arguments,too-many-branches + hostname: Optional[str] = typer.Option(None, help="Set the hostname"), + remote_address: Optional[str] = typer.Option(None, help="Set the remote address"), + remote_path: Optional[str] = typer.Option(None, help="Set the remote path"), + remote_port: Optional[int] = typer.Option(None, help="Set the remote port"), + retention_period: Optional[int] = typer.Option( + None, help="Set the retention period in days" + ), + add_source_dir: Optional[str] = typer.Option(None, help="Add a source directory"), + remove_source_dir: Optional[str] = typer.Option( + None, help="Remove a source directory" + ), + add_exclusion: Optional[str] = typer.Option(None, help="Add an exclusion pattern"), + remove_exclusion: Optional[str] = typer.Option( + None, help="Remove an exclusion pattern" + ), + ssh_key: Optional[str] = typer.Option(None, help="Set the SSH key path"), + verbose: Optional[bool] = typer.Option(None, help="Set verbose mode"), + log_file: Optional[str] = typer.Option(None, help="Set the log file path"), + show: bool = typer.Option(False, "--show", help="Show current configuration"), +): + """View or edit the AeonSync configuration.""" + if show: + show_config(config_manager.config) + return + + changed = False + if hostname is not None: + config_manager.set("hostname", hostname) + changed = True + if remote_address is not None: + config_manager.set("remote_address", remote_address) + changed = True + if remote_path is not None: + config_manager.set("remote_path", remote_path) + changed = True + if remote_port is not None: + config_manager.set("remote_port", remote_port) + changed = True + if retention_period is not None: + config_manager.set("retention_period", retention_period) + changed = True + if add_source_dir: + config_manager.add_to_list("source_dirs", add_source_dir) + changed = True + if remove_source_dir: + config_manager.remove_from_list("source_dirs", remove_source_dir) + changed = True + if add_exclusion: + config_manager.add_to_list("exclusions", add_exclusion) + changed = True + if remove_exclusion: + config_manager.remove_from_list("exclusions", remove_exclusion) + changed = True + if ssh_key is not None: + config_manager.set("ssh_key", ssh_key) + changed = True + if verbose is not None: + config_manager.set("verbose", verbose) + changed = True + if log_file is not None: + config_manager.set("log_file", log_file) + changed = True + + if changed: + console.print("Configuration updated successfully!", style="bold green") + else: + console.print("No changes were made to the configuration.", style="yellow") + + show_config(config_manager.config) + + +def show_config(config_dict: dict): + """Display the current configuration.""" + table = Table(title="AeonSync Configuration") + table.add_column("Setting", style="cyan") + table.add_column("Value", style="green") + + for key, value in config_dict.items(): + if isinstance(value, list): + value = "\n".join(map(str, value)) + table.add_row(key, str(value)) + + console.print(table) + + if __name__ == "__main__": app() diff --git a/aeonsync/config.py b/aeonsync/config.py index 0207900..83d2af5 100644 --- a/aeonsync/config.py +++ b/aeonsync/config.py @@ -1,36 +1,116 @@ """Configuration module for AeonSync.""" import socket -from typing import List, Optional, Union, NamedTuple +from typing import List, NamedTuple, Optional, Union, Dict, Any from pathlib import Path -# Configuration -HOSTNAME = socket.gethostname() -DEFAULT_REMOTE = "bliss@cloudless:/volume1/rsync_backups/aeonsync" -DEFAULT_RETENTION_PERIOD = 7 # Default number of days to keep backups -METADATA_FILE_NAME = "backup_metadata.json" +import toml +from appdirs import user_config_dir + + +class ConfigManager: + """Manages AeonSync configuration.""" + + APP_NAME = "aeonsync" + CONFIG_FILE_NAME = "config.toml" + + def __init__(self, config_dir: Optional[Path] = None): + self.config_dir = config_dir or Path(user_config_dir(self.APP_NAME)) + self.config_file_path = self.config_dir / self.CONFIG_FILE_NAME + self.config: Dict[str, Any] = {} # Initialize config as an empty dict + self.load_config() # Load the configuration + + @property + def default_config(self) -> Dict[str, Any]: + return { + "hostname": socket.gethostname(), + "remote_address": "user@example.com", + "remote_path": "/path/to/backups", + "remote_port": 22, + "retention_period": 7, + "source_dirs": [str(Path.home())], + "exclusions": [ + ".cache", + "*/caches/*", + ".local/share/Trash", + "*/node_modules", + "*/.venv", + "*/venv", + "*/__pycache__", + "*/.gradle", + "*/build", + "*/target", + "*/.cargo", + "*/dist", + "*/.npm", + "*/.yarn", + "*/.pub-cache", + ], + "ssh_key": str(Path.home() / ".ssh" / "id_rsa"), + "verbose": False, + "log_file": str( + Path.home() / ".local" / "share" / self.APP_NAME / "aeonsync.log" + ), + } + + def load_config(self) -> None: + """Load the configuration from file or create with default values if it doesn't exist.""" + self.config_dir.mkdir(parents=True, exist_ok=True) + if self.config_file_path.exists(): + with open(self.config_file_path, "r", encoding="utf-8") as config_file: + self.config = toml.load(config_file) + else: + # If the config file doesn't exist, use default values + self.config = self.default_config.copy() + self.save_config(self.config) + + def save_config(self, new_config: Dict[str, Any]) -> None: + """Save the configuration to file.""" + self.config_dir.mkdir(parents=True, exist_ok=True) + with open(self.config_file_path, "w", encoding="utf-8") as config_file: + toml.dump(new_config, config_file) + self.config = new_config -# Default source directory -DEFAULT_SOURCE_DIRS: List[str] = ["/home/bliss"] - -# Exclusions -EXCLUSIONS: List[str] = [ - ".cache", - "*/caches/*", - ".local/share/Trash", - "*/node_modules", - "*/.venv", - "*/venv", - "*/__pycache__", - "*/.gradle", - "*/build", - "*/target", - "*/.cargo", - "*/dist", - "*/.npm", - "*/.yarn", - "*/.pub-cache", -] + def get(self, key: str, default: Any = None) -> Any: + """Get a configuration value.""" + return self.config.get(key, default) + + def set(self, key: str, value: Any) -> None: + """Set a configuration value and save the configuration.""" + self.config[key] = value + self.save_config(self.config) + + def add_to_list(self, key: str, value: Any) -> None: + """Add a value to a list configuration item.""" + if key not in self.config or not isinstance(self.config[key], list): + self.config[key] = [] + if value not in self.config[key]: + self.config[key].append(value) + self.save_config(self.config) + + def remove_from_list(self, key: str, value: Any) -> None: + """Remove a value from a list configuration item.""" + if key in self.config and isinstance(self.config[key], list): + if value in self.config[key]: + self.config[key].remove(value) + self.save_config(self.config) + + +config_manager = ConfigManager() + +# Expose configuration values as module-level variables +HOSTNAME = config_manager.get("hostname") +DEFAULT_REMOTE = ( + f"{config_manager.get('remote_address')}:{config_manager.get('remote_path')}" +) +DEFAULT_RETENTION_PERIOD = config_manager.get("retention_period") +METADATA_FILE_NAME = "backup_metadata.json" +DEFAULT_SOURCE_DIRS: List[str] = config_manager.get("source_dirs") +EXCLUSIONS: List[str] = config_manager.get("exclusions") +DEFAULT_SSH_KEY = config_manager.get("ssh_key") +DEFAULT_REMOTE_PORT = config_manager.get("remote_port") +VERBOSE = config_manager.get("verbose") +LOG_FILE = config_manager.get("log_file") class BackupConfig(NamedTuple): @@ -40,8 +120,8 @@ class BackupConfig(NamedTuple): sources: List[Union[str, Path]] full: bool = False dry_run: bool = False - ssh_key: Optional[str] = None - remote_port: Optional[int] = None - verbose: bool = False + ssh_key: Optional[str] = DEFAULT_SSH_KEY + remote_port: Optional[int] = DEFAULT_REMOTE_PORT + verbose: bool = VERBOSE retention_period: int = DEFAULT_RETENTION_PERIOD - log_file: Optional[str] = None + log_file: Optional[str] = LOG_FILE diff --git a/poetry.lock b/poetry.lock index 55a107a..9fa3e1b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,16 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +[[package]] +name = "appdirs" +version = "1.4.4" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +optional = false +python-versions = "*" +files = [ + {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, + {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, +] + [[package]] name = "astroid" version = "3.2.4" @@ -424,6 +435,17 @@ files = [ {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, ] +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] + [[package]] name = "tomlkit" version = "0.13.2" @@ -452,6 +474,17 @@ rich = ">=10.11.0" shellingham = ">=1.3.0" typing-extensions = ">=3.7.4.3" +[[package]] +name = "types-toml" +version = "0.10.8.20240310" +description = "Typing stubs for toml" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-toml-0.10.8.20240310.tar.gz", hash = "sha256:3d41501302972436a6b8b239c850b26689657e25281b48ff0ec06345b8830331"}, + {file = "types_toml-0.10.8.20240310-py3-none-any.whl", hash = "sha256:627b47775d25fa29977d9c70dc0cbab3f314f32c8d8d0c012f2ef5de7aaec05d"}, +] + [[package]] name = "typing-extensions" version = "4.12.2" @@ -477,4 +510,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "f64b1cf4c20f4f66c2f55f6b6792d513b690eef569979ff12c1b93e0be4b7f40" +content-hash = "cd6aeadaa248ab1cf0b642a1b946e1b066b44d0214ecf1d5b0424eb3c64354df" diff --git a/pyproject.toml b/pyproject.toml index 62cca93..a60e0aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,12 +11,15 @@ python = "^3.12" typer = "^0.12.5" rich = "^13.8.0" prompt-toolkit = "^3.0.47" +toml = "^0.10.2" +appdirs = "^1.4.4" [tool.poetry.group.dev.dependencies] pytest = "^8.3.2" pylint = "^3.2.7" mypy = "^1.11.2" pytest-cov = "^5.0.0" +types-toml = "^0.10.8.20240310" [build-system] requires = ["poetry-core"] diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..22a4605 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,77 @@ +"""Tests for AeonSync configuration management.""" + +from time import sleep +import pytest +from aeonsync.config import ConfigManager + + +@pytest.fixture(name="temp_config_dir") +def fixture_temp_config_dir(tmp_path): + """Fixture to provide a temporary directory for config files.""" + return tmp_path / "config" + + +@pytest.fixture(name="config_manager") +def fixture_config_manager(temp_config_dir): + """Fixture to provide a ConfigManager instance with a temporary config directory.""" + return ConfigManager(config_dir=temp_config_dir) + + +def test_default_config_creation(config_manager, temp_config_dir): + """Test that a default configuration file is created if it doesn't exist.""" + config_file_path = temp_config_dir / ConfigManager.CONFIG_FILE_NAME + assert config_file_path.exists() + assert config_manager.get("hostname") is not None + + +def test_config_persistence(config_manager, temp_config_dir): + """Test that configuration changes are persisted.""" + config_manager.set("test_key", "test_value") + assert config_manager.get("test_key") == "test_value" + + # Create a new ConfigManager instance to ensure changes were saved + new_config_manager = ConfigManager(config_dir=temp_config_dir) + assert new_config_manager.get("test_key") == "test_value" + + +def test_add_to_list(config_manager): + """Test adding items to a list configuration.""" + config_manager.add_to_list("source_dirs", "/test/path") + assert "/test/path" in config_manager.get("source_dirs") + + +def test_remove_from_list(config_manager): + """Test removing items from a list configuration.""" + config_manager.add_to_list("exclusions", "*.tmp") + assert "*.tmp" in config_manager.get("exclusions") + config_manager.remove_from_list("exclusions", "*.tmp") + assert "*.tmp" not in config_manager.get("exclusions") + + +def test_config_types(config_manager): + """Test that configuration values maintain their types.""" + config_manager.set("int_value", 42) + config_manager.set("bool_value", True) + config_manager.set("list_value", [1, 2, 3]) + + assert isinstance(config_manager.get("int_value"), int) + assert isinstance(config_manager.get("bool_value"), bool) + assert isinstance(config_manager.get("list_value"), list) + + +def test_nonexistent_key(config_manager): + """Test behavior when accessing a nonexistent key.""" + assert config_manager.get("nonexistent_key") is None + assert config_manager.get("nonexistent_key", "default") == "default" + + +def test_overwrite_protection(config_manager): + """Test that the configuration is saved when changes are made.""" + original_mtime = config_manager.config_file_path.stat().st_mtime + sleep(0.01) # Small delay to ensure timestamp can change + config_manager.set("test_key", "test_value") + sleep(0.01) # Another small delay + new_mtime = config_manager.config_file_path.stat().st_mtime + assert ( + new_mtime > original_mtime + ), f"New mtime {new_mtime} should be greater than original mtime {original_mtime}"