Add core modules: config and platform detection
Some checks failed
CI / test (push) Has been cancelled
Some checks failed
CI / test (push) Has been cancelled
This commit is contained in:
400
dev_env_sync/core/config.py
Normal file
400
dev_env_sync/core/config.py
Normal file
@@ -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",
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user