Skip to content

Commit

Permalink
🔧 Implement configuration management system
Browse files Browse the repository at this point in the history
Introduce ConfigManager for centralized config handling

- Add ConfigManager class in config.py for managing AeonSync settings
- Implement TOML-based configuration file storage
- Update CLI to use new configuration system
- Add unit tests for configuration management
- Integrate appdirs for cross-platform config directory handling
- Update dependencies in pyproject.toml

This change improves configuration management, allowing for easier
user customization and more robust handling of settings across
different environments.
  • Loading branch information
hyperb1iss committed Sep 10, 2024
1 parent 212045b commit f73637d
Show file tree
Hide file tree
Showing 5 changed files with 321 additions and 39 deletions.
103 changes: 96 additions & 7 deletions aeonsync/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.")
Expand All @@ -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()
Expand All @@ -134,14 +136,101 @@ 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)
console.print(f"[bold red]Error:[/bold red] {str(e)}")
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()
142 changes: 111 additions & 31 deletions aeonsync/config.py
Original file line number Diff line number Diff line change
@@ -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": "[email protected]",
"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):
Expand All @@ -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
35 changes: 34 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading

0 comments on commit f73637d

Please sign in to comment.