stackhelx 1.0.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.
- stackhelx/__init__.py +2 -0
- stackhelx/__main__.py +4 -0
- stackhelx/browse.py +160 -0
- stackhelx/cli.py +1165 -0
- stackhelx/config.py +448 -0
- stackhelx/detect.py +1053 -0
- stackhelx/docker.py +202 -0
- stackhelx/doctor.py +356 -0
- stackhelx/guardrails.py +42 -0
- stackhelx/history.py +86 -0
- stackhelx/mcp.py +467 -0
- stackhelx/ports.py +392 -0
- stackhelx/registry.py +356 -0
- stackhelx/runner.py +877 -0
- stackhelx/scripts.py +123 -0
- stackhelx/server.py +1786 -0
- stackhelx/tunnel.py +207 -0
- stackhelx/web/app.css +1729 -0
- stackhelx/web/app.js +2425 -0
- stackhelx/web/index.html +378 -0
- stackhelx/web/tokens.css +104 -0
- stackhelx-1.0.0.dist-info/METADATA +323 -0
- stackhelx-1.0.0.dist-info/RECORD +26 -0
- stackhelx-1.0.0.dist-info/WHEEL +4 -0
- stackhelx-1.0.0.dist-info/entry_points.txt +3 -0
- stackhelx-1.0.0.dist-info/licenses/LICENSE +21 -0
stackhelx/config.py
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""Carga y validacion de stack.yaml.
|
|
2
|
+
|
|
3
|
+
La validacion es manual y explicita a proposito: el esquema tiene ocho campos
|
|
4
|
+
y no justifica una dependencia de schemas. Cuando crezca, se revisa.
|
|
5
|
+
|
|
6
|
+
Todo lo que entra por el archivo se trata como input no confiable: safe_load,
|
|
7
|
+
tipos verificados uno por uno, y `cwd` obligado a quedar dentro de la raiz del
|
|
8
|
+
proyecto.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
CONFIG_NAMES = ("stack.yaml", "stack.yml")
|
|
19
|
+
READY_KINDS = ("port", "none", "listen")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConfigError(Exception):
|
|
23
|
+
"""stack.yaml ausente, ilegible o invalido."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Service:
|
|
28
|
+
name: str
|
|
29
|
+
command: str
|
|
30
|
+
cwd: Path
|
|
31
|
+
port: int | None
|
|
32
|
+
ready: str
|
|
33
|
+
needs: tuple[str, ...]
|
|
34
|
+
env: dict[str, str]
|
|
35
|
+
detached: bool
|
|
36
|
+
# Comando de apagado propio, para lo que no muere matando al proceso que lo
|
|
37
|
+
# arranco: un contenedor vive fuera de nuestro arbol.
|
|
38
|
+
stop: str | None = None
|
|
39
|
+
env_file: tuple[Path, ...] = ()
|
|
40
|
+
pre_start: str | None = None
|
|
41
|
+
post_start: str | None = None
|
|
42
|
+
# Adonde lleva "Abrir". Sin esto es la raiz del puerto, que no alcanza para
|
|
43
|
+
# una app que vive en un path o que necesita un token en la query. Puede
|
|
44
|
+
# traer ${VAR}: lo expande runner.service_url, no la carga.
|
|
45
|
+
url: str | None = None
|
|
46
|
+
restart: str = "no"
|
|
47
|
+
max_retries: int = 3
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Stack:
|
|
52
|
+
name: str
|
|
53
|
+
root: Path
|
|
54
|
+
path: Path
|
|
55
|
+
services: dict[str, Service]
|
|
56
|
+
profiles: dict[str, tuple[str, ...]]
|
|
57
|
+
scripts: dict[str, tuple[str, ...]] = field(default_factory=dict)
|
|
58
|
+
detected: bool = False
|
|
59
|
+
# Que arranca sin pedir perfil. None significa "todo", que es lo que un
|
|
60
|
+
# stack.yaml siempre quiso decir. Existe por los `profiles:` de compose, que
|
|
61
|
+
# marcan servicios que quedan afuera salvo que los pidas: sin esto, un stack
|
|
62
|
+
# detectado arrancaria lo que el compose deja apagado a proposito.
|
|
63
|
+
default: tuple[str, ...] | None = None
|
|
64
|
+
|
|
65
|
+
def ports(self) -> list[int]:
|
|
66
|
+
"""Puertos declarados, en orden de aparicion y sin repetir."""
|
|
67
|
+
seen = dict.fromkeys(s.port for s in self.services.values() if s.port)
|
|
68
|
+
return list(seen)
|
|
69
|
+
|
|
70
|
+
def resolve(self, profile: str | None = None) -> list[Service]:
|
|
71
|
+
"""Servicios del perfil en orden de arranque.
|
|
72
|
+
|
|
73
|
+
Las dependencias transitivas entran aunque el perfil no las liste: pedir
|
|
74
|
+
`api` sin su base de datos nunca es lo que alguien quiso decir.
|
|
75
|
+
"""
|
|
76
|
+
if profile is None:
|
|
77
|
+
wanted = list(self.default if self.default is not None else self.services)
|
|
78
|
+
elif profile in self.profiles:
|
|
79
|
+
wanted = list(self.profiles[profile])
|
|
80
|
+
else:
|
|
81
|
+
known = ", ".join(self.profiles) or "ninguno"
|
|
82
|
+
raise ConfigError(f"perfil desconocido: {profile!r}. Definidos: {known}")
|
|
83
|
+
return [self.services[name] for name in _topological(self.services, wanted)]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _topological(services: dict[str, Service], wanted: list[str]) -> list[str]:
|
|
87
|
+
order: list[str] = []
|
|
88
|
+
done: set[str] = set()
|
|
89
|
+
|
|
90
|
+
def visit(name: str, path: list[str]) -> None:
|
|
91
|
+
if name in done:
|
|
92
|
+
return
|
|
93
|
+
if name in path:
|
|
94
|
+
chain = " -> ".join(path[path.index(name):] + [name])
|
|
95
|
+
raise ConfigError(f"dependencia circular: {chain}")
|
|
96
|
+
for dep in services[name].needs:
|
|
97
|
+
visit(dep, path + [name])
|
|
98
|
+
done.add(name)
|
|
99
|
+
order.append(name)
|
|
100
|
+
|
|
101
|
+
for name in wanted:
|
|
102
|
+
visit(name, [])
|
|
103
|
+
return order
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def find(start: Path | None = None) -> Path:
|
|
107
|
+
"""Busca stack.yaml desde start hacia arriba. Permite correr desde subdirs."""
|
|
108
|
+
current = (start or Path.cwd()).resolve()
|
|
109
|
+
for directory in (current, *current.parents):
|
|
110
|
+
for filename in CONFIG_NAMES:
|
|
111
|
+
candidate = directory / filename
|
|
112
|
+
if candidate.is_file():
|
|
113
|
+
return candidate
|
|
114
|
+
raise ConfigError(f"no se encontro {CONFIG_NAMES[0]} desde {current}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def load(path: Path | None = None, _visited: set[Path] | None = None) -> Stack:
|
|
118
|
+
path = (path or find()).resolve()
|
|
119
|
+
root = path.parent
|
|
120
|
+
|
|
121
|
+
if _visited is None:
|
|
122
|
+
_visited = set()
|
|
123
|
+
if path in _visited:
|
|
124
|
+
raise ConfigError(f"ciclo de inclusion detectado en: {path}")
|
|
125
|
+
_visited.add(path)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
129
|
+
except (OSError, yaml.YAMLError) as exc:
|
|
130
|
+
raise ConfigError(f"no se pudo leer {path}: {exc}") from exc
|
|
131
|
+
|
|
132
|
+
if not isinstance(raw, dict):
|
|
133
|
+
raise ConfigError(f"{path}: la raiz debe ser un mapa")
|
|
134
|
+
|
|
135
|
+
services_raw = raw.get("services") or {}
|
|
136
|
+
if not isinstance(services_raw, dict):
|
|
137
|
+
raise ConfigError(f"{path}: 'services' debe ser un mapa")
|
|
138
|
+
|
|
139
|
+
services = {
|
|
140
|
+
name: _service(name, spec, root)
|
|
141
|
+
for name, spec in services_raw.items()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
includes_raw = raw.get("includes")
|
|
145
|
+
if includes_raw is not None:
|
|
146
|
+
if isinstance(includes_raw, str):
|
|
147
|
+
includes_list = [includes_raw]
|
|
148
|
+
elif isinstance(includes_raw, list) and all(isinstance(i, str) for i in includes_raw):
|
|
149
|
+
includes_list = includes_raw
|
|
150
|
+
else:
|
|
151
|
+
raise ConfigError(f"{path}: 'includes' debe ser una ruta o lista de rutas")
|
|
152
|
+
|
|
153
|
+
for inc_item in includes_list:
|
|
154
|
+
inc_path = (root / inc_item).resolve()
|
|
155
|
+
if inc_path.is_dir():
|
|
156
|
+
target_file = None
|
|
157
|
+
for cname in CONFIG_NAMES:
|
|
158
|
+
if (inc_path / cname).is_file():
|
|
159
|
+
target_file = inc_path / cname
|
|
160
|
+
break
|
|
161
|
+
if target_file is None:
|
|
162
|
+
raise ConfigError(f"no se encontro stack.yaml en la ruta incluida: {inc_path}")
|
|
163
|
+
inc_stack = load(target_file, _visited=set(_visited))
|
|
164
|
+
elif inc_path.is_file():
|
|
165
|
+
inc_stack = load(inc_path, _visited=set(_visited))
|
|
166
|
+
else:
|
|
167
|
+
raise ConfigError(f"ruta de inclusion no encontrada: {inc_path}")
|
|
168
|
+
|
|
169
|
+
for s_name, s_svc in inc_stack.services.items():
|
|
170
|
+
if s_name in services:
|
|
171
|
+
raise ConfigError(
|
|
172
|
+
f"conflicto de servicio: '{s_name}' ya esta declarado y no puede ser importado desde '{inc_path}'"
|
|
173
|
+
)
|
|
174
|
+
services[s_name] = s_svc
|
|
175
|
+
|
|
176
|
+
if not services:
|
|
177
|
+
raise ConfigError(f"{path}: falta la seccion 'services' o esta vacia")
|
|
178
|
+
|
|
179
|
+
for service in services.values():
|
|
180
|
+
for dep in service.needs:
|
|
181
|
+
if dep not in services:
|
|
182
|
+
raise ConfigError(f"'{service.name}.needs' apunta a '{dep}', que no existe")
|
|
183
|
+
|
|
184
|
+
profiles = _profiles(raw.get("profiles"), services)
|
|
185
|
+
scripts = _scripts(raw.get("scripts"))
|
|
186
|
+
|
|
187
|
+
stack = Stack(
|
|
188
|
+
name=str(raw.get("name") or root.name),
|
|
189
|
+
root=root,
|
|
190
|
+
path=path,
|
|
191
|
+
services=services,
|
|
192
|
+
profiles=profiles,
|
|
193
|
+
scripts=scripts,
|
|
194
|
+
default=_default(raw.get("default"), services),
|
|
195
|
+
)
|
|
196
|
+
stack.resolve() # falla al cargar si hay ciclos, no en tiempo de arranque
|
|
197
|
+
return stack
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _service(name: str, spec: object, root: Path) -> Service:
|
|
201
|
+
where = f"services.{name}"
|
|
202
|
+
if not isinstance(spec, dict):
|
|
203
|
+
raise ConfigError(f"{where} debe ser un mapa")
|
|
204
|
+
|
|
205
|
+
unknown = set(spec) - {
|
|
206
|
+
"command", "cwd", "port", "ready", "needs", "env", "detached", "stop",
|
|
207
|
+
"env_file", "pre_start", "post_start", "url", "restart", "max_retries",
|
|
208
|
+
}
|
|
209
|
+
if unknown:
|
|
210
|
+
raise ConfigError(f"{where}: campos desconocidos: {', '.join(sorted(unknown))}")
|
|
211
|
+
|
|
212
|
+
command = spec.get("command")
|
|
213
|
+
if not isinstance(command, str) or not command.strip():
|
|
214
|
+
raise ConfigError(f"{where}.command es obligatorio y debe ser texto")
|
|
215
|
+
|
|
216
|
+
port = spec.get("port")
|
|
217
|
+
if port is not None:
|
|
218
|
+
if not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= 65535:
|
|
219
|
+
raise ConfigError(f"{where}.port debe ser un entero entre 1 y 65535")
|
|
220
|
+
|
|
221
|
+
ready = spec.get("ready", "port" if port else "none")
|
|
222
|
+
if not isinstance(ready, str) or not _valid_ready(ready):
|
|
223
|
+
raise ConfigError(
|
|
224
|
+
f"{where}.ready debe ser 'port', 'listen', 'none', 'log:<texto>' o una URL http"
|
|
225
|
+
)
|
|
226
|
+
if ready == "port" and port is None:
|
|
227
|
+
raise ConfigError(f"{where}.ready es 'port' pero no hay 'port' declarado")
|
|
228
|
+
if ready == "listen" and port is not None:
|
|
229
|
+
raise ConfigError(
|
|
230
|
+
f"{where}.ready es 'listen' pero el puerto esta declarado: usa 'port'"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
needs = spec.get("needs", [])
|
|
234
|
+
if not isinstance(needs, list) or not all(isinstance(n, str) for n in needs):
|
|
235
|
+
raise ConfigError(f"{where}.needs debe ser una lista de nombres")
|
|
236
|
+
if name in needs:
|
|
237
|
+
raise ConfigError(f"{where}.needs se incluye a si mismo")
|
|
238
|
+
|
|
239
|
+
detached = spec.get("detached", False)
|
|
240
|
+
if not isinstance(detached, bool):
|
|
241
|
+
raise ConfigError(f"{where}.detached debe ser true o false")
|
|
242
|
+
|
|
243
|
+
stop = spec.get("stop")
|
|
244
|
+
if stop is not None and (not isinstance(stop, str) or not stop.strip()):
|
|
245
|
+
raise ConfigError(f"{where}.stop debe ser texto no vacio")
|
|
246
|
+
|
|
247
|
+
pre_start = spec.get("pre_start")
|
|
248
|
+
if pre_start is not None and (not isinstance(pre_start, str) or not pre_start.strip()):
|
|
249
|
+
raise ConfigError(f"{where}.pre_start debe ser texto no vacio")
|
|
250
|
+
|
|
251
|
+
post_start = spec.get("post_start")
|
|
252
|
+
if post_start is not None and (not isinstance(post_start, str) or not post_start.strip()):
|
|
253
|
+
raise ConfigError(f"{where}.post_start debe ser texto no vacio")
|
|
254
|
+
|
|
255
|
+
url = spec.get("url")
|
|
256
|
+
if url is not None:
|
|
257
|
+
if not isinstance(url, str) or not url.strip():
|
|
258
|
+
raise ConfigError(f"{where}.url debe ser texto no vacio")
|
|
259
|
+
url = url.strip()
|
|
260
|
+
if not url.startswith(("http://", "https://")):
|
|
261
|
+
raise ConfigError(
|
|
262
|
+
f"{where}.url debe empezar con http:// o https://, y es {url!r}"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
restart = spec.get("restart", "no")
|
|
266
|
+
if not isinstance(restart, str) or restart not in ("no", "on-failure", "always"):
|
|
267
|
+
raise ConfigError(f"{where}.restart debe ser 'no', 'on-failure' o 'always'")
|
|
268
|
+
|
|
269
|
+
max_retries = spec.get("max_retries", 3)
|
|
270
|
+
if not isinstance(max_retries, int) or isinstance(max_retries, bool) or max_retries < 0:
|
|
271
|
+
raise ConfigError(f"{where}.max_retries debe ser un entero positivo o 0")
|
|
272
|
+
|
|
273
|
+
return Service(
|
|
274
|
+
name=name,
|
|
275
|
+
command=command,
|
|
276
|
+
cwd=_cwd(where, spec.get("cwd", "."), root),
|
|
277
|
+
port=port,
|
|
278
|
+
ready=ready,
|
|
279
|
+
needs=tuple(needs),
|
|
280
|
+
env=_env(where, spec.get("env")),
|
|
281
|
+
detached=detached,
|
|
282
|
+
stop=stop,
|
|
283
|
+
env_file=_env_files(where, spec.get("env_file"), root),
|
|
284
|
+
pre_start=pre_start,
|
|
285
|
+
post_start=post_start,
|
|
286
|
+
url=url,
|
|
287
|
+
restart=restart,
|
|
288
|
+
max_retries=max_retries,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _valid_ready(ready: str) -> bool:
|
|
293
|
+
if ready in READY_KINDS:
|
|
294
|
+
return True
|
|
295
|
+
if ready.startswith("log:") and len(ready) > 4:
|
|
296
|
+
return True
|
|
297
|
+
return ready.startswith(("http://", "https://"))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _cwd(where: str, value: object, root: Path) -> Path:
|
|
301
|
+
if not isinstance(value, str):
|
|
302
|
+
raise ConfigError(f"{where}.cwd debe ser una ruta relativa")
|
|
303
|
+
candidate = Path(value)
|
|
304
|
+
if candidate.is_absolute():
|
|
305
|
+
raise ConfigError(f"{where}.cwd debe ser relativa a la raiz del proyecto")
|
|
306
|
+
|
|
307
|
+
resolved = (root / candidate).resolve()
|
|
308
|
+
if not resolved.is_relative_to(root):
|
|
309
|
+
raise ConfigError(f"{where}.cwd sale de la raiz del proyecto: {value!r}")
|
|
310
|
+
if not resolved.is_dir():
|
|
311
|
+
raise ConfigError(f"{where}.cwd no existe: {value!r}")
|
|
312
|
+
return resolved
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _env(where: str, value: object) -> dict[str, str]:
|
|
316
|
+
if value is None:
|
|
317
|
+
return {}
|
|
318
|
+
if not isinstance(value, dict):
|
|
319
|
+
raise ConfigError(f"{where}.env debe ser un mapa")
|
|
320
|
+
env = {}
|
|
321
|
+
for key, item in value.items():
|
|
322
|
+
if isinstance(item, (dict, list)) or item is None:
|
|
323
|
+
raise ConfigError(f"{where}.env.{key} debe ser un valor simple")
|
|
324
|
+
env[str(key)] = str(item)
|
|
325
|
+
return env
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _default(value: object, services: dict[str, Service]) -> tuple[str, ...] | None:
|
|
329
|
+
"""Servicios que arrancan sin pedir perfil. Ausente significa todos.
|
|
330
|
+
|
|
331
|
+
Existe para que `stackhelx init` pueda congelar un compose con `profiles:`
|
|
332
|
+
sin cambiar lo que arranca: en compose esos contenedores quedan afuera hasta
|
|
333
|
+
que los pedis, y sin esta clave el archivo congelado los prenderia a todos.
|
|
334
|
+
"""
|
|
335
|
+
if value is None:
|
|
336
|
+
return None
|
|
337
|
+
if not isinstance(value, list) or not value:
|
|
338
|
+
raise ConfigError("'default' debe ser una lista no vacia de servicios")
|
|
339
|
+
for member in value:
|
|
340
|
+
if member not in services:
|
|
341
|
+
raise ConfigError(f"'default' incluye '{member}', que no existe")
|
|
342
|
+
return tuple(str(m) for m in value)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _profiles(value: object, services: dict[str, Service]) -> dict[str, tuple[str, ...]]:
|
|
346
|
+
if value is None:
|
|
347
|
+
return {}
|
|
348
|
+
if not isinstance(value, dict):
|
|
349
|
+
raise ConfigError("'profiles' debe ser un mapa de nombre a lista de servicios")
|
|
350
|
+
|
|
351
|
+
profiles = {}
|
|
352
|
+
for name, members in value.items():
|
|
353
|
+
if not isinstance(members, list) or not members:
|
|
354
|
+
raise ConfigError(f"profiles.{name} debe ser una lista no vacia")
|
|
355
|
+
for member in members:
|
|
356
|
+
if member not in services:
|
|
357
|
+
raise ConfigError(f"profiles.{name} incluye '{member}', que no existe")
|
|
358
|
+
profiles[str(name)] = tuple(members)
|
|
359
|
+
return profiles
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _env_files(where: str, value: object, root: Path) -> tuple[Path, ...]:
|
|
363
|
+
if value is None:
|
|
364
|
+
return ()
|
|
365
|
+
raw_list: list[object]
|
|
366
|
+
if isinstance(value, str):
|
|
367
|
+
raw_list = [value]
|
|
368
|
+
elif isinstance(value, list):
|
|
369
|
+
raw_list = value
|
|
370
|
+
else:
|
|
371
|
+
raise ConfigError(f"{where}.env_file debe ser una ruta o lista de rutas")
|
|
372
|
+
|
|
373
|
+
paths: list[Path] = []
|
|
374
|
+
for item in raw_list:
|
|
375
|
+
if not isinstance(item, str) or not item.strip():
|
|
376
|
+
raise ConfigError(f"{where}.env_file debe contener rutas relativas de texto no vacias")
|
|
377
|
+
candidate = Path(item)
|
|
378
|
+
if candidate.is_absolute():
|
|
379
|
+
raise ConfigError(f"{where}.env_file debe ser relativa a la raiz del proyecto")
|
|
380
|
+
resolved = (root / candidate).resolve()
|
|
381
|
+
if not resolved.is_relative_to(root):
|
|
382
|
+
raise ConfigError(f"{where}.env_file sale de la raiz del proyecto: {item!r}")
|
|
383
|
+
paths.append(resolved)
|
|
384
|
+
return tuple(paths)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def parse_env_file(path: Path) -> dict[str, str]:
|
|
388
|
+
"""Lee un archivo .env simple sin dependencias externas."""
|
|
389
|
+
if not path.is_file():
|
|
390
|
+
return {}
|
|
391
|
+
env: dict[str, str] = {}
|
|
392
|
+
try:
|
|
393
|
+
content = path.read_text(encoding="utf-8-sig")
|
|
394
|
+
except (OSError, UnicodeDecodeError):
|
|
395
|
+
# UnicodeDecodeError no es un OSError: un .env guardado en latin-1, que
|
|
396
|
+
# es lo que deja cualquier editor viejo en Windows, tumbaba el arranque
|
|
397
|
+
# entero del stack en vez de quedarse sin esas variables.
|
|
398
|
+
return {}
|
|
399
|
+
|
|
400
|
+
for raw_line in content.splitlines():
|
|
401
|
+
line = raw_line.strip()
|
|
402
|
+
if not line or line.startswith("#"):
|
|
403
|
+
continue
|
|
404
|
+
if line.startswith("export "):
|
|
405
|
+
line = line[7:].lstrip()
|
|
406
|
+
if "=" not in line:
|
|
407
|
+
continue
|
|
408
|
+
key, val = line.split("=", 1)
|
|
409
|
+
key = key.strip()
|
|
410
|
+
val = val.strip()
|
|
411
|
+
if not key:
|
|
412
|
+
continue
|
|
413
|
+
# El valor entrecomillado se corta en su comilla de cierre, no en el
|
|
414
|
+
# final de la linea: exigir que TERMINE en comilla fallaba en cuanto
|
|
415
|
+
# habia un comentario detras, y `TOKEN="abc" # el de prod` entregaba el
|
|
416
|
+
# token con las comillas pegadas. Y como lo de adentro se toma tal cual,
|
|
417
|
+
# un `#` dentro de las comillas sigue siendo parte del valor.
|
|
418
|
+
if len(val) >= 2 and val[0] in "\"'" and val.find(val[0], 1) != -1:
|
|
419
|
+
val = val[1 : val.find(val[0], 1)]
|
|
420
|
+
elif " #" in val:
|
|
421
|
+
val = val.split(" #", 1)[0].rstrip()
|
|
422
|
+
env[key] = val
|
|
423
|
+
return env
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _scripts(value: object) -> dict[str, tuple[str, ...]]:
|
|
427
|
+
if value is None:
|
|
428
|
+
return {}
|
|
429
|
+
if not isinstance(value, dict):
|
|
430
|
+
raise ConfigError("'scripts' debe ser un mapa de nombre a comando o lista de comandos")
|
|
431
|
+
|
|
432
|
+
scripts = {}
|
|
433
|
+
for name, cmd in value.items():
|
|
434
|
+
if not isinstance(name, str) or not name.strip():
|
|
435
|
+
raise ConfigError("el nombre del script debe ser texto no vacio")
|
|
436
|
+
if isinstance(cmd, str):
|
|
437
|
+
if not cmd.strip():
|
|
438
|
+
raise ConfigError(f"scripts.{name} no puede estar vacio")
|
|
439
|
+
scripts[str(name)] = (cmd.strip(),)
|
|
440
|
+
elif isinstance(cmd, list):
|
|
441
|
+
if not cmd or not all(isinstance(c, str) and c.strip() for c in cmd):
|
|
442
|
+
raise ConfigError(f"scripts.{name} debe ser una lista de comandos de texto no vacios")
|
|
443
|
+
scripts[str(name)] = tuple(str(c).strip() for c in cmd)
|
|
444
|
+
else:
|
|
445
|
+
raise ConfigError(f"scripts.{name} debe ser un texto o lista de comandos")
|
|
446
|
+
return scripts
|
|
447
|
+
|
|
448
|
+
|