esync

Directory watching and remote syncing
Log | Files | Refs | README | LICENSE

commit 00eb8059366acc742cf67314ed0e6efa64adfcdb
parent 0d670f4fdce65f7f79478f339fe888577a948cbb
Author: Erik Loualiche <[email protected]>
Date:   Mon, 24 Feb 2025 15:38:36 -0600

quick configuration with better logging

Diffstat:
M.gitignore | 1+
Mesync/cli.py | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mesync/config.py | 7+++++++
Mesync/sync_manager.py | 354++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
4 files changed, 411 insertions(+), 46 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -19,3 +19,4 @@ test-sync tests README.md esync.toml +sync.log diff --git a/esync/cli.py b/esync/cli.py @@ -4,6 +4,7 @@ import typer from enum import Enum from rich.console import Console from rich.table import Table +from rich.live import Live from .sync_manager import SyncManager from .watchdog_watcher import WatchdogWatcher @@ -44,8 +45,21 @@ Basic Usage: Quick Sync: esync sync --quick -l ./local -r ./remote # Quick sync with default settings esync sync -q -l ./local -r user@host:/path # Quick sync to remote SSH + +Logging: + esync sync --log sync.log # Log operations to file + esync sync -q -l ./local -r ./remote --log sync.log # Quick sync with logging + +Output Control: + esync sync # Default: clean minimal output with status panel + esync sync --no-quiet # Show more console output + esync sync -v # Enable verbose mode with detailed output + esync sync --log sync.log # Log operations to file + esync sync -v --log sync.log # Detailed logging to file """ + + console = Console() class WatcherType(str, Enum): @@ -145,7 +159,22 @@ def sync( "-q", help="Quick sync with default settings" ), - verbose: bool = typer.Option(False, "--verbose", help="Enable verbose output"), + log_file: Optional[Path] = typer.Option( + None, + "--log", + help="Path to log file" + ), + quiet: bool = typer.Option( + True, + "--quiet/--no-quiet", + help="Reduce console output (default: quiet)" + ), + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Enable verbose output with detailed logging" + ), help_override: bool = typer.Option(False, "--help", is_eager=True, help="Show help message"), ): """Start the file synchronization service.""" @@ -164,11 +193,12 @@ def sync( # Create quick configuration config = create_config_for_paths(local, remote, watcher.value if watcher else None) - console.print("[bold blue]Using quick sync configuration[/]") + if not quiet: + console.print("[bold blue]Using quick sync configuration[/]") - # Display effective configuration - console.print("\n[bold]Quick Sync Configuration:[/]") - display_config(config) + # Display effective configuration + console.print("\n[bold]Quick Sync Configuration:[/]") + display_config(config) else: # Find and load config file (original flow) config_path = config_file or find_config_file() @@ -179,7 +209,8 @@ def sync( raise typer.Exit(1) # Show which config file we're using - console.print(f"[bold blue]Loading configuration from:[/] {config_path.resolve()}") + if not quiet: + console.print(f"[bold blue]Loading configuration from:[/] {config_path.resolve()}") try: config = load_config(config_path) @@ -203,8 +234,9 @@ def sync( config.settings.esync.watcher = watcher.value # Display effective configuration - console.print("\n[bold]Effective Configuration:[/]") - display_config(config) + if not quiet: + console.print("\n[bold]Effective Configuration:[/]") + display_config(config) # Get sync data from config sync_data = config.model_dump().get('sync', {}) @@ -215,38 +247,69 @@ def sync( # Create sync configuration remote_config = sync_data['remote'] + rsync_settings = config.settings.rsync + if "ssh" in remote_config: sync_config = SyncConfig( target=remote_config["path"], ssh=SSHConfig(**remote_config["ssh"]), - ignores=config.settings.rsync.ignore + config.settings.esync.ignore + ignores=rsync_settings.ignore + config.settings.esync.ignore, + backup_enabled=rsync_settings.backup_enabled, + backup_dir=rsync_settings.backup_dir, + compress=rsync_settings.compress, + human_readable=rsync_settings.human_readable ) else: remote_path = Path(remote_config["path"]).expanduser().resolve() remote_path.mkdir(parents=True, exist_ok=True) sync_config = SyncConfig( target=remote_path, - ignores=config.settings.rsync.ignore + config.settings.esync.ignore + ignores=rsync_settings.ignore + config.settings.esync.ignore, + backup_enabled=rsync_settings.backup_enabled, + backup_dir=rsync_settings.backup_dir, + compress=rsync_settings.compress, + human_readable=rsync_settings.human_readable ) # Initialize sync manager and watcher - sync_manager = SyncManager(sync_config) + log_file_path = str(log_file) if log_file else None + + # Create sync manager and watcher + sync_manager = SyncManager(sync_config, log_file_path) + # Apply quiet/verbose settings + sync_manager._quiet = quiet + sync_manager._verbose = verbose watcher = create_watcher( WatcherType(config.settings.esync.watcher), local_path, sync_manager ) - console.print(f"\nStarting {config.settings.esync.watcher} watcher...") + if not quiet: + console.print(f"\nStarting {config.settings.esync.watcher} watcher...") + if log_file: + console.print(f"[bold blue]Logging to:[/] {log_file}") + + # Start with Live display try: watcher.start() - while True: - import time - time.sleep(1) + + if not quiet: + console.print("[bold green]Watcher started successfully. Press Ctrl+C to stop.[/]") + + with Live(sync_manager.status_panel, refresh_per_second=4, console=console) as live: + while True: + live.update(sync_manager.status_panel) + import time + time.sleep(0.5) + except KeyboardInterrupt: - console.print("\nStopping watcher...") + if not quiet: + console.print("\nStopping watcher...") watcher.stop() sync_manager.stop() + if not quiet: + console.print("[bold green]Watcher stopped successfully.[/]") except Exception as e: console.print(f"[red]Error: {str(e)}[/]") diff --git a/esync/config.py b/esync/config.py @@ -15,6 +15,10 @@ class SyncConfig(BaseModel): target: Union[Path, str] ssh: Optional[SSHConfig] = None ignores: List[str] = Field(default_factory=list) + backup_enabled: bool = False + backup_dir: str = ".rsync_backup" + compress: bool = True + human_readable: bool = True def is_remote(self) -> bool: """Check if this is a remote sync configuration.""" @@ -26,6 +30,9 @@ class SyncConfig(BaseModel): return Path(self.target).expanduser() return self.target + + + class RemoteConfig(BaseModel): path: Union[Path, str] ssh: Optional[SSHConfig] = None diff --git a/esync/sync_manager.py b/esync/sync_manager.py @@ -1,38 +1,146 @@ import threading import queue import subprocess +import datetime +import os +import logging +import re from pathlib import Path -from typing import Optional +from typing import Optional, List from rich.console import Console +from rich.panel import Panel +from rich.text import Text from .config import SyncConfig console = Console() +# Customize logger to use shorter log level names +class CustomAdapter(logging.LoggerAdapter): + def process(self, msg, kwargs): + return msg, kwargs + +class ShortLevelNameFormatter(logging.Formatter): + """Custom formatter with shorter level names""" + short_levels = { + 'DEBUG': 'debug', + 'INFO': 'info', + 'WARNING': 'warn', + 'ERROR': 'error', + 'CRITICAL': 'critic' + } + + def format(self, record): + if record.levelname in self.short_levels: + record.levelname = self.short_levels[record.levelname] + return super().format(record) + class SyncManager: - def __init__(self, config: SyncConfig): + """Manages file synchronization operations.""" + + def __init__(self, config: SyncConfig, log_file: Optional[str] = None): + """Initialize the sync manager. + + Args: + config: The sync configuration + log_file: Optional path to log file + """ self._sync_lock = threading.Lock() self._task_queue = queue.Queue() self._current_sync: Optional[subprocess.Popen] = None self._should_stop = threading.Event() self._sync_thread = threading.Thread(target=self._sync_worker, daemon=True) self._config = config + self._last_sync_time = None + self._last_sync_status = None + self._sync_count = 0 + + # Default to quiet mode (cleaner output) + self._quiet = True + self._verbose = False + + # Set up logging if log file is specified + self._logger = None + if log_file: + self._setup_logging(log_file) + self._sync_thread.start() + # Single status panel that we'll update + self._status_panel = Panel( + Text("Waiting for changes...", style="italic dim"), + title="Sync Status" + ) + + def _setup_logging(self, log_file: str): + """Set up logging to file.""" + self._logger = logging.getLogger("esync") + self._logger.setLevel(logging.DEBUG) # Always use DEBUG level for logging + + # Create handlers + file_handler = logging.FileHandler(log_file) + file_handler.setLevel(logging.DEBUG) # Always log at DEBUG level + + # Create formatters with fixed-width level names using our custom formatter + formatter = ShortLevelNameFormatter( + '%(asctime)s - %(name)s - %(levelname)-5s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + file_handler.setFormatter(formatter) + + # Add handlers to the logger + self._logger.addHandler(file_handler) + self._logger.info("ESync started") + self._logger.info(f"Log level set to: DEBUG") + + def _log(self, level: str, message: str): + """Log a message if logging is enabled.""" + if self._logger: + if level.lower() == "info": + self._logger.info(message) + elif level.lower() == "warning" or level.lower() == "warn": + self._logger.warning(message) + elif level.lower() == "error" or level.lower() == "err": + self._logger.error(message) + elif level.lower() == "debug" or level.lower() == "dbg": + self._logger.debug(message) + @property def config(self) -> SyncConfig: return self._config + @property + def last_sync_time(self) -> Optional[datetime.datetime]: + return self._last_sync_time + + @property + def last_sync_status(self) -> Optional[bool]: + return self._last_sync_status + + @property + def sync_count(self) -> int: + return self._sync_count + + @property + def status_panel(self) -> Panel: + return self._status_panel + def schedule_sync(self, path: Path): """Schedule a sync task.""" self._task_queue.put(path) + self._log("info", f"Sync scheduled for {path}") def stop(self): """Stop the sync manager and cleanup.""" self._should_stop.set() + self._log("info", "Stopping sync manager") if self._current_sync: self._current_sync.terminate() self._sync_thread.join() + def _update_status(self, status_text: Text): + """Update the status panel with new text.""" + self._status_panel = Panel(status_text, title="Sync Status") + def _sync_worker(self): while not self._should_stop.is_set(): try: @@ -42,53 +150,240 @@ class SyncManager: with self._sync_lock: if self._current_sync is not None: - console.print("[yellow]Sync already in progress, queuing changes...[/]") + status_text = Text() + status_text.append("ESync: ", style="bold cyan") + status_text.append(f"Sync #{self._sync_count} ", style="yellow") + status_text.append("in progress - ", style="italic") + status_text.append("waiting for previous sync to complete", style="italic yellow") + self._update_status(status_text) + + self._log("warn", "Sync already in progress, queuing changes...") + if not self._quiet: + console.print("[yellow]Sync already in progress, queuing changes...[/]", highlight=False) + try: self._current_sync.wait() except subprocess.CalledProcessError as e: - console.print(f"[bold red]Previous sync failed: {e}[/]") + error_msg = f"Previous sync failed: {e}" + self._log("error", error_msg) + if not self._quiet: + console.print(f"[bold red]{error_msg}[/]", highlight=False) try: self._perform_sync(path) except Exception as e: - console.print(f"[bold red]Sync error: {e}[/]") + error_msg = f"Sync error: {e}" + self._log("error", error_msg) + if not self._quiet: + console.print(f"[bold red]{error_msg}[/]", highlight=False) + self._last_sync_status = False self._task_queue.task_done() + def _format_size(self, size_bytes: int) -> str: + """Format bytes into human readable size.""" + if size_bytes < 1024: + return f"{size_bytes} B" + elif size_bytes < 1024 * 1024: + return f"{size_bytes/1024:.1f} KB" + elif size_bytes < 1024 * 1024 * 1024: + return f"{size_bytes/(1024*1024):.1f} MB" + else: + return f"{size_bytes/(1024*1024*1024):.2f} GB" + + def _extract_transferred_files(self, stdout: str) -> List[str]: + """Extract list of transferred files from rsync output.""" + transferred_files = [] + + # Split output into lines and process + for line in stdout.splitlines(): + # Skip common status message lines + if any(pattern in line for pattern in + ['building file list', 'files to consider', 'sent', 'total size', 'bytes/sec']): + continue + + # Most rsync outputs show files on their own line before any stats + # Try to extract just the filename (ignoring paths and stats) + parts = line.strip().split() + if not parts: + continue + + # Files typically appear at the start of lines, before transfer stats + filename = parts[0] + + # Skip purely numeric entries and other likely non-filenames + if (filename.isdigit() or + filename in ['sending', 'sent', 'total', 'building'] or + '%' in filename): + continue + + # Get base filename and add if it looks valid + base_name = os.path.basename(filename) + if base_name and not base_name.isdigit(): + transferred_files.append(base_name) + + # Make sure our list is unique - no duplicates + transferred_files = list(dict.fromkeys(transferred_files)) + + return transferred_files + + def _extract_file_info(self, stdout: str): + """Extract file information from rsync output.""" + stats_lines = [] + total_bytes = 0 + + # Try to extract file count from rsync output + file_count_match = re.search(r'(\d+) files? to consider', stdout) + file_count = file_count_match.group(1) if file_count_match else "0" + + # Extract bytes sent/received + bytes_match = re.search(r'sent ([\d,]+) bytes\s+received ([\d,]+) bytes', stdout) + if bytes_match: + sent = bytes_match.group(1).replace(',', '') + received = bytes_match.group(2).replace(',', '') + try: + total_bytes = int(sent) + int(received) + except ValueError: + # In case of parsing issues + total_bytes = 0 + + # Calculate time taken + time_taken = (datetime.datetime.now() - self._last_sync_time).total_seconds() + time_str = f"{time_taken:.2f}s" + + # Extract transferred files + transferred_files = self._extract_transferred_files(stdout) + + # Format transferred files with truncation if needed + files_summary = "" + if transferred_files: + if len(transferred_files) <= 3: + files_summary = ", ".join(transferred_files) + else: + files_summary = f"{transferred_files[0]}, {transferred_files[1]}, ... +{len(transferred_files)-2} more" + + # Format total bytes in human readable format + size_str = self._format_size(total_bytes) + + # Build stats lines + stats_lines.append(f"Files: {file_count}, Size: {size_str}, Time: {time_str}") + if files_summary: + stats_lines.append(f"Transferred: {files_summary}") + + return stats_lines + + def _log_rsync_output(self, stdout: str): + """Log the rsync stdout in a readable format.""" + if not self._logger: + return + + self._log("debug", "===== RSYNC OUTPUT START =====") + for line in stdout.splitlines(): + if line.strip(): + self._log("debug", f"RSYNC: {line}") + self._log("debug", "===== RSYNC OUTPUT END =====") + def _perform_sync(self, path: Path): """Perform the actual sync operation.""" cmd = self._build_rsync_command(path) + self._sync_count += 1 + self._last_sync_time = datetime.datetime.now() - with console.status("[bold green]Syncing...") as status: - self._current_sync = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) + # Always log the exact command at DEBUG level + command_str = ' '.join(cmd) + self._log("debug", f"COMMAND: {command_str}") - try: - stdout, stderr = self._current_sync.communicate() - if self._current_sync.returncode != 0: - raise subprocess.CalledProcessError( - self._current_sync.returncode, cmd, stdout, stderr - ) - console.print("[bold green]✓[/] Sync completed successfully!") - finally: - self._current_sync = None + status_text = Text() + status_text.append("ESync: ", style="bold cyan") + status_text.append(f"Sync #{self._sync_count} ", style="yellow") + status_text.append("in progress ", style="italic") + status_text.append(f"[{self._last_sync_time.strftime('%Y-%m-%d %H:%M:%S')}]", style="dim") + self._update_status(status_text) + + self._log("info", f"Starting sync #{self._sync_count} from {path}") + + # Always capture stdout to extract info, even without logging + self._current_sync = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + try: + stdout, stderr = self._current_sync.communicate() + + # Log the full rsync output at DEBUG level + if stdout: + self._log_rsync_output(stdout) + + if self._current_sync.returncode != 0: + if stderr: + self._log("error", f"Rsync stderr: {stderr}") + + self._last_sync_status = False + status_text = Text() + status_text.append("ESync: ", style="bold cyan") + status_text.append(f"Sync #{self._sync_count} ", style="yellow") + status_text.append("failed ", style="bold red") + status_text.append(f"[{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}]", style="dim") + status_text.append("\n", style="default") + status_text.append(f"Error: {stderr.strip() if stderr else f'Exit code {self._current_sync.returncode}'}", style="red") + self._update_status(status_text) + + error_msg = f"Sync failed with code {self._current_sync.returncode}" + self._log("error", error_msg) + if not self._quiet: + console.print(f"[bold red]✗[/] {error_msg}", highlight=False) + + raise subprocess.CalledProcessError( + self._current_sync.returncode, cmd, stdout, stderr + ) + else: + # Always extract stats even without logging + stats_lines = self._extract_file_info(stdout) + + self._last_sync_status = True + status_text = Text() + status_text.append("ESync: ", style="bold cyan") + status_text.append(f"Sync #{self._sync_count} ", style="yellow") + status_text.append("completed ", style="bold green") + status_text.append(f"[{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}]", style="dim") + + for stats_line in stats_lines: + status_text.append("\n", style="default") + status_text.append(stats_line, style="green dim") + + self._update_status(status_text) + + self._log("info", f"Sync #{self._sync_count} completed successfully") + if not self._quiet: + console.print(f"[bold green]✓[/] Sync #{self._sync_count} completed successfully", highlight=False) + finally: + self._current_sync = None def _build_rsync_command(self, source_path: Path) -> list[str]: """Build rsync command for local or remote sync.""" cmd = [ "rsync", - "--recursive", # recurse into directories - "--times", # preserve modification times (-t) - "--progress", # show progress - "--verbose", # increase verbosity - # "--backup", # make backups of deleted files - # "--backup-dir=.rsync_backup", # backup directory + "--recursive", # recursive + "--times", # preserve times + "--progress", # progress for parsing + "--verbose", # verbose for parsing ] + # Add backup if enabled + if hasattr(self._config, 'backup_enabled') and self._config.backup_enabled: + cmd.append("--backup") + backup_dir = getattr(self._config, 'backup_dir', '.rsync_backup') + cmd.append(f"--backup-dir={backup_dir}") + + # Add other rsync options if configured + if hasattr(self._config, 'compress') and self._config.compress: + cmd.append("--compress") + if hasattr(self._config, 'human_readable') and self._config.human_readable: + cmd.append("--human-readable") + # Add ignore patterns for pattern in self._config.ignores: # Remove any quotes and brackets from the input @@ -104,8 +399,8 @@ class SyncManager: if self._config.is_remote(): # For remote sync ssh = self._config.ssh - if ssh.username: - remote = f"{ssh.username}@{ssh.host}:{self._config.target}" + if ssh.user: + remote = f"{ssh.user}@{ssh.host}:{self._config.target}" else: remote = f"{ssh.host}:{self._config.target}" cmd.append(source) @@ -119,5 +414,4 @@ class SyncManager: cmd.append(source) cmd.append(str(target) + '/') - console.print(f"Running rsync command: {' '.join(cmd)}") return cmd