hueman-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hueman/__init__.py +11 -0
- hueman/__main__.py +3 -0
- hueman/cli.py +353 -0
- hueman/data/hueman.config.example.yaml +111 -0
- hueman/engine/__init__.py +0 -0
- hueman/engine/colors.py +30 -0
- hueman/engine/config.py +186 -0
- hueman/engine/fsutil.py +41 -0
- hueman/engine/palettes.py +66 -0
- hueman/engine/reload.py +60 -0
- hueman/engine/render.py +243 -0
- hueman/engine/statesource.py +370 -0
- hueman/engine/targets.py +95 -0
- hueman/palettes/arctic-ice.yaml +102 -0
- hueman/palettes/autumn.yaml +102 -0
- hueman/palettes/catppuccin-latte.yaml +102 -0
- hueman/palettes/catppuccin-mocha.yaml +102 -0
- hueman/palettes/copperwave.yaml +103 -0
- hueman/palettes/crimson-vivid.yaml +111 -0
- hueman/palettes/dracula.yaml +102 -0
- hueman/palettes/ember-red.yaml +111 -0
- hueman/palettes/everforest-dark.yaml +102 -0
- hueman/palettes/galaxy.yaml +102 -0
- hueman/palettes/gruvbox-light.yaml +102 -0
- hueman/palettes/gruvbox.yaml +102 -0
- hueman/palettes/kanagawa-dragon.yaml +102 -0
- hueman/palettes/matrix.yaml +102 -0
- hueman/palettes/mono-contrast.yaml +102 -0
- hueman/palettes/nord.yaml +102 -0
- hueman/palettes/ocean-deep.yaml +102 -0
- hueman/palettes/one-light.yaml +102 -0
- hueman/palettes/onyx-gold.yaml +103 -0
- hueman/palettes/rose-pine-dawn.yaml +102 -0
- hueman/palettes/solarized-light.yaml +102 -0
- hueman/palettes/synthwave.yaml +102 -0
- hueman/palettes/tokyonight.yaml +106 -0
- hueman/palettes/tuxedo.yaml +103 -0
- hueman/palettes/vaporwave.yaml +102 -0
- hueman/resources.py +18 -0
- hueman/templates/atuin_theme.toml.j2 +19 -0
- hueman/templates/bottom_theme.toml.j2 +43 -0
- hueman/templates/bspwm_colors.conf.j2 +5 -0
- hueman/templates/claude_statusline.sh.j2 +216 -0
- hueman/templates/dunst_colors.conf.j2 +15 -0
- hueman/templates/fzf_opts.j2 +9 -0
- hueman/templates/fzf_theme.j2 +17 -0
- hueman/templates/herdr_theme_custom.toml.j2 +11 -0
- hueman/templates/k9s_theme.yaml.j2 +83 -0
- hueman/templates/kitty.conf.j2 +51 -0
- hueman/templates/lazydocker_theme.yml.j2 +19 -0
- hueman/templates/leaf_theme.toml.j2 +89 -0
- hueman/templates/newt_colors.j2 +32 -0
- hueman/templates/nvim_nightfox_theme.lua.j2 +70 -0
- hueman/templates/omp.omp.json.j2 +107 -0
- hueman/templates/opencode_theme.json.j2 +71 -0
- hueman/templates/pi_theme.json.j2 +65 -0
- hueman/templates/polybar_colors.ini.j2 +11 -0
- hueman/templates/posting_theme.yaml.j2 +47 -0
- hueman/templates/qutebrowser_github.user.js.j2 +121 -0
- hueman/templates/qutebrowser_google.user.js.j2 +132 -0
- hueman/templates/qutebrowser_hteam.user.js.j2 +962 -0
- hueman/templates/qutebrowser_theme.py.j2 +78 -0
- hueman/templates/rofi_theme.rasi.j2 +132 -0
- hueman/templates/sshub_theme.toml.j2 +49 -0
- hueman/templates/starship_block.toml.j2 +11 -0
- hueman/templates/termux_colors.properties.j2 +20 -0
- hueman/templates/tmux.conf.j2 +33 -0
- hueman/templates/tuicr_theme.toml.j2 +51 -0
- hueman/templates/wezterm_colors.lua.j2 +14 -0
- hueman/templates/windhawk_taskbar.json.j2 +4 -0
- hueman/templates/yazi_theme.toml.j2 +130 -0
- hueman/templates/zen_theme.json.j2 +11 -0
- hueman_cli-0.2.0.dist-info/METADATA +119 -0
- hueman_cli-0.2.0.dist-info/RECORD +78 -0
- hueman_cli-0.2.0.dist-info/WHEEL +5 -0
- hueman_cli-0.2.0.dist-info/entry_points.txt +2 -0
- hueman_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- hueman_cli-0.2.0.dist-info/top_level.txt +1 -0
hueman/engine/config.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Carga hueman.config.yaml (paths, targets, reload) y monta un logger
|
|
2
|
+
propio — sin depender de ningún gestor de dotfiles externo."""
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from .statesource import StateSyncConfigError, parse_settings
|
|
12
|
+
from .targets import FAILURE_POLICIES, load_targets
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConfigError(ValueError):
|
|
16
|
+
"""hueman.config.yaml is unreadable, malformed or has an invalid schema."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
VAR_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
20
|
+
_VAR_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def substitute_vars(text: str, variables: dict, where: str) -> str:
|
|
24
|
+
"""Replace every exact ``${name}`` in ``text`` with ``variables[name]``.
|
|
25
|
+
|
|
26
|
+
A ``$`` not followed by ``{name}`` is left alone. Replacement values are
|
|
27
|
+
inserted verbatim (no recursion). An unknown name raises ConfigError
|
|
28
|
+
naming the variable and ``where`` it was used."""
|
|
29
|
+
|
|
30
|
+
def repl(match):
|
|
31
|
+
name = match.group(1)
|
|
32
|
+
if name not in variables:
|
|
33
|
+
raise ConfigError(f"variable '${{{name}}}' no definida (usada en {where})")
|
|
34
|
+
return variables[name]
|
|
35
|
+
|
|
36
|
+
return _VAR_REF_RE.sub(repl, text)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _subst_paths(value, variables, where):
|
|
40
|
+
"""Substitute in a str or in each str of a list; other shapes pass through."""
|
|
41
|
+
if isinstance(value, str):
|
|
42
|
+
return substitute_vars(value, variables, where)
|
|
43
|
+
if isinstance(value, list):
|
|
44
|
+
return [substitute_vars(v, variables, where) if isinstance(v, str) else v for v in value]
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _validate_vars(path, raw_vars) -> dict:
|
|
49
|
+
if raw_vars is None:
|
|
50
|
+
return {}
|
|
51
|
+
if not isinstance(raw_vars, dict):
|
|
52
|
+
raise ConfigError(f"{path}: 'vars' debe ser un mapeo nombre -> valor")
|
|
53
|
+
for name, value in raw_vars.items():
|
|
54
|
+
if not isinstance(name, str) or not VAR_NAME_RE.fullmatch(name):
|
|
55
|
+
raise ConfigError(f"{path}: nombre de variable invalido en 'vars': {name!r}")
|
|
56
|
+
if not isinstance(value, str):
|
|
57
|
+
raise ConfigError(f"{path}: vars.{name} debe ser un string, no {value!r}")
|
|
58
|
+
if _VAR_REF_RE.search(value):
|
|
59
|
+
raise ConfigError(
|
|
60
|
+
f"{path}: vars.{name} no puede contener ${{...}} (las variables no se anidan)"
|
|
61
|
+
)
|
|
62
|
+
return dict(raw_vars)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class HuemanConfig:
|
|
66
|
+
def __init__(self, path: Path, var_overrides: dict = None):
|
|
67
|
+
self.path = path
|
|
68
|
+
try:
|
|
69
|
+
raw = yaml.safe_load(path.read_text()) or {}
|
|
70
|
+
except yaml.YAMLError as exc:
|
|
71
|
+
raise ConfigError(f"{path}: YAML invalido: {exc}") from exc
|
|
72
|
+
if not isinstance(raw, dict):
|
|
73
|
+
raise ConfigError(f"{path}: se esperaba un mapeo YAML en la raiz")
|
|
74
|
+
variables = _validate_vars(path, raw.get("vars"))
|
|
75
|
+
variables.update(_validate_vars(path, var_overrides))
|
|
76
|
+
try:
|
|
77
|
+
self.state_file = Path(
|
|
78
|
+
_subst_paths(raw.get("state_file", "~/.config/hueman/current"), variables, "state_file")
|
|
79
|
+
).expanduser()
|
|
80
|
+
self.palette_dirs = [
|
|
81
|
+
Path(p).expanduser() for p in _subst_paths(raw.get("palette_dirs", []), variables, "palette_dirs")
|
|
82
|
+
]
|
|
83
|
+
self.template_dirs = [
|
|
84
|
+
Path(p).expanduser() for p in _subst_paths(raw.get("template_dirs", []), variables, "template_dirs")
|
|
85
|
+
]
|
|
86
|
+
raw_targets = raw.get("targets") or {}
|
|
87
|
+
if not isinstance(raw_targets, dict) or not all(
|
|
88
|
+
isinstance(v, dict) for v in raw_targets.values()
|
|
89
|
+
):
|
|
90
|
+
raise ConfigError(f"{path}: 'targets' debe ser un mapeo nombre -> definicion")
|
|
91
|
+
raw_targets = {
|
|
92
|
+
name: self._substitute_target(name, cfg, variables) for name, cfg in raw_targets.items()
|
|
93
|
+
}
|
|
94
|
+
except ConfigError as exc:
|
|
95
|
+
if str(exc).startswith(str(path)):
|
|
96
|
+
raise
|
|
97
|
+
raise ConfigError(f"{path}: {exc}") from exc
|
|
98
|
+
try:
|
|
99
|
+
self.targets = load_targets(raw_targets)
|
|
100
|
+
except TypeError as exc:
|
|
101
|
+
raise ConfigError(f"{path}: target invalido: {exc}") from exc
|
|
102
|
+
self.reload_failure = raw.get("reload_failure", "warn")
|
|
103
|
+
if self.reload_failure not in FAILURE_POLICIES:
|
|
104
|
+
raise ConfigError(
|
|
105
|
+
f"{path}: reload_failure debe ser 'warn' o 'error', no {self.reload_failure!r}"
|
|
106
|
+
)
|
|
107
|
+
self.post_apply = self._validate_post_apply(path, raw.get("post_apply"))
|
|
108
|
+
self.state_sync = self._load_state_sync(path, raw.get("state_sync"))
|
|
109
|
+
self.logger = self._make_logger(self._validate_logging(path, raw.get("logging")))
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def _substitute_target(name, cfg, variables):
|
|
113
|
+
out = dict(cfg)
|
|
114
|
+
for key in ("outputs", "skip_unless_path_exists"):
|
|
115
|
+
if key in out:
|
|
116
|
+
out[key] = _subst_paths(out[key], variables, f"targets.{name}.{key}")
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def _validate_post_apply(path, raw):
|
|
121
|
+
if raw is None:
|
|
122
|
+
return []
|
|
123
|
+
if not isinstance(raw, list) or not all(isinstance(c, str) for c in raw):
|
|
124
|
+
raise ConfigError(f"{path}: 'post_apply' debe ser una lista de comandos (strings)")
|
|
125
|
+
return raw
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _validate_logging(path, raw) -> dict:
|
|
129
|
+
"""Return a checked copy of the ``logging`` section."""
|
|
130
|
+
if raw is None:
|
|
131
|
+
return {}
|
|
132
|
+
if not isinstance(raw, dict):
|
|
133
|
+
raise ConfigError(f"{path}: 'logging' debe ser un mapeo (level, file)")
|
|
134
|
+
level = raw.get("level", "INFO")
|
|
135
|
+
if not isinstance(level, str) or not isinstance(
|
|
136
|
+
logging.getLevelName(level.upper()), int
|
|
137
|
+
):
|
|
138
|
+
raise ConfigError(
|
|
139
|
+
f"{path}: logging.level desconocido: {level!r} "
|
|
140
|
+
"(usa DEBUG, INFO, WARNING, ERROR o CRITICAL)"
|
|
141
|
+
)
|
|
142
|
+
log_file = raw.get("file")
|
|
143
|
+
if log_file is not None:
|
|
144
|
+
if not isinstance(log_file, str) or not log_file:
|
|
145
|
+
raise ConfigError(f"{path}: logging.file debe ser una ruta (string)")
|
|
146
|
+
parent = Path(log_file).expanduser().parent
|
|
147
|
+
if not parent.is_dir():
|
|
148
|
+
raise ConfigError(f"{path}: logging.file: el directorio {parent} no existe")
|
|
149
|
+
if not os.access(parent, os.W_OK | os.X_OK):
|
|
150
|
+
raise ConfigError(f"{path}: logging.file: no se puede escribir en {parent}")
|
|
151
|
+
return {**raw, "level": level.upper()}
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def _load_state_sync(path, raw):
|
|
155
|
+
if raw is None:
|
|
156
|
+
return None
|
|
157
|
+
try:
|
|
158
|
+
return parse_settings(raw)
|
|
159
|
+
except StateSyncConfigError as exc:
|
|
160
|
+
raise ConfigError(f"{path}: {exc}") from exc
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _make_logger(logging_cfg: dict) -> logging.Logger:
|
|
164
|
+
logger = logging.getLogger("hueman")
|
|
165
|
+
logger.setLevel(logging_cfg.get("level", "INFO"))
|
|
166
|
+
if not logger.handlers:
|
|
167
|
+
handler = logging.StreamHandler()
|
|
168
|
+
handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
|
|
169
|
+
logger.addHandler(handler)
|
|
170
|
+
log_file = logging_cfg.get("file")
|
|
171
|
+
if log_file:
|
|
172
|
+
file_handler = logging.FileHandler(Path(log_file).expanduser())
|
|
173
|
+
file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s: %(message)s"))
|
|
174
|
+
logger.addHandler(file_handler)
|
|
175
|
+
return logger
|
|
176
|
+
|
|
177
|
+
@staticmethod
|
|
178
|
+
def resolve_path(explicit) -> Path:
|
|
179
|
+
if explicit:
|
|
180
|
+
return Path(explicit).expanduser()
|
|
181
|
+
env_path = os.environ.get("HUEMAN_CONFIG")
|
|
182
|
+
if env_path:
|
|
183
|
+
return Path(env_path).expanduser()
|
|
184
|
+
# el directorio actual NO se busca a proposito: un config puede ejecutar
|
|
185
|
+
# comandos (post_apply/reload) y no debe cargarse desde un repo ajeno
|
|
186
|
+
return Path("~/.config/hueman/config.yaml").expanduser()
|
hueman/engine/fsutil.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Atomic file writes: temp file in the same directory + os.replace."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def atomic_write_text(path: Path, content: str, make_executable: bool = False,
|
|
10
|
+
encoding: str = "utf-8") -> None:
|
|
11
|
+
"""Write `content` to `path` so readers never see a partial file.
|
|
12
|
+
|
|
13
|
+
The data goes to a temp file next to the target and is moved into place
|
|
14
|
+
with os.replace. An existing file's permission bits are preserved (new
|
|
15
|
+
files get the umask default); `make_executable` adds the x bits. On any
|
|
16
|
+
failure the original is untouched and the temp file is removed.
|
|
17
|
+
"""
|
|
18
|
+
path = Path(path)
|
|
19
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
try:
|
|
21
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
22
|
+
except FileNotFoundError:
|
|
23
|
+
umask = os.umask(0)
|
|
24
|
+
os.umask(umask)
|
|
25
|
+
mode = 0o666 & ~umask
|
|
26
|
+
if make_executable:
|
|
27
|
+
mode |= 0o111
|
|
28
|
+
fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
|
29
|
+
try:
|
|
30
|
+
with os.fdopen(fd, "w", encoding=encoding) as fh:
|
|
31
|
+
fh.write(content)
|
|
32
|
+
fh.flush()
|
|
33
|
+
os.fsync(fh.fileno())
|
|
34
|
+
os.chmod(tmp_name, mode)
|
|
35
|
+
os.replace(tmp_name, path)
|
|
36
|
+
except BaseException:
|
|
37
|
+
try:
|
|
38
|
+
os.unlink(tmp_name)
|
|
39
|
+
except FileNotFoundError:
|
|
40
|
+
pass
|
|
41
|
+
raise
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Carga de paletas YAML: empaquetadas dentro del tool + directorios custom
|
|
2
|
+
del usuario, con precedencia de los custom sobre los empaquetados."""
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
PALETTE_NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def is_valid_palette_name(name) -> bool:
|
|
13
|
+
"""Nombre seguro: sin separadores de ruta, sin '..', sin espacios ni saltos
|
|
14
|
+
de linea. El nombre llega a rutas, logs y comandos (y con `sync` lo controla
|
|
15
|
+
el remoto), asi que se valida antes de tocar el disco."""
|
|
16
|
+
return (isinstance(name, str) and PALETTE_NAME_RE.fullmatch(name) is not None
|
|
17
|
+
and ".." not in name)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PaletteLoader:
|
|
21
|
+
def __init__(self, packaged_dir: Path, custom_dirs: list, logger):
|
|
22
|
+
self.packaged_dir = packaged_dir
|
|
23
|
+
self.custom_dirs = custom_dirs
|
|
24
|
+
self.logger = logger
|
|
25
|
+
|
|
26
|
+
def _search_dirs(self):
|
|
27
|
+
# los custom_dirs (en orden declarado) tienen precedencia sobre lo empaquetado
|
|
28
|
+
return list(self.custom_dirs) + [self.packaged_dir]
|
|
29
|
+
|
|
30
|
+
def list_palettes(self) -> list:
|
|
31
|
+
found = {}
|
|
32
|
+
for d in self._search_dirs():
|
|
33
|
+
if not d.exists():
|
|
34
|
+
continue
|
|
35
|
+
for p in d.glob("*.yaml"):
|
|
36
|
+
if p.stem in found and found[p.stem] != p:
|
|
37
|
+
self.logger.warning(
|
|
38
|
+
f"Paleta '{p.stem}' definida en más de un directorio, "
|
|
39
|
+
f"gana la de mayor precedencia: {found[p.stem]}"
|
|
40
|
+
)
|
|
41
|
+
continue
|
|
42
|
+
found.setdefault(p.stem, p)
|
|
43
|
+
return sorted(found)
|
|
44
|
+
|
|
45
|
+
def load(self, name: str) -> dict:
|
|
46
|
+
if not is_valid_palette_name(name):
|
|
47
|
+
self.logger.error(
|
|
48
|
+
f"Nombre de paleta invalido: {name!r} "
|
|
49
|
+
"(solo letras, digitos, '.', '_' y '-'; debe empezar por letra o digito)"
|
|
50
|
+
)
|
|
51
|
+
raise SystemExit(1)
|
|
52
|
+
for d in self._search_dirs():
|
|
53
|
+
path = d / f"{name}.yaml"
|
|
54
|
+
if path.exists():
|
|
55
|
+
try:
|
|
56
|
+
data = yaml.safe_load(path.read_text())
|
|
57
|
+
except yaml.YAMLError as exc:
|
|
58
|
+
self.logger.error(f"Paleta invalida {path}: {exc}")
|
|
59
|
+
raise SystemExit(1)
|
|
60
|
+
if not isinstance(data, dict):
|
|
61
|
+
self.logger.error(f"Paleta invalida {path}: se esperaba un mapeo YAML")
|
|
62
|
+
raise SystemExit(1)
|
|
63
|
+
return data
|
|
64
|
+
available = ", ".join(self.list_palettes())
|
|
65
|
+
self.logger.error(f"Paleta no encontrada: {name}. Disponibles: {available}")
|
|
66
|
+
raise SystemExit(1)
|
hueman/engine/reload.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Ejecuta comandos de recarga: uno por target (best-effort, tras escribir
|
|
2
|
+
con éxito) y los `post_apply` globales (una sola vez al final de `set`)."""
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import shlex
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
from .render import _dig
|
|
9
|
+
|
|
10
|
+
# `{name}`, `{palette}` o `{seccion.clave}`; el lookbehind deja intactos
|
|
11
|
+
# `${VAR}` del shell. Llaves simples sin punto (ej. `find -exec {} +`) no
|
|
12
|
+
# matchean salvo `name`/`palette`.
|
|
13
|
+
_PLACEHOLDER = re.compile(r"(?<!\$)\{([A-Za-z_]\w*(?:\.\w+)*)\}")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PlaceholderError(ValueError):
|
|
17
|
+
"""Un `{seccion.clave}` de reload/post_apply no existe en la paleta."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def expand_command(cmd: str, name: str, palette: dict) -> str:
|
|
21
|
+
"""Sustituye `{name}`/`{palette}` y `{a.b}` (lookup en la paleta activa)
|
|
22
|
+
por su valor con shlex.quote. Un `{a.b}` que no resuelve es un error."""
|
|
23
|
+
def repl(m):
|
|
24
|
+
key = m.group(1)
|
|
25
|
+
if key in ("name", "palette"):
|
|
26
|
+
return shlex.quote(name)
|
|
27
|
+
if "." not in key:
|
|
28
|
+
return m.group(0)
|
|
29
|
+
try:
|
|
30
|
+
value = _dig(palette, key)
|
|
31
|
+
except (KeyError, TypeError):
|
|
32
|
+
raise PlaceholderError(f"placeholder desconocido {{{key}}} en comando: {cmd}")
|
|
33
|
+
return shlex.quote(str(value))
|
|
34
|
+
|
|
35
|
+
return _PLACEHOLDER.sub(repl, cmd)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _run(label: str, cmd: str, logger) -> int:
|
|
39
|
+
"""Run `cmd` through the shell and log its exit code."""
|
|
40
|
+
rc = subprocess.run(["sh", "-c", cmd]).returncode
|
|
41
|
+
logger.info(f"{label}: {cmd} (exit {rc})")
|
|
42
|
+
return rc
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run_reload(cmd: str, logger) -> int:
|
|
46
|
+
"""Run a target reload command; return its exit code (0 if no command)."""
|
|
47
|
+
if not cmd:
|
|
48
|
+
return 0
|
|
49
|
+
return _run("reload", cmd, logger)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run_post_apply(commands: list, logger) -> list:
|
|
53
|
+
"""Run every command (a failure does not stop the rest); return the
|
|
54
|
+
`(command, exit_code)` pairs of those that exited non-zero."""
|
|
55
|
+
failed = []
|
|
56
|
+
for cmd in commands:
|
|
57
|
+
rc = _run("post_apply", cmd, logger)
|
|
58
|
+
if rc:
|
|
59
|
+
failed.append((cmd, rc))
|
|
60
|
+
return failed
|
hueman/engine/render.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Motor de render genérico: dos modos (overwrite, patch) compartidos por
|
|
2
|
+
TODOS los targets — nada de código Python específico por herramienta."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import jinja2
|
|
9
|
+
import jinja2.sandbox
|
|
10
|
+
|
|
11
|
+
from .colors import contrast_text, is_dark
|
|
12
|
+
from .fsutil import atomic_write_text
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TargetRenderError(Exception):
|
|
16
|
+
"""A target could not be rendered (e.g. the palette lacks a required key)."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _dig(data: dict, dotted_path: str):
|
|
20
|
+
cur = data
|
|
21
|
+
for part in dotted_path.split("."):
|
|
22
|
+
cur = cur[part]
|
|
23
|
+
return cur
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def make_jinja_env(template_dirs: list, packaged_dir: Path, delimiters=None) -> jinja2.sandbox.SandboxedEnvironment:
|
|
27
|
+
"""Entorno Jinja en sandbox: una plantilla ajena no alcanza internals de
|
|
28
|
+
Python (`__class__`, `__globals__`...).
|
|
29
|
+
|
|
30
|
+
ChoiceLoader: los template_dirs custom del usuario se resuelven ANTES
|
|
31
|
+
que los empaquetados — así un target idiosincrático puede referenciar un
|
|
32
|
+
template por nombre igual que uno genérico."""
|
|
33
|
+
loader = jinja2.ChoiceLoader([
|
|
34
|
+
jinja2.FileSystemLoader([str(d) for d in template_dirs]),
|
|
35
|
+
jinja2.FileSystemLoader(str(packaged_dir)),
|
|
36
|
+
])
|
|
37
|
+
kwargs = {"loader": loader, "keep_trailing_newline": True}
|
|
38
|
+
if delimiters:
|
|
39
|
+
kwargs["variable_start_string"], kwargs["variable_end_string"] = delimiters
|
|
40
|
+
env = jinja2.sandbox.SandboxedEnvironment(**kwargs)
|
|
41
|
+
env.filters["contrast"] = contrast_text
|
|
42
|
+
env.filters["is_dark"] = is_dark
|
|
43
|
+
return env
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_context(target, name: str, palette: dict) -> dict:
|
|
47
|
+
context = {"theme_name": name}
|
|
48
|
+
if target.palette_key and target.palette_key in palette:
|
|
49
|
+
context.update(palette[target.palette_key])
|
|
50
|
+
for key, dotted_path in target.extra_vars.items():
|
|
51
|
+
try:
|
|
52
|
+
context[key] = _dig(palette, dotted_path)
|
|
53
|
+
except (KeyError, TypeError):
|
|
54
|
+
raise KeyError(dotted_path) from None
|
|
55
|
+
return context
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def render_template(env: jinja2.Environment, target, name: str, palette: dict) -> str:
|
|
59
|
+
context = build_context(target, name, palette)
|
|
60
|
+
return env.get_template(target.template).render(**context)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _write(path: Path, content: str, dry_run: bool, logger, make_executable: bool = False):
|
|
64
|
+
if dry_run:
|
|
65
|
+
print(f"[DRY RUN] escribiría {path}")
|
|
66
|
+
return
|
|
67
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
atomic_write_text(path, content, make_executable)
|
|
69
|
+
logger.info(f"Generado: {path}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _missing_palette_section(target, palette, logger) -> bool:
|
|
73
|
+
"""True (con log INFO) si el target apunta a una seccion que la paleta no tiene."""
|
|
74
|
+
if target.palette_key and target.palette_key not in palette:
|
|
75
|
+
logger.info(f"{target.name}: la paleta no tiene la seccion '{target.palette_key}', omitido")
|
|
76
|
+
return True
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def render_overwrite(env, target, name, palette, dry_run, logger) -> bool:
|
|
81
|
+
if _missing_palette_section(target, palette, logger):
|
|
82
|
+
return False
|
|
83
|
+
content = render_template(env, target, name, palette)
|
|
84
|
+
wrote = False
|
|
85
|
+
for out in target.outputs:
|
|
86
|
+
if target.skip_if_missing_parent and not out.parent.exists():
|
|
87
|
+
logger.info(f"{target.name}: {out.parent} no existe, omitido")
|
|
88
|
+
continue
|
|
89
|
+
_write(out, content, dry_run, logger, target.make_executable)
|
|
90
|
+
wrote = True
|
|
91
|
+
return wrote
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _apply_regex_splice(original: str, rendered: str, patch, logger) -> str:
|
|
95
|
+
if patch.marker_regex:
|
|
96
|
+
# Bloque delimitado por un único regex (ej. una sección TOML entera).
|
|
97
|
+
block = rendered
|
|
98
|
+
pattern = re.compile(patch.marker_regex, re.DOTALL)
|
|
99
|
+
inserted = not pattern.search(original)
|
|
100
|
+
if not inserted:
|
|
101
|
+
content = pattern.sub(lambda m: block, original, count=1)
|
|
102
|
+
elif patch.insert_after_section_regex:
|
|
103
|
+
section = re.search(patch.insert_after_section_regex, original, re.DOTALL)
|
|
104
|
+
if section:
|
|
105
|
+
insert_at = section.end()
|
|
106
|
+
content = original[:insert_at] + "\n" + block + original[insert_at:]
|
|
107
|
+
else:
|
|
108
|
+
content = original.rstrip("\n") + "\n\n" + block
|
|
109
|
+
else:
|
|
110
|
+
content = original.rstrip("\n") + "\n\n" + block
|
|
111
|
+
# Si el regex no reconoce el bloque recién insertado, se duplicará.
|
|
112
|
+
if inserted and not pattern.search(content):
|
|
113
|
+
logger.warning(
|
|
114
|
+
"marker_regex no reconoce el bloque insertado; "
|
|
115
|
+
"se volvera a insertar en la siguiente ejecucion"
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
# Marcadores begin/end literales envolviendo el contenido renderizado.
|
|
119
|
+
marker_begin = patch.marker_begin
|
|
120
|
+
marker_end = patch.marker_end
|
|
121
|
+
if patch.marker_end_regex:
|
|
122
|
+
end_pattern = patch.marker_end_regex
|
|
123
|
+
else:
|
|
124
|
+
# Consume the newline after END: full_block re-adds it.
|
|
125
|
+
end_pattern = re.escape(marker_end) + r"\n?"
|
|
126
|
+
pattern = re.compile(re.escape(marker_begin) + r".*?" + end_pattern, re.DOTALL)
|
|
127
|
+
if marker_end and not patch.marker_end_regex:
|
|
128
|
+
full_block = f"{marker_begin}\n{rendered}{marker_end}\n"
|
|
129
|
+
else:
|
|
130
|
+
full_block = f"{marker_begin}\n{rendered}"
|
|
131
|
+
if pattern.search(original):
|
|
132
|
+
content = pattern.sub(lambda m: full_block, original, count=1)
|
|
133
|
+
else:
|
|
134
|
+
content = original.rstrip("\n") + "\n\n" + full_block
|
|
135
|
+
|
|
136
|
+
for old, new in patch.replacements:
|
|
137
|
+
content = content.replace(old, new)
|
|
138
|
+
for rule in patch.ensure_after:
|
|
139
|
+
if not re.search(rule["if_missing_regex"], content):
|
|
140
|
+
content = content.replace(rule["anchor"], rule["anchor"] + rule["insert"], 1)
|
|
141
|
+
return content
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _apply_json_scheme_upsert(original: str, kitty: dict, scheme_name: str) -> str:
|
|
145
|
+
data = json.loads(original)
|
|
146
|
+
scheme = {
|
|
147
|
+
"name": scheme_name,
|
|
148
|
+
"background": kitty["background"],
|
|
149
|
+
"foreground": kitty["foreground"],
|
|
150
|
+
"black": kitty["color0"],
|
|
151
|
+
"red": kitty["color1"],
|
|
152
|
+
"green": kitty["color2"],
|
|
153
|
+
"yellow": kitty["color3"],
|
|
154
|
+
"blue": kitty["color4"],
|
|
155
|
+
"purple": kitty["color5"],
|
|
156
|
+
"cyan": kitty["color6"],
|
|
157
|
+
"white": kitty["color7"],
|
|
158
|
+
"brightBlack": kitty["color8"],
|
|
159
|
+
"brightRed": kitty["color9"],
|
|
160
|
+
"brightGreen": kitty["color10"],
|
|
161
|
+
"brightYellow": kitty["color11"],
|
|
162
|
+
"brightBlue": kitty["color12"],
|
|
163
|
+
"brightPurple": kitty["color13"],
|
|
164
|
+
"brightCyan": kitty["color14"],
|
|
165
|
+
"brightWhite": kitty["color15"],
|
|
166
|
+
"cursorColor": kitty["cursor"],
|
|
167
|
+
"selectionBackground": kitty["selection_background"],
|
|
168
|
+
}
|
|
169
|
+
schemes = data.setdefault("schemes", [])
|
|
170
|
+
for i, s in enumerate(schemes):
|
|
171
|
+
if s.get("name") == scheme_name:
|
|
172
|
+
schemes[i] = scheme
|
|
173
|
+
break
|
|
174
|
+
else:
|
|
175
|
+
schemes.append(scheme)
|
|
176
|
+
return json.dumps(data, indent=4, ensure_ascii=False) + "\n"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def render_patch(env, target, name, palette, dry_run, logger) -> bool:
|
|
180
|
+
patch = target.patch
|
|
181
|
+
if _missing_palette_section(target, palette, logger):
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
if patch.strategy == "json_scheme_upsert":
|
|
185
|
+
section = palette[patch.palette_section]
|
|
186
|
+
scheme_name = patch.scheme_name or "Dotfiles"
|
|
187
|
+
wrote = False
|
|
188
|
+
for out in target.outputs:
|
|
189
|
+
if not out.exists():
|
|
190
|
+
logger.info(f"{target.name}: {out} no encontrado, omitido")
|
|
191
|
+
continue
|
|
192
|
+
original = out.read_text(encoding="utf-8")
|
|
193
|
+
try:
|
|
194
|
+
content = _apply_json_scheme_upsert(original, section, scheme_name)
|
|
195
|
+
except json.JSONDecodeError as exc:
|
|
196
|
+
raise TargetRenderError(f"{target.name}: {out} no es JSON valido ({exc})") from exc
|
|
197
|
+
wrote = True
|
|
198
|
+
if dry_run:
|
|
199
|
+
print(f"[DRY RUN] escribiría {out} (scheme '{scheme_name}')")
|
|
200
|
+
continue
|
|
201
|
+
atomic_write_text(out, content)
|
|
202
|
+
logger.info(f"{target.name}: scheme '{scheme_name}' actualizado en {out}")
|
|
203
|
+
return wrote
|
|
204
|
+
|
|
205
|
+
# regex_splice
|
|
206
|
+
rendered = render_template(env, target, name, palette) if target.template else ""
|
|
207
|
+
wrote = False
|
|
208
|
+
for out in target.outputs:
|
|
209
|
+
if target.skip_if_missing_parent and not out.parent.exists():
|
|
210
|
+
logger.info(f"{target.name}: {out.parent} no existe, omitido")
|
|
211
|
+
continue
|
|
212
|
+
if not out.exists():
|
|
213
|
+
logger.info(f"{target.name}: {out} no encontrado, omitido")
|
|
214
|
+
continue
|
|
215
|
+
original = out.read_text()
|
|
216
|
+
content = _apply_regex_splice(original, rendered, patch, logger)
|
|
217
|
+
_write(out, content, dry_run, logger, target.make_executable)
|
|
218
|
+
wrote = True
|
|
219
|
+
return wrote
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def render_target(env, target, name, palette, dry_run, logger) -> bool:
|
|
223
|
+
missing = [p for p in target.skip_unless_path_exists if not p.exists()]
|
|
224
|
+
if missing:
|
|
225
|
+
logger.info(f"{target.name}: {missing[0]} no existe, target omitido")
|
|
226
|
+
return False
|
|
227
|
+
try:
|
|
228
|
+
if target.mode == "overwrite":
|
|
229
|
+
return render_overwrite(env, target, name, palette, dry_run, logger)
|
|
230
|
+
return render_patch(env, target, name, palette, dry_run, logger)
|
|
231
|
+
except jinja2.TemplateNotFound as exc:
|
|
232
|
+
raise TargetRenderError(f"{target.name}: plantilla {exc.name!r} no encontrada") from exc
|
|
233
|
+
except jinja2.exceptions.SecurityError as exc:
|
|
234
|
+
raise TargetRenderError(
|
|
235
|
+
f"{target.name}: la plantilla intento una operacion no permitida en el sandbox ({exc})"
|
|
236
|
+
) from exc
|
|
237
|
+
except OSError as exc:
|
|
238
|
+
raise TargetRenderError(f"{target.name}: error de E/S ({exc})") from exc
|
|
239
|
+
except (KeyError, TypeError) as exc:
|
|
240
|
+
key = exc.args[0] if exc.args else exc
|
|
241
|
+
raise TargetRenderError(
|
|
242
|
+
f"{target.name}: la paleta '{name}' no tiene la clave {key!r} requerida"
|
|
243
|
+
) from exc
|