Files
7000pctAUTO 43a764c6e4
Some checks failed
CI / test (push) Has been cancelled
Add core modules: config and platform detection
2026-01-30 04:07:49 +00:00

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",
),
)