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
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Shared palette state behind a small `StateSource` interface.
|
|
2
|
+
|
|
3
|
+
The engine only ever talks to `StateSource` (see its docstring for the
|
|
4
|
+
contract) and obtains one through `build_source(settings)`; the backend is
|
|
5
|
+
chosen declaratively by `state_sync.type` and looked up in a registry.
|
|
6
|
+
Backends: `git` (default) and `file` (a state file in a synced folder).
|
|
7
|
+
|
|
8
|
+
`GitBranchSource` keeps the state in an orphan branch (`hueman-state`) holding
|
|
9
|
+
a single parentless commit with `hueman.state.yaml`. All git work happens in a
|
|
10
|
+
hueman-owned bare repository (default: XDG state dir), built with plumbing
|
|
11
|
+
commands, so the user's dotfiles repo and working tree are never touched; the
|
|
12
|
+
dotfiles repo is only consulted to resolve the remote URL.
|
|
13
|
+
|
|
14
|
+
Rewriting the single commit with `commit-tree` (no parent) is the plumbing
|
|
15
|
+
equivalent of `commit --amend`; it is published with `push --force-with-lease`.
|
|
16
|
+
Every failure mode (no git, no remote, no network) degrades to None / False
|
|
17
|
+
and never raises, so callers can keep applying palettes locally.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
from abc import ABC, abstractmethod
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Callable, ClassVar, Optional
|
|
29
|
+
|
|
30
|
+
import yaml
|
|
31
|
+
|
|
32
|
+
from .fsutil import atomic_write_text
|
|
33
|
+
from .palettes import is_valid_palette_name
|
|
34
|
+
|
|
35
|
+
STATE_FILE_NAME = "hueman.state.yaml"
|
|
36
|
+
DEFAULT_BRANCH = "hueman-state"
|
|
37
|
+
GIT_TIMEOUT = 15 # seconds per git call; keeps shell-startup sync bounded
|
|
38
|
+
_REMOTE_REF = "refs/hueman/remote"
|
|
39
|
+
_IDENTITY = ["-c", "user.name=hueman", "-c", "user.email=hueman@localhost",
|
|
40
|
+
"-c", "commit.gpgsign=false"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class State:
|
|
45
|
+
palette: str
|
|
46
|
+
revision: int
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class ReadResult:
|
|
51
|
+
"""Outcome of a read. `remote` is True only when the state was just
|
|
52
|
+
fetched from the remote (an empty remote is still remote=True, state=None);
|
|
53
|
+
False means the remote was unreachable and `state` is the local cache."""
|
|
54
|
+
state: Optional[State]
|
|
55
|
+
remote: bool
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class StateSyncSettings:
|
|
60
|
+
"""Settings of the `git` backend (`state_sync.type: git`, the default)."""
|
|
61
|
+
type: ClassVar[str] = "git"
|
|
62
|
+
repo: Path
|
|
63
|
+
remote: str = "origin"
|
|
64
|
+
branch: str = DEFAULT_BRANCH
|
|
65
|
+
workdir: Optional[Path] = None
|
|
66
|
+
timeout: int = GIT_TIMEOUT
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class StateSource(ABC):
|
|
70
|
+
"""Backend holding the shared palette state.
|
|
71
|
+
|
|
72
|
+
Contract every backend must honour (checked by
|
|
73
|
+
tests/test_state_source_contract.py):
|
|
74
|
+
|
|
75
|
+
* Never raise: every failure (missing tool, unreachable store, corrupt
|
|
76
|
+
data, I/O error, timeout) degrades to the "unreachable"/False results
|
|
77
|
+
below so callers can keep applying palettes locally.
|
|
78
|
+
* Bounded time: every blocking operation has a timeout; a backend must
|
|
79
|
+
not hang shell startup.
|
|
80
|
+
* `revision` is a monotonic integer. Higher revision wins; on a tie the
|
|
81
|
+
accepted write wins (last writer), mirroring the git push rule.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
@abstractmethod
|
|
85
|
+
def read_result(self) -> ReadResult:
|
|
86
|
+
"""Read the shared state.
|
|
87
|
+
|
|
88
|
+
`ReadResult(state, remote=True)`: the store was reached; `state` is
|
|
89
|
+
None when it is empty or its content is unparseable.
|
|
90
|
+
`ReadResult(state, remote=False)`: the store was unreachable; `state`
|
|
91
|
+
is a possibly stale local cache or None, and callers must not apply it.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def read(self) -> Optional[State]:
|
|
95
|
+
"""Convenience: just the state of `read_result()`."""
|
|
96
|
+
return self.read_result().state
|
|
97
|
+
|
|
98
|
+
@abstractmethod
|
|
99
|
+
def write(self, state: State) -> bool:
|
|
100
|
+
"""Publish `state`; True only if it is now the shared state.
|
|
101
|
+
|
|
102
|
+
Returns True when the store already holds exactly `state`. Returns
|
|
103
|
+
False, without overwriting, when the store holds a strictly higher
|
|
104
|
+
revision, and False when the store is unreachable or the write fails.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def dump_state(state: State) -> str:
|
|
109
|
+
return yaml.safe_dump({"palette": state.palette, "revision": state.revision},
|
|
110
|
+
sort_keys=False)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def parse_state(text: str) -> Optional[State]:
|
|
114
|
+
try:
|
|
115
|
+
raw = yaml.safe_load(text)
|
|
116
|
+
state = State(str(raw["palette"]), int(raw["revision"]))
|
|
117
|
+
except (yaml.YAMLError, KeyError, TypeError, ValueError):
|
|
118
|
+
return None
|
|
119
|
+
# el nombre viene del remoto: uno invalido (ruta, '..') es dato inutilizable
|
|
120
|
+
return state if is_valid_palette_name(state.palette) else None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class GitBranchSource(StateSource):
|
|
124
|
+
def __init__(self, repo, remote="origin", branch=DEFAULT_BRANCH, workdir=None,
|
|
125
|
+
max_retries=3, logger=None, timeout=GIT_TIMEOUT):
|
|
126
|
+
self.timeout = timeout
|
|
127
|
+
self.repo = Path(repo).expanduser()
|
|
128
|
+
self.remote = remote
|
|
129
|
+
self.branch = branch
|
|
130
|
+
self.max_retries = max_retries
|
|
131
|
+
self.log = logger or logging.getLogger("hueman")
|
|
132
|
+
self._workdir = Path(workdir).expanduser() if workdir else None
|
|
133
|
+
self._url = None
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def from_settings(cls, s: StateSyncSettings, logger=None):
|
|
137
|
+
return cls(s.repo, s.remote, s.branch, s.workdir, logger=logger, timeout=s.timeout)
|
|
138
|
+
|
|
139
|
+
# -- git plumbing -------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def _run(self, args, git_dir=True, input=None, cwd=None):
|
|
142
|
+
cmd = ["git"]
|
|
143
|
+
if git_dir:
|
|
144
|
+
cmd += ["--git-dir", str(self._workdir)]
|
|
145
|
+
env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
|
|
146
|
+
env["GIT_TERMINAL_PROMPT"] = "0"
|
|
147
|
+
for keep in ("GIT_SSH", "GIT_SSH_COMMAND"): # user's transport choice
|
|
148
|
+
if keep in os.environ:
|
|
149
|
+
env[keep] = os.environ[keep]
|
|
150
|
+
if "GIT_SSH" not in env:
|
|
151
|
+
env.setdefault("GIT_SSH_COMMAND", "ssh -o BatchMode=yes") # never prompt
|
|
152
|
+
try:
|
|
153
|
+
return subprocess.run(cmd + args, input=input, cwd=cwd, env=env,
|
|
154
|
+
capture_output=True, text=True, timeout=self.timeout)
|
|
155
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
156
|
+
self.log.debug(f"git {' '.join(args)}: {exc}")
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
def _ok(self, args, **kw):
|
|
160
|
+
res = self._run(args, **kw)
|
|
161
|
+
return res.stdout.strip() if res is not None and res.returncode == 0 else None
|
|
162
|
+
|
|
163
|
+
def _prepare(self) -> bool:
|
|
164
|
+
"""Resolve the remote URL and make sure the private repo exists."""
|
|
165
|
+
if shutil.which("git") is None:
|
|
166
|
+
self.log.warning("git no disponible: estado compartido deshabilitado")
|
|
167
|
+
return False
|
|
168
|
+
url = self._ok(["-C", str(self.repo), "remote", "get-url", self.remote],
|
|
169
|
+
git_dir=False) if self.repo.is_dir() else None
|
|
170
|
+
if not url:
|
|
171
|
+
self.log.warning(f"remoto '{self.remote}' no disponible en {self.repo}")
|
|
172
|
+
return False
|
|
173
|
+
self._url = url
|
|
174
|
+
if self._workdir is None:
|
|
175
|
+
base = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state")
|
|
176
|
+
digest = hashlib.sha1(f"{url}\0{self.branch}".encode()).hexdigest()[:12]
|
|
177
|
+
self._workdir = base / "hueman" / "state-sync" / f"{digest}.git"
|
|
178
|
+
if not (self._workdir / "HEAD").exists():
|
|
179
|
+
self._workdir.parent.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
if self._ok(["init", "--bare", "-q", str(self._workdir)], git_dir=False) is None:
|
|
181
|
+
self.log.warning(f"no se pudo crear el repo de estado en {self._workdir}")
|
|
182
|
+
return False
|
|
183
|
+
return True
|
|
184
|
+
|
|
185
|
+
def _fetch(self):
|
|
186
|
+
"""Refresh the remote tip. Returns (ok, sha-or-None)."""
|
|
187
|
+
ls = self._ok(["ls-remote", self._url, f"refs/heads/{self.branch}"])
|
|
188
|
+
if ls is None:
|
|
189
|
+
self.log.warning(f"remoto inalcanzable: {self._url}")
|
|
190
|
+
return False, None
|
|
191
|
+
if not ls:
|
|
192
|
+
self._run(["update-ref", "-d", _REMOTE_REF])
|
|
193
|
+
return True, None
|
|
194
|
+
spec = f"+refs/heads/{self.branch}:{_REMOTE_REF}"
|
|
195
|
+
if self._ok(["fetch", "-q", "--no-tags", self._url, spec]) is None:
|
|
196
|
+
self.log.warning(f"fetch fallo desde {self._url}")
|
|
197
|
+
return False, None
|
|
198
|
+
return True, self._ok(["rev-parse", _REMOTE_REF])
|
|
199
|
+
|
|
200
|
+
def _state_at(self, ref) -> Optional[State]:
|
|
201
|
+
text = self._ok(["cat-file", "-p", f"{ref}:{STATE_FILE_NAME}"])
|
|
202
|
+
return parse_state(text) if text else None
|
|
203
|
+
|
|
204
|
+
def _commit(self, state: State) -> Optional[str]:
|
|
205
|
+
blob = self._ok(["hash-object", "-w", "--stdin"], input=dump_state(state))
|
|
206
|
+
tree = self._ok(["mktree"], input=f"100644 blob {blob}\t{STATE_FILE_NAME}\n") if blob else None
|
|
207
|
+
if not tree:
|
|
208
|
+
return None
|
|
209
|
+
return self._ok(_IDENTITY + ["commit-tree", tree, "-m", f"hueman state r{state.revision}"])
|
|
210
|
+
|
|
211
|
+
def _push(self, sha: str, expected: Optional[str]) -> bool:
|
|
212
|
+
ref = f"refs/heads/{self.branch}"
|
|
213
|
+
res = self._run(["push", "-q", f"--force-with-lease={ref}:{expected or ''}",
|
|
214
|
+
self._url, f"{sha}:{ref}"])
|
|
215
|
+
return res is not None and res.returncode == 0
|
|
216
|
+
|
|
217
|
+
# -- StateSource --------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def read_result(self) -> ReadResult:
|
|
220
|
+
if not self._prepare():
|
|
221
|
+
return ReadResult(None, False)
|
|
222
|
+
ok, sha = self._fetch()
|
|
223
|
+
if ok:
|
|
224
|
+
return ReadResult(self._state_at(sha) if sha else None, True)
|
|
225
|
+
# remote unreachable: last known local copy, flagged as such
|
|
226
|
+
# (last fetched tip; readers never write refs/heads, only publishers do)
|
|
227
|
+
cached = self._state_at(_REMOTE_REF) or self._state_at(f"refs/heads/{self.branch}")
|
|
228
|
+
return ReadResult(cached, False)
|
|
229
|
+
|
|
230
|
+
def write(self, state: State) -> bool:
|
|
231
|
+
if not self._prepare():
|
|
232
|
+
return False
|
|
233
|
+
for _ in range(self.max_retries + 1):
|
|
234
|
+
ok, remote_sha = self._fetch()
|
|
235
|
+
if not ok:
|
|
236
|
+
return False
|
|
237
|
+
remote = self._state_at(remote_sha) if remote_sha else None
|
|
238
|
+
if remote is not None:
|
|
239
|
+
if remote.revision > state.revision:
|
|
240
|
+
self.log.warning(
|
|
241
|
+
f"estado remoto mas nuevo (r{remote.revision} > r{state.revision}, "
|
|
242
|
+
f"paleta {remote.palette}); no se publica")
|
|
243
|
+
return False
|
|
244
|
+
if remote == state:
|
|
245
|
+
return True
|
|
246
|
+
sha = self._commit(state)
|
|
247
|
+
if sha is None:
|
|
248
|
+
self.log.warning("no se pudo construir el commit de estado")
|
|
249
|
+
return False
|
|
250
|
+
if self._push(sha, remote_sha):
|
|
251
|
+
self._run(["update-ref", f"refs/heads/{self.branch}", sha])
|
|
252
|
+
return True
|
|
253
|
+
self.log.warning("push de estado rechazado repetidamente; se desiste")
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class FileSource(StateSource):
|
|
258
|
+
"""State file in a folder synced by something else (Syncthing, Dropbox...).
|
|
259
|
+
|
|
260
|
+
Same YAML format as the git backend. The write is atomic (temp file +
|
|
261
|
+
rename via `engine.fsutil`), so the sync tool never uploads a partial file.
|
|
262
|
+
There is no cross-node lock: simultaneous writers are reconciled by the
|
|
263
|
+
sync tool (e.g. conflict copies) and by the higher-revision-wins rule on
|
|
264
|
+
the next read/write. A missing parent folder means the synced folder is
|
|
265
|
+
not mounted: unreachable, and it is never created.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
def __init__(self, path, logger=None):
|
|
269
|
+
self.path = Path(path).expanduser()
|
|
270
|
+
self.log = logger or logging.getLogger("hueman")
|
|
271
|
+
|
|
272
|
+
@classmethod
|
|
273
|
+
def from_settings(cls, s: "FileSettings", logger=None):
|
|
274
|
+
return cls(s.path, logger=logger)
|
|
275
|
+
|
|
276
|
+
def read_result(self) -> ReadResult:
|
|
277
|
+
try:
|
|
278
|
+
if not self.path.parent.is_dir():
|
|
279
|
+
return ReadResult(None, False)
|
|
280
|
+
try:
|
|
281
|
+
text = self.path.read_text(encoding="utf-8")
|
|
282
|
+
except FileNotFoundError:
|
|
283
|
+
return ReadResult(None, True)
|
|
284
|
+
return ReadResult(parse_state(text), True)
|
|
285
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
286
|
+
self.log.debug(f"{self.path}: {exc}")
|
|
287
|
+
return ReadResult(None, False)
|
|
288
|
+
|
|
289
|
+
def write(self, state: State) -> bool:
|
|
290
|
+
res = self.read_result()
|
|
291
|
+
if not res.remote:
|
|
292
|
+
self.log.warning(f"carpeta de estado no disponible: {self.path.parent}")
|
|
293
|
+
return False
|
|
294
|
+
current = res.state
|
|
295
|
+
if current is not None:
|
|
296
|
+
if current.revision > state.revision:
|
|
297
|
+
self.log.warning(
|
|
298
|
+
f"estado compartido mas nuevo (r{current.revision} > r{state.revision}, "
|
|
299
|
+
f"paleta {current.palette}); no se publica")
|
|
300
|
+
return False
|
|
301
|
+
if current == state:
|
|
302
|
+
return True
|
|
303
|
+
try:
|
|
304
|
+
atomic_write_text(self.path, dump_state(state))
|
|
305
|
+
except OSError as exc:
|
|
306
|
+
self.log.warning(f"no se pudo escribir {self.path}: {exc}")
|
|
307
|
+
return False
|
|
308
|
+
return self.read() == state
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@dataclass(frozen=True)
|
|
312
|
+
class FileSettings:
|
|
313
|
+
"""Settings of the `file` backend (`state_sync.type: file`)."""
|
|
314
|
+
type: ClassVar[str] = "file"
|
|
315
|
+
path: Path
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# -- registry -----------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
class StateSyncConfigError(ValueError):
|
|
321
|
+
"""Invalid `state_sync` mapping (caller adds file context)."""
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _parse_git(raw: dict) -> StateSyncSettings:
|
|
325
|
+
if not isinstance(raw.get("repo"), str) or not raw["repo"]:
|
|
326
|
+
raise StateSyncConfigError("'state_sync' (type git) debe ser un mapeo con 'repo'")
|
|
327
|
+
for key in ("remote", "branch", "workdir"):
|
|
328
|
+
if key in raw and not isinstance(raw[key], str):
|
|
329
|
+
raise StateSyncConfigError(f"state_sync.{key} debe ser texto")
|
|
330
|
+
timeout = raw.get("timeout", GIT_TIMEOUT)
|
|
331
|
+
if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout <= 0:
|
|
332
|
+
raise StateSyncConfigError("state_sync.timeout debe ser un entero > 0 (segundos)")
|
|
333
|
+
workdir = raw.get("workdir")
|
|
334
|
+
return StateSyncSettings(
|
|
335
|
+
repo=Path(raw["repo"]).expanduser(),
|
|
336
|
+
remote=raw.get("remote", "origin"),
|
|
337
|
+
branch=raw.get("branch", DEFAULT_BRANCH),
|
|
338
|
+
workdir=Path(workdir).expanduser() if workdir else None,
|
|
339
|
+
timeout=timeout,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _parse_file(raw: dict) -> FileSettings:
|
|
344
|
+
if not isinstance(raw.get("path"), str) or not raw["path"]:
|
|
345
|
+
raise StateSyncConfigError("'state_sync' (type file) debe ser un mapeo con 'path'")
|
|
346
|
+
return FileSettings(path=Path(raw["path"]).expanduser())
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# type name -> (parse raw mapping into settings, build source from settings)
|
|
350
|
+
SOURCE_TYPES: dict = {
|
|
351
|
+
"git": (_parse_git, GitBranchSource.from_settings),
|
|
352
|
+
"file": (_parse_file, FileSource.from_settings),
|
|
353
|
+
}
|
|
354
|
+
DEFAULT_TYPE = "git"
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def parse_settings(raw):
|
|
358
|
+
"""Validate a `state_sync` mapping and return the backend's settings."""
|
|
359
|
+
if not isinstance(raw, dict):
|
|
360
|
+
raise StateSyncConfigError("'state_sync' debe ser un mapeo")
|
|
361
|
+
kind = raw.get("type", DEFAULT_TYPE)
|
|
362
|
+
if not isinstance(kind, str) or kind not in SOURCE_TYPES:
|
|
363
|
+
raise StateSyncConfigError(
|
|
364
|
+
f"state_sync.type desconocido: {kind!r} (validos: {', '.join(sorted(SOURCE_TYPES))})")
|
|
365
|
+
return SOURCE_TYPES[kind][0](raw)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def build_source(settings, logger=None) -> StateSource:
|
|
369
|
+
"""Instantiate the backend that `settings` belongs to."""
|
|
370
|
+
return SOURCE_TYPES[settings.type][1](settings, logger)
|
hueman/engine/targets.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Definición declarativa de targets: qué template/paleta renderiza a qué
|
|
2
|
+
archivo(s), con qué modo (overwrite/patch) y qué comando de reload dispara."""
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
FAILURE_POLICIES = ("warn", "error")
|
|
8
|
+
MODES = ("overwrite", "patch")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class PatchSpec:
|
|
13
|
+
strategy: str = "regex_splice" # "regex_splice" | "json_scheme_upsert"
|
|
14
|
+
|
|
15
|
+
# regex_splice, variante "bloque delimitado por un único regex" (ej. una
|
|
16
|
+
# sección TOML entera como [theme.custom] o [palettes.dotfiles]):
|
|
17
|
+
marker_regex: str = None
|
|
18
|
+
insert_after_section_regex: str = None # fallback si el bloque no existe aún
|
|
19
|
+
|
|
20
|
+
# regex_splice, variante "marcadores begin/end literales" (ej. comentarios
|
|
21
|
+
# -- BEGIN ... / -- END ... envolviendo un bloque de código):
|
|
22
|
+
marker_begin: str = None
|
|
23
|
+
marker_end: str = None
|
|
24
|
+
marker_end_regex: str = None # alternativa a marker_end cuando el cierre no es literal
|
|
25
|
+
|
|
26
|
+
# post-procesamiento aplicado sobre el contenido ya parcheado, ANTES de
|
|
27
|
+
# escribir — son datos (contenido específico del usuario), no lógica:
|
|
28
|
+
replacements: list = field(default_factory=list) # [[viejo, nuevo], ...]
|
|
29
|
+
ensure_after: list = field(default_factory=list) # [{if_missing_regex, anchor, insert}, ...]
|
|
30
|
+
|
|
31
|
+
# solo para strategy == "json_scheme_upsert":
|
|
32
|
+
scheme_name: str = None
|
|
33
|
+
palette_section: str = "kitty"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Target:
|
|
38
|
+
name: str
|
|
39
|
+
mode: str = "overwrite" # "overwrite" | "patch"
|
|
40
|
+
template: str = None
|
|
41
|
+
palette_key: str = None
|
|
42
|
+
outputs: list = field(default_factory=list)
|
|
43
|
+
reload: str = None
|
|
44
|
+
reload_failure: str = None # None = inherit global; "warn" | "error"
|
|
45
|
+
make_executable: bool = False
|
|
46
|
+
skip_if_missing_parent: bool = False
|
|
47
|
+
# Rutas (archivo o directorio) que deben existir todas para que el target
|
|
48
|
+
# corra; si falta alguna se omite sin escribir ni disparar reload.
|
|
49
|
+
skip_unless_path_exists: list = field(default_factory=list)
|
|
50
|
+
jinja_delimiters: tuple = None
|
|
51
|
+
extra_vars: dict = field(default_factory=dict)
|
|
52
|
+
patch: PatchSpec = None
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_dict(cls, name: str, data: dict) -> "Target":
|
|
56
|
+
mode = data.get("mode", "overwrite")
|
|
57
|
+
if mode not in MODES:
|
|
58
|
+
raise TypeError(f"{name}: mode debe ser 'overwrite' o 'patch', no {mode!r}")
|
|
59
|
+
patch_data = data.get("patch")
|
|
60
|
+
if patch_data is not None and not isinstance(patch_data, dict):
|
|
61
|
+
raise TypeError(f"{name}: 'patch' debe ser un mapeo")
|
|
62
|
+
if mode == "patch" and not patch_data:
|
|
63
|
+
raise TypeError(f"{name}: mode 'patch' requiere una seccion 'patch'")
|
|
64
|
+
patch = PatchSpec(**patch_data) if patch_data else None
|
|
65
|
+
raw_outputs = data.get("outputs") or []
|
|
66
|
+
if not isinstance(raw_outputs, list) or not all(isinstance(o, str) for o in raw_outputs):
|
|
67
|
+
raise TypeError(f"{name}: 'outputs' debe ser una lista de rutas (strings)")
|
|
68
|
+
delimiters = data.get("jinja_delimiters")
|
|
69
|
+
required = data.get("skip_unless_path_exists") or []
|
|
70
|
+
if isinstance(required, str):
|
|
71
|
+
required = [required]
|
|
72
|
+
policy = data.get("reload_failure")
|
|
73
|
+
if policy is not None and policy not in FAILURE_POLICIES:
|
|
74
|
+
raise TypeError(
|
|
75
|
+
f"{name}: reload_failure debe ser 'warn' o 'error', no {policy!r}"
|
|
76
|
+
)
|
|
77
|
+
return cls(
|
|
78
|
+
name=name,
|
|
79
|
+
mode=mode,
|
|
80
|
+
template=data.get("template"),
|
|
81
|
+
palette_key=data.get("palette_key"),
|
|
82
|
+
outputs=[Path(o).expanduser() for o in raw_outputs],
|
|
83
|
+
reload=data.get("reload"),
|
|
84
|
+
reload_failure=policy,
|
|
85
|
+
make_executable=data.get("make_executable", False),
|
|
86
|
+
skip_if_missing_parent=data.get("skip_if_missing_parent", False),
|
|
87
|
+
skip_unless_path_exists=[Path(p).expanduser() for p in required],
|
|
88
|
+
jinja_delimiters=tuple(delimiters) if delimiters else None,
|
|
89
|
+
extra_vars=data.get("extra_vars", {}),
|
|
90
|
+
patch=patch,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load_targets(raw: dict) -> list:
|
|
95
|
+
return [Target.from_dict(name, cfg) for name, cfg in raw.items()]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
meta:
|
|
2
|
+
name: arctic-ice
|
|
3
|
+
|
|
4
|
+
kitty:
|
|
5
|
+
foreground: "#2d3540"
|
|
6
|
+
background: "#eef4f8"
|
|
7
|
+
selection_foreground: "#eef4f8"
|
|
8
|
+
selection_background: "#4a90a4"
|
|
9
|
+
url_color: "#2f6f83"
|
|
10
|
+
color0: "#dce7ee"
|
|
11
|
+
color1: "#ee6c6c"
|
|
12
|
+
color2: "#5fa88f"
|
|
13
|
+
color3: "#d9a441"
|
|
14
|
+
color4: "#4a90a4"
|
|
15
|
+
color5: "#7d6b9e"
|
|
16
|
+
color6: "#2f6f83"
|
|
17
|
+
color7: "#2d3540"
|
|
18
|
+
color8: "#8a97a3"
|
|
19
|
+
color9: "#ee6c6c"
|
|
20
|
+
color10: "#5fa88f"
|
|
21
|
+
color11: "#d9a441"
|
|
22
|
+
color12: "#4a90a4"
|
|
23
|
+
color13: "#7d6b9e"
|
|
24
|
+
color14: "#2f6f83"
|
|
25
|
+
color15: "#45505c"
|
|
26
|
+
cursor: "#4a90a4"
|
|
27
|
+
cursor_text_color: "#eef4f8"
|
|
28
|
+
active_tab_foreground: "#eef4f8"
|
|
29
|
+
active_tab_background: "#4a90a4"
|
|
30
|
+
inactive_tab_foreground: "#8a97a3"
|
|
31
|
+
inactive_tab_background: "#dce7ee"
|
|
32
|
+
tab_bar_background: "#f7fbfd"
|
|
33
|
+
tab_bar_margin_color: "#eef4f8"
|
|
34
|
+
mark1_foreground: "#eef4f8"
|
|
35
|
+
mark1_background: "#ee6c6c"
|
|
36
|
+
active_border_color: "#4a90a4"
|
|
37
|
+
inactive_border_color: "#d1dee6"
|
|
38
|
+
bell_border_color: "#ee6c6c"
|
|
39
|
+
visual_bell_color: "#ee6c6c"
|
|
40
|
+
search_result_foreground: "#eef4f8"
|
|
41
|
+
search_result_background: "#d9a441"
|
|
42
|
+
search_current_match_foreground: "#eef4f8"
|
|
43
|
+
search_current_match_background: "#4a90a4"
|
|
44
|
+
|
|
45
|
+
tmux:
|
|
46
|
+
status_bg: "#f7fbfd"
|
|
47
|
+
status_fg: "#2d3540"
|
|
48
|
+
pane_border_fg: "#d1dee6"
|
|
49
|
+
pane_active_border_fg: "#4a90a4"
|
|
50
|
+
segment_session_bg: "#4a90a4"
|
|
51
|
+
segment_session_fg: "#eef4f8"
|
|
52
|
+
separator_fg: "#8a97a3"
|
|
53
|
+
segment_time_fg: "#2f6f83"
|
|
54
|
+
segment_date_fg: "#7d6b9e"
|
|
55
|
+
segment_user_fg: "#5fa88f"
|
|
56
|
+
segment_battery_fg: "#d9a441"
|
|
57
|
+
segment_alert_fg: "#ee6c6c"
|
|
58
|
+
window_inactive_fg: "#45505c"
|
|
59
|
+
|
|
60
|
+
polybar:
|
|
61
|
+
background: "#eef4f8"
|
|
62
|
+
background_alt: "#f7fbfd"
|
|
63
|
+
foreground: "#2d3540"
|
|
64
|
+
primary: "#4a90a4"
|
|
65
|
+
secondary: "#2f6f83"
|
|
66
|
+
alert: "#ee6c6c"
|
|
67
|
+
success: "#5fa88f"
|
|
68
|
+
warning: "#d9a441"
|
|
69
|
+
muted: "#8a97a3"
|
|
70
|
+
|
|
71
|
+
pi:
|
|
72
|
+
vars:
|
|
73
|
+
orange: "#d9a441"
|
|
74
|
+
black: "#2d3540"
|
|
75
|
+
green: "#5fa88f"
|
|
76
|
+
sand: "#2f6f83"
|
|
77
|
+
brown: "#8a97a3"
|
|
78
|
+
redEarth: "#ee6c6c"
|
|
79
|
+
baseBg: "#eef4f8"
|
|
80
|
+
elevatedBg: "#f7fbfd"
|
|
81
|
+
floatingBg: "#dce7ee"
|
|
82
|
+
textPrimary: "#2d3540"
|
|
83
|
+
textSecondary: "#45505c"
|
|
84
|
+
textMuted: "#8a97a3"
|
|
85
|
+
dimGray: "#c3d2db"
|
|
86
|
+
thinkingColor: "#4a90a4"
|
|
87
|
+
|
|
88
|
+
omp:
|
|
89
|
+
separator_fg: "#8a97a3"
|
|
90
|
+
session_fg: "#d9a441"
|
|
91
|
+
git_fg: "#2f6f83"
|
|
92
|
+
git_working_fg: "#d9a441"
|
|
93
|
+
git_staging_fg: "#5fa88f"
|
|
94
|
+
aws_fg: "#d9a441"
|
|
95
|
+
kubectl_fg: "#2f6f83"
|
|
96
|
+
root_fg: "#ee6c6c"
|
|
97
|
+
battery_fg: "#5fa88f"
|
|
98
|
+
status_fg: "#ee6c6c"
|
|
99
|
+
path_fg: "#4a90a4"
|
|
100
|
+
ticket_fg: "#7d6b9e"
|
|
101
|
+
venv_fg: "#d9a441"
|
|
102
|
+
prompt_char_fg: "#2f6f83"
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
meta:
|
|
2
|
+
name: autumn
|
|
3
|
+
|
|
4
|
+
kitty:
|
|
5
|
+
foreground: "#f0e3cc"
|
|
6
|
+
background: "#1c130b"
|
|
7
|
+
selection_foreground: "#1c130b"
|
|
8
|
+
selection_background: "#d68c45"
|
|
9
|
+
url_color: "#b98d4a"
|
|
10
|
+
color0: "#241a10"
|
|
11
|
+
color1: "#b3462c"
|
|
12
|
+
color2: "#7a8a4f"
|
|
13
|
+
color3: "#d9a441"
|
|
14
|
+
color4: "#5b7b8a"
|
|
15
|
+
color5: "#8a5a54"
|
|
16
|
+
color6: "#6f9a8d"
|
|
17
|
+
color7: "#f0e3cc"
|
|
18
|
+
color8: "#5a4632"
|
|
19
|
+
color9: "#d1684a"
|
|
20
|
+
color10: "#9cae6b"
|
|
21
|
+
color11: "#f0c674"
|
|
22
|
+
color12: "#7fa0ad"
|
|
23
|
+
color13: "#ab7c6d"
|
|
24
|
+
color14: "#8fbdb0"
|
|
25
|
+
color15: "#fbf1de"
|
|
26
|
+
cursor: "#d68c45"
|
|
27
|
+
cursor_text_color: "#1c130b"
|
|
28
|
+
active_tab_foreground: "#1c130b"
|
|
29
|
+
active_tab_background: "#d68c45"
|
|
30
|
+
inactive_tab_foreground: "#a3906f"
|
|
31
|
+
inactive_tab_background: "#2a1e12"
|
|
32
|
+
tab_bar_background: "#241a10"
|
|
33
|
+
tab_bar_margin_color: "#1c130b"
|
|
34
|
+
mark1_foreground: "#1c130b"
|
|
35
|
+
mark1_background: "#b3462c"
|
|
36
|
+
active_border_color: "#d9a441"
|
|
37
|
+
inactive_border_color: "#3d2e1c"
|
|
38
|
+
bell_border_color: "#b3462c"
|
|
39
|
+
visual_bell_color: "#b3462c"
|
|
40
|
+
search_result_foreground: "#1c130b"
|
|
41
|
+
search_result_background: "#f0c674"
|
|
42
|
+
search_current_match_foreground: "#1c130b"
|
|
43
|
+
search_current_match_background: "#d68c45"
|
|
44
|
+
|
|
45
|
+
tmux:
|
|
46
|
+
status_bg: "#1c130b"
|
|
47
|
+
status_fg: "#f0e3cc"
|
|
48
|
+
pane_border_fg: "#3d2e1c"
|
|
49
|
+
pane_active_border_fg: "#d9a441"
|
|
50
|
+
segment_session_bg: "#d68c45"
|
|
51
|
+
segment_session_fg: "#1c130b"
|
|
52
|
+
separator_fg: "#5a4632"
|
|
53
|
+
segment_time_fg: "#d9a441"
|
|
54
|
+
segment_date_fg: "#8a5a54"
|
|
55
|
+
segment_user_fg: "#7a8a4f"
|
|
56
|
+
segment_battery_fg: "#f0c674"
|
|
57
|
+
segment_alert_fg: "#b3462c"
|
|
58
|
+
window_inactive_fg: "#a3906f"
|
|
59
|
+
|
|
60
|
+
polybar:
|
|
61
|
+
background: "#1c130b"
|
|
62
|
+
background_alt: "#2a1e12"
|
|
63
|
+
foreground: "#f0e3cc"
|
|
64
|
+
primary: "#d68c45"
|
|
65
|
+
secondary: "#d9a441"
|
|
66
|
+
alert: "#b3462c"
|
|
67
|
+
success: "#7a8a4f"
|
|
68
|
+
warning: "#f0c674"
|
|
69
|
+
muted: "#5a4632"
|
|
70
|
+
|
|
71
|
+
pi:
|
|
72
|
+
vars:
|
|
73
|
+
orange: "#d68c45"
|
|
74
|
+
black: "#1c130b"
|
|
75
|
+
green: "#7a8a4f"
|
|
76
|
+
sand: "#f0c674"
|
|
77
|
+
brown: "#5a4632"
|
|
78
|
+
redEarth: "#b3462c"
|
|
79
|
+
baseBg: "#1c130b"
|
|
80
|
+
elevatedBg: "#241a10"
|
|
81
|
+
floatingBg: "#2a1e12"
|
|
82
|
+
textPrimary: "#f0e3cc"
|
|
83
|
+
textSecondary: "#d3bd96"
|
|
84
|
+
textMuted: "#a3906f"
|
|
85
|
+
dimGray: "#5a4632"
|
|
86
|
+
thinkingColor: "#d68c45"
|
|
87
|
+
|
|
88
|
+
omp:
|
|
89
|
+
separator_fg: "#5a4632"
|
|
90
|
+
session_fg: "#f0c674"
|
|
91
|
+
git_fg: "#d9a441"
|
|
92
|
+
git_working_fg: "#d68c45"
|
|
93
|
+
git_staging_fg: "#7a8a4f"
|
|
94
|
+
aws_fg: "#d68c45"
|
|
95
|
+
kubectl_fg: "#6f9a8d"
|
|
96
|
+
root_fg: "#b3462c"
|
|
97
|
+
battery_fg: "#7a8a4f"
|
|
98
|
+
status_fg: "#b3462c"
|
|
99
|
+
path_fg: "#d68c45"
|
|
100
|
+
ticket_fg: "#8a5a54"
|
|
101
|
+
venv_fg: "#f0c674"
|
|
102
|
+
prompt_char_fg: "#d9a441"
|