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.
Files changed (78) hide show
  1. hueman/__init__.py +11 -0
  2. hueman/__main__.py +3 -0
  3. hueman/cli.py +353 -0
  4. hueman/data/hueman.config.example.yaml +111 -0
  5. hueman/engine/__init__.py +0 -0
  6. hueman/engine/colors.py +30 -0
  7. hueman/engine/config.py +186 -0
  8. hueman/engine/fsutil.py +41 -0
  9. hueman/engine/palettes.py +66 -0
  10. hueman/engine/reload.py +60 -0
  11. hueman/engine/render.py +243 -0
  12. hueman/engine/statesource.py +370 -0
  13. hueman/engine/targets.py +95 -0
  14. hueman/palettes/arctic-ice.yaml +102 -0
  15. hueman/palettes/autumn.yaml +102 -0
  16. hueman/palettes/catppuccin-latte.yaml +102 -0
  17. hueman/palettes/catppuccin-mocha.yaml +102 -0
  18. hueman/palettes/copperwave.yaml +103 -0
  19. hueman/palettes/crimson-vivid.yaml +111 -0
  20. hueman/palettes/dracula.yaml +102 -0
  21. hueman/palettes/ember-red.yaml +111 -0
  22. hueman/palettes/everforest-dark.yaml +102 -0
  23. hueman/palettes/galaxy.yaml +102 -0
  24. hueman/palettes/gruvbox-light.yaml +102 -0
  25. hueman/palettes/gruvbox.yaml +102 -0
  26. hueman/palettes/kanagawa-dragon.yaml +102 -0
  27. hueman/palettes/matrix.yaml +102 -0
  28. hueman/palettes/mono-contrast.yaml +102 -0
  29. hueman/palettes/nord.yaml +102 -0
  30. hueman/palettes/ocean-deep.yaml +102 -0
  31. hueman/palettes/one-light.yaml +102 -0
  32. hueman/palettes/onyx-gold.yaml +103 -0
  33. hueman/palettes/rose-pine-dawn.yaml +102 -0
  34. hueman/palettes/solarized-light.yaml +102 -0
  35. hueman/palettes/synthwave.yaml +102 -0
  36. hueman/palettes/tokyonight.yaml +106 -0
  37. hueman/palettes/tuxedo.yaml +103 -0
  38. hueman/palettes/vaporwave.yaml +102 -0
  39. hueman/resources.py +18 -0
  40. hueman/templates/atuin_theme.toml.j2 +19 -0
  41. hueman/templates/bottom_theme.toml.j2 +43 -0
  42. hueman/templates/bspwm_colors.conf.j2 +5 -0
  43. hueman/templates/claude_statusline.sh.j2 +216 -0
  44. hueman/templates/dunst_colors.conf.j2 +15 -0
  45. hueman/templates/fzf_opts.j2 +9 -0
  46. hueman/templates/fzf_theme.j2 +17 -0
  47. hueman/templates/herdr_theme_custom.toml.j2 +11 -0
  48. hueman/templates/k9s_theme.yaml.j2 +83 -0
  49. hueman/templates/kitty.conf.j2 +51 -0
  50. hueman/templates/lazydocker_theme.yml.j2 +19 -0
  51. hueman/templates/leaf_theme.toml.j2 +89 -0
  52. hueman/templates/newt_colors.j2 +32 -0
  53. hueman/templates/nvim_nightfox_theme.lua.j2 +70 -0
  54. hueman/templates/omp.omp.json.j2 +107 -0
  55. hueman/templates/opencode_theme.json.j2 +71 -0
  56. hueman/templates/pi_theme.json.j2 +65 -0
  57. hueman/templates/polybar_colors.ini.j2 +11 -0
  58. hueman/templates/posting_theme.yaml.j2 +47 -0
  59. hueman/templates/qutebrowser_github.user.js.j2 +121 -0
  60. hueman/templates/qutebrowser_google.user.js.j2 +132 -0
  61. hueman/templates/qutebrowser_hteam.user.js.j2 +962 -0
  62. hueman/templates/qutebrowser_theme.py.j2 +78 -0
  63. hueman/templates/rofi_theme.rasi.j2 +132 -0
  64. hueman/templates/sshub_theme.toml.j2 +49 -0
  65. hueman/templates/starship_block.toml.j2 +11 -0
  66. hueman/templates/termux_colors.properties.j2 +20 -0
  67. hueman/templates/tmux.conf.j2 +33 -0
  68. hueman/templates/tuicr_theme.toml.j2 +51 -0
  69. hueman/templates/wezterm_colors.lua.j2 +14 -0
  70. hueman/templates/windhawk_taskbar.json.j2 +4 -0
  71. hueman/templates/yazi_theme.toml.j2 +130 -0
  72. hueman/templates/zen_theme.json.j2 +11 -0
  73. hueman_cli-0.2.0.dist-info/METADATA +119 -0
  74. hueman_cli-0.2.0.dist-info/RECORD +78 -0
  75. hueman_cli-0.2.0.dist-info/WHEEL +5 -0
  76. hueman_cli-0.2.0.dist-info/entry_points.txt +2 -0
  77. hueman_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
  78. hueman_cli-0.2.0.dist-info/top_level.txt +1 -0
@@ -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()
@@ -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)
@@ -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
@@ -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