From 43a764c6e4748d406450f969af4ee63e63d6109a Mon Sep 17 00:00:00 2001 From: 7000pctAUTO Date: Fri, 30 Jan 2026 04:07:49 +0000 Subject: [PATCH] Add core modules: config and platform detection --- dev_env_sync/core/config.py | 400 ++++++++++++++++++++++++++++++++++++ 1 file changed, 400 insertions(+) create mode 100644 dev_env_sync/core/config.py diff --git a/dev_env_sync/core/config.py b/dev_env_sync/core/config.py new file mode 100644 index 0000000..47f0119 --- /dev/null +++ b/dev_env_sync/core/config.py @@ -0,0 +1,400 @@ +"""Configuration loading and validation for dev-env-sync.""" + +import os +from pathlib import Path +from typing import Any, Dict, List, Optional, Union +from dataclasses import dataclass, field +import yaml +from yaml import YAMLError + + +class ConfigParseError(Exception): + """Raised when configuration file cannot be parsed or is invalid.""" + pass + + +@dataclass +class DotfileConfig: + """Configuration for a single dotfile.""" + source: str + target: str + create_parent: bool = True + backup: bool = True + ignore: Optional[List[str]] = None + + +@dataclass +class ShellConfig: + """Configuration for shell settings.""" + shell: str = "bash" + config_file: Optional[str] = None + merge_strategy: str = "replace" + sections: Optional[List[str]] = None + + +@dataclass +class EditorExtension: + """Configuration for an editor extension/plugin.""" + name: str + install: bool = True + + +@dataclass +class EditorSettings: + """Configuration for editor settings.""" + settings_file: Optional[str] = None + extensions: List[EditorExtension] = field(default_factory=list) + + +@dataclass +class VSCodeConfig: + """Configuration for VS Code settings.""" + settings: Optional[EditorSettings] = None + user_data_dir: Optional[str] = None + extensions: List[EditorExtension] = field(default_factory=list) + + +@dataclass +class NeovimConfig: + """Configuration for Neovim settings.""" + init_file: Optional[str] = None + plugins: List[EditorExtension] = field(default_factory=list) + config_dir: Optional[str] = None + + +@dataclass +class PackageManagerConfig: + """Configuration for package managers.""" + name: str + packages: List[str] = field(default_factory=list) + install_command: Optional[str] = None + + +@dataclass +class BackupConfig: + """Configuration for backup behavior.""" + enabled: bool = True + directory: Optional[str] = None + timestamp_format: str = "%Y%m%d_%H%M%S" + + +@dataclass +class ConfigSchema: + """Complete configuration schema for dev-env-sync.""" + version: str = "1.0" + name: Optional[str] = None + description: Optional[str] = None + + dotfiles: Dict[str, DotfileConfig] = field(default_factory=dict) + shell: Optional[ShellConfig] = None + editors: Dict[str, Any] = field(default_factory=dict) + packages: List[PackageManagerConfig] = field(default_factory=list) + backup: BackupConfig = field(default_factory=BackupConfig) + + platform_specific: Dict[str, Dict[str, Any]] = field(default_factory=dict) + includes: Optional[List[str]] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert config to dictionary for YAML output.""" + result = { + "version": self.version, + } + if self.name: + result["name"] = self.name + if self.description: + result["description"] = self.description + + if self.dotfiles: + result["dotfiles"] = { + src: { + "source": cfg.source, + "target": cfg.target, + "create_parent": cfg.create_parent, + "backup": cfg.backup, + "ignore": cfg.ignore, + } + for src, cfg in self.dotfiles.items() + } + + if self.shell: + result["shell"] = { + "shell": self.shell.shell, + "config_file": self.shell.config_file, + "merge_strategy": self.shell.merge_strategy, + "sections": self.shell.sections, + } + + if self.editors: + result["editors"] = self.editors + + if self.packages: + result["packages"] = [ + { + "name": pkg.name, + "packages": pkg.packages, + "install_command": pkg.install_command, + } + for pkg in self.packages + ] + + result["backup"] = { + "enabled": self.backup.enabled, + "directory": self.backup.directory, + "timestamp_format": self.backup.timestamp_format, + } + + if self.platform_specific: + result["platform_specific"] = self.platform_specific + + if self.includes: + result["includes"] = self.includes + + return result + + +class ConfigLoader: + """Loads and validates dev-env-sync configuration files.""" + + DEFAULT_CONFIG_PATHS = [ + "~/.dev-env-sync.yml", + "~/.config/dev-env-sync/config.yml", + "./.dev-env-sync.yml", + "./dev-env-sync.yml", + ] + + def __init__(self, config_path: Optional[str] = None): + self.config_path = config_path + self._raw_config: Optional[Dict[str, Any]] = None + self._config: Optional[ConfigSchema] = None + + def load(self, path: Optional[str] = None) -> ConfigSchema: + """Load and parse configuration from file.""" + config_path = path or self.config_path + self._raw_config = self._read_config_file(config_path) + self._config = self._parse_and_validate(self._raw_config) + return self._config + + def _read_config_file(self, path: Optional[str]) -> Dict[str, Any]: + """Read configuration file from path or default locations.""" + if path: + config_file = Path(path).expanduser() + if not config_file.exists(): + raise ConfigParseError(f"Configuration file not found: {config_file}") + return self._parse_yaml(config_file) + + for default_path in self.DEFAULT_CONFIG_PATHS: + config_file = Path(default_path).expanduser() + if config_file.exists(): + return self._parse_yaml(config_file) + + raise ConfigParseError( + f"No configuration file found. Searched: {', '.join(self.DEFAULT_CONFIG_PATHS)}" + ) + + def _parse_yaml(self, path: Path) -> Dict[str, Any]: + """Parse YAML file with error handling.""" + try: + with open(path, 'r', encoding='utf-8') as f: + return yaml.safe_load(f) or {} + except YAMLError as e: + raise ConfigParseError(f"YAML parsing error: {e}") + except IOError as e: + raise ConfigParseError(f"Could not read config file: {e}") + + def _parse_and_validate(self, raw_config: Dict[str, Any]) -> ConfigSchema: + """Parse raw configuration dictionary into ConfigSchema.""" + if not isinstance(raw_config, dict): + raise ConfigParseError("Configuration must be a dictionary") + + config = ConfigSchema( + version=raw_config.get("version", "1.0"), + name=raw_config.get("name"), + description=raw_config.get("description"), + ) + + if "dotfiles" in raw_config: + config.dotfiles = self._parse_dotfiles(raw_config["dotfiles"]) + + if "shell" in raw_config: + config.shell = self._parse_shell(raw_config["shell"]) + + if "editors" in raw_config: + config.editors = self._parse_editors(raw_config["editors"]) + + if "packages" in raw_config: + config.packages = self._parse_packages(raw_config["packages"]) + + if "backup" in raw_config: + config.backup = self._parse_backup(raw_config["backup"]) + + if "platform_specific" in raw_config: + config.platform_specific = raw_config["platform_specific"] + + if "includes" in raw_config: + if not isinstance(raw_config["includes"], list): + raise ConfigParseError("'includes' must be a list") + config.includes = raw_config["includes"] + + return config + + def _parse_dotfiles(self, dotfiles_config: Dict[str, Any]) -> Dict[str, DotfileConfig]: + """Parse dotfiles configuration.""" + dotfiles = {} + for name, config in dotfiles_config.items(): + if isinstance(config, str): + dotfiles[name] = DotfileConfig( + source=config, + target=f"~/.{name}", + ) + elif isinstance(config, dict): + dotfiles[name] = DotfileConfig( + source=config.get("source", name), + target=config.get("target", f"~/.{name}"), + create_parent=config.get("create_parent", True), + backup=config.get("backup", True), + ignore=config.get("ignore"), + ) + else: + raise ConfigParseError(f"Invalid dotfile configuration for '{name}'") + return dotfiles + + def _parse_shell(self, shell_config: Union[str, Dict[str, Any]]) -> ShellConfig: + """Parse shell configuration.""" + if isinstance(shell_config, str): + return ShellConfig(shell=shell_config) + + return ShellConfig( + shell=shell_config.get("shell", "bash"), + config_file=shell_config.get("config_file"), + merge_strategy=shell_config.get("merge_strategy", "replace"), + sections=shell_config.get("sections"), + ) + + def _parse_editors(self, editors_config: Dict[str, Any]) -> Dict[str, Any]: + """Parse editors configuration.""" + editors = {} + for editor_name, config in editors_config.items(): + if editor_name == "vscode": + vscode_config = {} + if "settings" in config: + vscode_config["settings"] = self._parse_editor_settings(config["settings"]) + if "extensions" in config: + vscode_config["extensions"] = [ + EditorExtension(name=ext) if isinstance(ext, str) else ext + for ext in config["extensions"] + ] + if "user_data_dir" in config: + vscode_config["user_data_dir"] = config["user_data_dir"] + editors[editor_name] = vscode_config + elif editor_name == "neovim": + neovim_config = {} + if "init_file" in config: + neovim_config["init_file"] = config["init_file"] + if "plugins" in config: + neovim_config["plugins"] = [ + EditorExtension(name=plugin) if isinstance(plugin, str) else plugin + for plugin in config["plugins"] + ] + if "config_dir" in config: + neovim_config["config_dir"] = config["config_dir"] + editors[editor_name] = neovim_config + else: + editors[editor_name] = config + return editors + + def _parse_editor_settings(self, settings_config: Any) -> EditorSettings: + """Parse editor settings configuration.""" + if isinstance(settings_config, dict): + extensions = [] + if "extensions" in settings_config: + extensions = [ + EditorExtension(name=ext) if isinstance(ext, str) else ext + for ext in settings_config["extensions"] + ] + return EditorSettings( + settings_file=settings_config.get("settings_file"), + extensions=extensions, + ) + return EditorSettings() + + def _parse_packages(self, packages_config: List[Any]) -> List[PackageManagerConfig]: + """Parse packages configuration.""" + packages = [] + for pkg_config in packages_config: + if isinstance(pkg_config, dict): + packages.append( + PackageManagerConfig( + name=pkg_config.get("name", "custom"), + packages=pkg_config.get("packages", []), + install_command=pkg_config.get("install_command"), + ) + ) + else: + packages.append(PackageManagerConfig(name="custom", packages=[pkg_config])) + return packages + + def _parse_backup(self, backup_config: Dict[str, Any]) -> BackupConfig: + """Parse backup configuration.""" + return BackupConfig( + enabled=backup_config.get("enabled", True), + directory=backup_config.get("directory"), + timestamp_format=backup_config.get("timestamp_format", "%Y%m%d_%H%M%S"), + ) + + @staticmethod + def create_default_config() -> ConfigSchema: + """Create a default configuration with sample values.""" + return ConfigSchema( + version="1.0", + name="My Dev Environment", + description="My developer environment configuration", + dotfiles={ + "bashrc": DotfileConfig( + source="./dotfiles/.bashrc", + target="~/.bashrc", + backup=True, + ), + "zshrc": DotfileConfig( + source="./dotfiles/.zshrc", + target="~/.zshrc", + backup=True, + ), + "vimrc": DotfileConfig( + source="./dotfiles/.vimrc", + target="~/.vimrc", + backup=True, + ), + }, + shell=ShellConfig( + shell="bash", + merge_strategy="replace", + ), + editors={ + "vscode": { + "extensions": [ + {"name": "ms-python.python"}, + {"name": "esbenp.prettier-vscode"}, + ] + }, + "neovim": { + "plugins": [ + {"name": "vim-airline/vim-airline"}, + {"name": "tpope/vim-fugitive"}, + ] + } + }, + packages=[ + PackageManagerConfig( + name="brew", + packages=["git", "neovim", "tmux", "fzf", "ripgrep"], + ), + PackageManagerConfig( + name="apt", + packages=["git", "neovim", "tmux", "fzf", "ripgrep"], + ), + ], + backup=BackupConfig( + enabled=True, + directory="~/.dev-env-sync-backups", + ), + )