392 lines
12 KiB
Python
392 lines
12 KiB
Python
"""
|
|
This module contains helper functions for locating and loading config
|
|
files.
|
|
|
|
The result of loading a config file are the "local" variables,
|
|
including the modules loaded via "import".
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
import logging
|
|
|
|
from .colors import *
|
|
from .util import *
|
|
|
|
__all__ = [
|
|
"DEFAULT_LOCATIONS",
|
|
"ConfigurationException",
|
|
"DefaultConfigValue", "DefaultConfig", "DEFAULT_CONFIG",
|
|
"Config",
|
|
]
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_LOCATIONS = [
|
|
Path("~/.config/evering/config.py"),
|
|
Path("~/.evering/config.py"),
|
|
Path("~/.evering.py"),
|
|
]
|
|
|
|
class ConfigurationException(Exception):
|
|
pass
|
|
|
|
@dataclass
|
|
class DefaultConfigValue:
|
|
# A short textual description of the value's function
|
|
description: str
|
|
# The actual default value
|
|
value: Any
|
|
# Whether this variable even has a default value or the value is set at runtime
|
|
has_constant_value: bool
|
|
|
|
class DefaultConfig:
|
|
def __init__(self) -> None:
|
|
self._values: Dict[str, DefaultConfigValue] = {}
|
|
|
|
def add(self,
|
|
name: str,
|
|
description: str,
|
|
value: Any = None,
|
|
has_constant_value: bool = True
|
|
) -> None:
|
|
if name in self._values:
|
|
raise ConfigurationException(f"Value {name!r} already exists")
|
|
|
|
self._values[name] = DefaultConfigValue(
|
|
description, value=value, has_constant_value=has_constant_value)
|
|
|
|
def get(self, name: str) -> Optional[DefaultConfigValue]:
|
|
return self._values.get(name)
|
|
|
|
def to_local_vars(self) -> Dict[str, Any]:
|
|
return {name: d.value for name, d in self._values.items() if d.has_constant_value}
|
|
|
|
def to_config(self) -> "Config":
|
|
config = Config(self.to_local_vars())
|
|
config.user = get_user()
|
|
config.host = get_host()
|
|
return config
|
|
|
|
def to_config_file(self) -> str:
|
|
"""
|
|
Attempt to convert the DefaultConfig into a format that can be read by a
|
|
python interpreter. This assumes that all names are valid variable names
|
|
and that the repr() representations of each object can be read by the
|
|
interpreter.
|
|
|
|
This solution is quite hacky, so use at your own risk :P (At least make
|
|
sure that this works with all your default values before you use it).
|
|
"""
|
|
|
|
lines = ["from pathlib import *", ""]
|
|
|
|
for name in sorted(self._values):
|
|
value = self._values[name]
|
|
|
|
line: str
|
|
if value.has_constant_value:
|
|
line = f"{name} = {value.value!r}"
|
|
else:
|
|
line = f"# {name}"
|
|
|
|
line = f"{line:<32} # {value.description}"
|
|
lines.append(line)
|
|
|
|
return "\n".join(lines) + "\n"
|
|
|
|
DEFAULT_CONFIG = DefaultConfig()
|
|
|
|
DEFAULT_CONFIG.add(
|
|
"base_dir",
|
|
"All relative paths are interpreted as relative to this directory."
|
|
" Default: The directory the config file was loaded from",
|
|
has_constant_value=False)
|
|
|
|
DEFAULT_CONFIG.add(
|
|
"known_files",
|
|
"The file where evering stores which files it is currently managing",
|
|
value="known_files")
|
|
|
|
DEFAULT_CONFIG.add(
|
|
"config_dir",
|
|
"The directory containing the config files",
|
|
value="config")
|
|
|
|
DEFAULT_CONFIG.add(
|
|
"action_dir",
|
|
"The directory to copy the action scripts to",
|
|
value="actions")
|
|
|
|
DEFAULT_CONFIG.add(
|
|
"binary",
|
|
"When interpreting a header file: When True, the corresponding file is copied directly to the target instead of compiled. Has no effect if a file has no header file",
|
|
value=True)
|
|
|
|
DEFAULT_CONFIG.add(
|
|
"targets",
|
|
"The locations a config file should be placed in. Either a path or a list of paths",
|
|
value=[])
|
|
|
|
DEFAULT_CONFIG.add(
|
|
"action",
|
|
"Whether a file should be treated as an action with a certain name. If set, must be a string",
|
|
has_constant_value=False)
|
|
|
|
DEFAULT_CONFIG.add(
|
|
"statement_prefix",
|
|
"Determines the prefix for statements like \"if\"",
|
|
value="#")
|
|
|
|
DEFAULT_CONFIG.add(
|
|
"expression_delimiters",
|
|
"Determines the delimiters for in-line expressions",
|
|
value=("{{", "}}"))
|
|
|
|
# Compile-time info
|
|
|
|
DEFAULT_CONFIG.add(
|
|
"filename",
|
|
"Name of the file currently being compiled, as a string. Set during compilation",
|
|
has_constant_value=False)
|
|
|
|
DEFAULT_CONFIG.add(
|
|
"target",
|
|
"Location the file is currently being compiled for, as a Path. Set during compilation",
|
|
has_constant_value=False)
|
|
|
|
DEFAULT_CONFIG.add(
|
|
"user",
|
|
"Current username. Set during compilation",
|
|
has_constant_value=False)
|
|
|
|
DEFAULT_CONFIG.add(
|
|
"host",
|
|
"Name of the current computer. Set during compilation",
|
|
has_constant_value=False)
|
|
|
|
class Config:
|
|
@staticmethod
|
|
def load_config_file(path: Optional[Path]) -> "Config":
|
|
"""
|
|
May raise: ConfigurationException
|
|
"""
|
|
|
|
conf = DEFAULT_CONFIG.to_config()
|
|
|
|
if path is None:
|
|
# Try out all default config file locations
|
|
for path in DEFAULT_LOCATIONS:
|
|
try:
|
|
copy = conf.copy()
|
|
copy.apply_config_file(path)
|
|
conf = copy
|
|
break
|
|
except ConfigurationException as e:
|
|
logger.debug(f"Tried default config file at {style_path(path)} and it didn't work")
|
|
else:
|
|
raise ConfigurationException(style_error(
|
|
"No valid config file found in any of the default locations"))
|
|
else:
|
|
# Use the path
|
|
try:
|
|
copy = conf.copy()
|
|
copy.apply_config_file(path)
|
|
conf = copy
|
|
except (ReadFileException, ExecuteException) as e:
|
|
raise ConfigurationException(
|
|
style_error("Could not load config file from ") +
|
|
style_path(path) + f": {e}")
|
|
|
|
return conf
|
|
|
|
def __init__(self, local_vars: Dict[str, Any]) -> None:
|
|
self.local_vars = local_vars
|
|
|
|
def apply_config_file(self, path: Path) -> None:
|
|
"""
|
|
May raise: ConfigurationException
|
|
"""
|
|
|
|
if not "base_dir" in self.local_vars:
|
|
self.local_vars["base_dir"] = path.parent
|
|
|
|
try:
|
|
safer_exec(read_file(path), self.local_vars)
|
|
except (ReadFileException, ExecuteException) as e:
|
|
error_msg = f"Could not load config from {style_path(path)}: {e}"
|
|
logger.debug(error_msg)
|
|
raise ConfigurationException(error_msg)
|
|
else:
|
|
logger.info(f"Loaded config from {style_path(path)}")
|
|
|
|
def copy(self) -> "Config":
|
|
return Config(copy_local_variables(self.local_vars))
|
|
|
|
def _get(self, name: str, *types: type) -> Any:
|
|
"""
|
|
May raise: ConfigurationException
|
|
"""
|
|
|
|
if not name in self.local_vars:
|
|
raise ConfigurationException(
|
|
style_error(f"Expected a variable named ") +
|
|
style_var(name))
|
|
|
|
value = self.local_vars[name]
|
|
|
|
if types:
|
|
if not any(isinstance(value, t) for t in types):
|
|
raise ConfigurationException(
|
|
style_error("Expexted variable ") + style_var(name) +
|
|
style_error(" to have one of the following types:\n" +
|
|
", ".join(t.__name__ for t in types))
|
|
)
|
|
|
|
return value
|
|
|
|
def _get_optional(self, name: str, *types: type) -> Optional[Any]:
|
|
if not name in self.local_vars:
|
|
return None
|
|
else:
|
|
return self._get(name, *types)
|
|
|
|
def _set(self, name: str, value: Any) -> None:
|
|
self.local_vars[name] = value
|
|
|
|
@staticmethod
|
|
def _is_pathy(elem: Any) -> bool:
|
|
return isinstance(elem, str) or isinstance(elem, Path)
|
|
|
|
# Attributes begin here
|
|
|
|
# Locations and paths
|
|
|
|
@property
|
|
def base_dir(self) -> Path:
|
|
return Path(self._get("base_dir", str, Path)).expanduser()
|
|
|
|
@base_dir.setter
|
|
def base_dir(self, path: Path) -> None:
|
|
self._set("base_dir", path)
|
|
|
|
def _interpret_path(self, path: Union[str, Path]) -> Path:
|
|
path = Path(path).expanduser()
|
|
if path.is_absolute():
|
|
logger.debug(style_path(path) + " is absolute, no interpreting required")
|
|
return path
|
|
else:
|
|
logger.debug(style_path(path) + " is relative, interpreting as " + style_path(self.base_dir / path))
|
|
return self.base_dir / path
|
|
|
|
@property
|
|
def known_files(self) -> Path:
|
|
return self._interpret_path(self._get("known_files", str, Path))
|
|
|
|
@property
|
|
def config_dir(self) -> Path:
|
|
return self._interpret_path(self._get("config_dir", str, Path))
|
|
|
|
@property
|
|
def action_dir(self) -> Path:
|
|
return self._interpret_path(self._get("action_dir", str, Path))
|
|
|
|
# Parsing and compiling behavior
|
|
|
|
@property
|
|
def binary(self) -> bool:
|
|
return self._get("binary", bool)
|
|
|
|
@property
|
|
def targets(self) -> List[Path]:
|
|
name = "targets"
|
|
targets = self._get(name)
|
|
|
|
# Check whether targets argument has the correct format
|
|
is_path = self._is_pathy(targets)
|
|
is_list_of_paths = (isinstance(targets, list) and
|
|
all(self._is_pathy(elem) for elem in targets))
|
|
if not is_path and not is_list_of_paths:
|
|
raise ConfigurationException(
|
|
style_error("Expected variable ") + style_var(name) +
|
|
style_error(" to be either a path or a list of paths"))
|
|
|
|
paths: List[Path]
|
|
if is_path:
|
|
paths = [self._interpret_path(targets)]
|
|
else:
|
|
paths = [self._interpret_path(elem) for elem in targets]
|
|
|
|
# If this is an action, just treat it like yet another target in a very
|
|
# specific location
|
|
if self.action is not None:
|
|
paths.append(self.action_dir / self.action)
|
|
|
|
return paths
|
|
|
|
@property
|
|
def action(self) -> Optional[str]:
|
|
return self._get_optional("action", str)
|
|
|
|
@property
|
|
def statement_prefix(self) -> str:
|
|
name = "statement_prefix"
|
|
prefix = self._get(name, str)
|
|
|
|
if len(prefix) < 1:
|
|
raise ConfigurationException(
|
|
style_error("Expected variable ") + style_var(name) +
|
|
style_error(" to have at least length 1"))
|
|
|
|
return prefix
|
|
|
|
@property
|
|
def expression_delimiters(self) -> Tuple[str, str]:
|
|
name = "expression_delimiters"
|
|
delimiters = self._get(name, tuple)
|
|
|
|
if len(delimiters) != 2:
|
|
raise ConfigurationException(
|
|
style_error("Expected variable ") + style_var(name) +
|
|
style_error(" to be a tuple of length 2"))
|
|
|
|
if len(delimiters[0]) < 1 or len(delimiters[1]) < 1:
|
|
raise ConfigurationException(
|
|
style_error("Expected both strings in variable ") +
|
|
style_var(name) + style_error( "to be of length >= 1"))
|
|
|
|
return delimiters
|
|
|
|
# Environment and file-specific information
|
|
|
|
@property
|
|
def filename(self) -> str:
|
|
return self._get("filename", str)
|
|
|
|
@filename.setter
|
|
def filename(self, filename: str) -> None:
|
|
self._set("filename", filename)
|
|
|
|
@property
|
|
def target(self) -> Path:
|
|
return self._interpret_path(self._get("target", str, Path))
|
|
|
|
@target.setter
|
|
def target(self, path: Path) -> None:
|
|
self._set("target", path)
|
|
|
|
@property
|
|
def user(self) -> str:
|
|
return self._get("user", str)
|
|
|
|
@target.setter
|
|
def user(self, user: str) -> None:
|
|
self._set("user", user)
|
|
|
|
@property
|
|
def host(self) -> str:
|
|
return self._get("host", str)
|
|
|
|
@target.setter
|
|
def host(self, host: str) -> None:
|
|
self._set("host", host)
|