401 lines
14 KiB
Python
401 lines
14 KiB
Python
"""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",
|
|
),
|
|
)
|