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/scripts.py ADDED
@@ -0,0 +1,123 @@
1
+ """Ejecucion de scripts y tareas del proyecto."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shlex
8
+ import subprocess
9
+ from typing import Sequence
10
+
11
+ from rich.console import Console
12
+
13
+ from . import config, registry
14
+ from .config import ConfigError, Stack
15
+
16
+
17
+ def build_script_env(stack: Stack) -> dict[str, str]:
18
+ """Entorno de ejecucion para scripts de proyecto."""
19
+ env = dict(os.environ)
20
+ global_env = registry.HOME / "env.global"
21
+ if global_env.is_file():
22
+ env.update(config.parse_env_file(global_env))
23
+ local_env = stack.root / ".env"
24
+ if local_env.is_file():
25
+ env.update(config.parse_env_file(local_env))
26
+ env["PYTHONUNBUFFERED"] = "1"
27
+ env["FORCE_COLOR"] = "1"
28
+ return env
29
+
30
+
31
+ def _entrecomillar(args: Sequence[str]) -> str:
32
+ """Une los argumentos extra respetando sus limites, para una linea de shell.
33
+
34
+ Iban con un `" ".join`, y estos comandos corren con `shell=True`: un
35
+ argumento con un separador ejecutaba lo que viniera despues. Desde el CLI lo
36
+ escribe el usuario, pero `portmaster_run` los recibe de un agente de IA.
37
+
38
+ Por plataforma, porque el shell no es el mismo. `shlex.join` entrecomilla al
39
+ estilo POSIX y `cmd.exe` no entiende las comillas simples: un `&` adentro le
40
+ seguiria partiendo el comando.
41
+
42
+ En Windows hacen falta las dos capas, y con `list2cmdline` sola no alcanza:
43
+ escapa las comillas dobles como `\\"`, que es la convencion de CreateProcess,
44
+ y `cmd.exe` no la conoce. Ve esa comilla como cierre, lo que sigue queda
45
+ afuera del entrecomillado y un `&` ahi ejecuta un segundo comando. Probado:
46
+ con `a" & echo x > archivo & echo "b` el archivo se creaba.
47
+
48
+ Entonces: `list2cmdline` para la capa de argumentos, y despues `^` delante
49
+ de cada metacaracter de cmd, comillas incluidas. Asi cmd los pasa literales
50
+ y el programa los parsea con sus propias reglas, que es lo que corresponde.
51
+ """
52
+ if os.name == "nt":
53
+ linea = subprocess.list2cmdline(list(args))
54
+ return _CMD_META.sub(r"^\g<0>", linea)
55
+ return shlex.join(args)
56
+
57
+
58
+ # Lo que `cmd.exe` interpreta antes de partir en argumentos. El `%` entra porque
59
+ # expande variables adentro de comillas, que es la otra forma de salirse.
60
+ _CMD_META = re.compile(r'[()%!^"<>&|]')
61
+
62
+
63
+ def resolve(stack: Stack, name: str, _visto: tuple[str, ...] = ()) -> list[str]:
64
+ """Aplana un script a comandos de shell, expandiendo los que nombran a otro.
65
+
66
+ Un item que coincide con el nombre de otro script es una referencia; todo lo
67
+ demas es un comando literal. Sin esto, el `check: [lint, test]` que documenta
68
+ `docs/stack-yaml.md` corria `lint` como si fuera un binario y moria con "no
69
+ se reconoce como comando".
70
+
71
+ Un nombre que no es script queda como comando tal cual, que es lo que hace
72
+ falta para `test: pytest -v`. No se puede distinguir un binario real de una
73
+ referencia mal escrita sin adivinar, y adivinar en un ejecutor de comandos
74
+ sale mas caro que el error del shell.
75
+ """
76
+ if name in _visto:
77
+ raise ConfigError(f"ciclo entre scripts: {' -> '.join((*_visto, name))}")
78
+ fuera: list[str] = []
79
+ for item in stack.scripts[name]:
80
+ if item in stack.scripts:
81
+ fuera.extend(resolve(stack, item, (*_visto, name)))
82
+ else:
83
+ fuera.append(item)
84
+ return fuera
85
+
86
+
87
+ def run_script(
88
+ stack: Stack,
89
+ name: str,
90
+ extra_args: Sequence[str] | None = None,
91
+ console: Console | None = None,
92
+ ) -> int:
93
+ """Ejecuta una tarea o pipeline de comandos definido en stack.yaml.
94
+
95
+ Retorna el codigo de salida del comando (0 si todos terminan con exito).
96
+ """
97
+ console = console or Console()
98
+ if name not in stack.scripts:
99
+ known = ", ".join(stack.scripts) or "ninguno"
100
+ raise ConfigError(f"script desconocido: {name!r}. Definidos: {known}")
101
+
102
+ commands = resolve(stack, name)
103
+ env = build_script_env(stack)
104
+ extra_str = (" " + _entrecomillar(extra_args)) if extra_args else ""
105
+
106
+ for idx, cmd in enumerate(commands):
107
+ # Si es el ultimo comando del pipeline, le pasamos los extra_args
108
+ full_cmd = (cmd + extra_str) if (idx == len(commands) - 1 and extra_str) else cmd
109
+ console.print(f"[bold cyan]$[/] [dim]{full_cmd}[/]")
110
+ res = subprocess.run(
111
+ full_cmd,
112
+ shell=True,
113
+ cwd=stack.root,
114
+ env=env,
115
+ )
116
+ if res.returncode != 0:
117
+ console.print(
118
+ f"[bold red]error:[/] el script '{name}' fallo en el paso {idx + 1} "
119
+ f"con codigo {res.returncode}"
120
+ )
121
+ return res.returncode
122
+
123
+ return 0