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/docker.py ADDED
@@ -0,0 +1,202 @@
1
+ """Arranque y reinicio de Docker Desktop, a pedido del usuario.
2
+
3
+ Vive fuera de `doctor` a proposito: ese modulo diagnostica sin arrancar nada, y
4
+ lo dice en su primera linea. Aca no hay chequeos; el "esta apagado" sigue
5
+ saliendo de `doctor._docker`, que es quien ya lo sabe.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import subprocess
11
+ from collections.abc import Sequence
12
+
13
+ # El plugin del CLI y no el ejecutable. `docker` ya es una dependencia del
14
+ # proyecto y ya tiene que estar en el PATH para que un stack con compose sirva
15
+ # de algo; el ejecutable de Docker Desktop no esta en el PATH en ninguna
16
+ # plataforma, vive en una ruta distinta por sistema operativo, y en Windows
17
+ # lanzarlo con `start` lo hace buscar tambien en el directorio actual, que es
18
+ # el del proyecto que estes mirando.
19
+ #
20
+ # `--detach` porque sin el, el comando espera a que el motor termine: entre 30 y
21
+ # 60 segundos con un request colgado en el threadpool que FastAPI comparte con
22
+ # apagar y con matar procesos. Ver server._probe_late_http, que existe por haber
23
+ # aprendido eso mismo.
24
+ #
25
+ # Diccionario y no un string armado con lo que llegue: lo unico que puede pedir
26
+ # la red es una de estas dos claves, y el comando sale de aca.
27
+ ACTIONS = {
28
+ "start": ("docker", "desktop", "start", "--detach"),
29
+ "restart": ("docker", "desktop", "restart", "--detach"),
30
+ }
31
+ TIMEOUT = 10.0
32
+
33
+ HECHO = {
34
+ "start": "Docker Desktop esta arrancando",
35
+ "restart": "Docker Desktop se esta reiniciando",
36
+ }
37
+
38
+
39
+ def run(action: str) -> tuple[bool, str]:
40
+ """Pide `start` o `restart` de Docker Desktop. Devuelve si se pudo, y que decir.
41
+
42
+ Mira el resultado en vez de disparar y olvidarse. Un lanzamiento que nadie
43
+ espera contesta "listo" siempre, tambien cuando Docker no esta instalado, y
44
+ el usuario se queda mirando una interfaz que le mintio.
45
+
46
+ Que el comando haya salido bien no es que el motor este arriba: eso tarda y
47
+ lo reporta la vista de estado, que ya sondea `docker info`.
48
+ """
49
+ command = ACTIONS[action]
50
+ try:
51
+ done = subprocess.run(
52
+ list(command),
53
+ capture_output=True,
54
+ text=True,
55
+ errors="replace",
56
+ timeout=TIMEOUT,
57
+ )
58
+ except FileNotFoundError:
59
+ return False, "docker no esta en el PATH"
60
+ except subprocess.TimeoutExpired:
61
+ return False, f"docker no contesto en {TIMEOUT:.0f}s"
62
+ except OSError as exc:
63
+ return False, f"no se pudo ejecutar docker: {exc}"
64
+
65
+ if done.returncode != 0:
66
+ return False, _motivo(done, action)
67
+ return True, HECHO[action]
68
+
69
+
70
+ def _motivo(done: subprocess.CompletedProcess, action: str) -> str:
71
+ """La primera linea util de la salida, o el codigo si no dijo nada.
72
+
73
+ Docker Desktop para Linux no trae el plugin `desktop`, y ahi el error es
74
+ 'docker: desktop is not a docker command'. Decirlo tal cual es mas util que
75
+ un "no se pudo" que obliga a ir a buscar por que.
76
+ """
77
+ for stream in (done.stderr, done.stdout):
78
+ primera = next((line.strip() for line in (stream or "").splitlines() if line.strip()), "")
79
+ if primera:
80
+ return primera[:200]
81
+ return f"docker desktop {action} fallo con codigo {done.returncode}"
82
+
83
+
84
+ def running() -> list[str]:
85
+ """Nombres de los contenedores corriendo.
86
+
87
+ Reiniciar el motor los baja a todos, incluidos los de proyectos que no
88
+ estas mirando. No hay forma de reiniciarlo a medias, asi que lo unico que
89
+ se puede hacer por el usuario es decirle que se va a llevar antes de
90
+ llevarselo. Vacio si docker no contesta: la confirmacion sigue, con la
91
+ frase generica.
92
+ """
93
+ try:
94
+ done = subprocess.run(
95
+ ["docker", "ps", "--format", "{{.Names}}"],
96
+ capture_output=True,
97
+ text=True,
98
+ errors="replace",
99
+ timeout=TIMEOUT,
100
+ )
101
+ except (OSError, subprocess.SubprocessError):
102
+ return []
103
+ if done.returncode != 0:
104
+ return []
105
+ return [line.strip() for line in (done.stdout or "").splitlines() if line.strip()]
106
+
107
+
108
+ def usage() -> str | None:
109
+ """La tabla de `docker system df`, para que la confirmacion diga cuanto hay.
110
+
111
+ Sin parsear: Docker ya la formatea, y sacarle el numero a mano seria atarnos
112
+ a su formato de salida a cambio de nada. None si docker no contesta, y ahi
113
+ la confirmacion sigue sin la tabla en vez de cancelarse.
114
+ """
115
+ try:
116
+ done = subprocess.run(
117
+ ["docker", "system", "df"],
118
+ capture_output=True,
119
+ text=True,
120
+ errors="replace",
121
+ timeout=TIMEOUT,
122
+ )
123
+ except (OSError, subprocess.SubprocessError):
124
+ return None
125
+ return done.stdout.strip() or None if done.returncode == 0 else None
126
+
127
+
128
+ # Que limpia cada categoria, con su comando propio. `docker system prune` los
129
+ # tira todos juntos y no deja elegir: el cache de build puede ser decenas de GB
130
+ # que se regeneran solas, y las imagenes sin tag pueden ser la capa base que vas
131
+ # a volver a bajar. No es lo mismo y el usuario tiene que poder separarlo.
132
+ #
133
+ # Diccionario y no un comando armado con lo que llegue: lo unico que puede pedir
134
+ # la red son estas claves.
135
+ TARGETS = {
136
+ "containers": ("docker", "container", "prune", "-f"),
137
+ "images": ("docker", "image", "prune", "-f"),
138
+ "networks": ("docker", "network", "prune", "-f"),
139
+ "cache": ("docker", "builder", "prune", "-f"),
140
+ # Aparte del resto en todos lados: adentro hay datos y no se regeneran. Ni
141
+ # el default del CLI ni el de la interfaz lo incluyen, y el MCP lo rechaza.
142
+ "volumes": ("docker", "volume", "prune", "-f"),
143
+ }
144
+
145
+ # Lo que se limpia cuando no se elige nada. `volumes` queda afuera a proposito.
146
+ DEFAULT_TARGETS = ("containers", "images", "networks", "cache")
147
+
148
+ ETIQUETAS = {
149
+ "containers": "contenedores parados",
150
+ "images": "imagenes sin tag",
151
+ "networks": "redes sin usar",
152
+ "cache": "cache de build",
153
+ "volumes": "volumenes anonimos",
154
+ }
155
+
156
+ PRUNE_TIMEOUT = 60.0
157
+
158
+
159
+ def prune(targets: Sequence[str] | None = None) -> tuple[bool, str]:
160
+ """Limpia las categorias pedidas, una por una.
161
+
162
+ Devuelve si todas salieron bien, y un resumen con lo que reclamo cada una.
163
+ Una que falle no cancela a las demas: son independientes, y quedarse a mitad
164
+ sin decir cual fallo es peor que terminar y contarlo.
165
+ """
166
+ elegidos = list(targets) if targets else list(DEFAULT_TARGETS)
167
+ desconocidos = [t for t in elegidos if t not in TARGETS]
168
+ if desconocidos:
169
+ return False, f"categoria desconocida: {', '.join(desconocidos)}"
170
+
171
+ partes, fallo = [], False
172
+ for target in elegidos:
173
+ ok, detalle = _prune_one(target)
174
+ fallo = fallo or not ok
175
+ partes.append(f"{ETIQUETAS[target]}: {detalle}")
176
+ return not fallo, " · ".join(partes)
177
+
178
+
179
+ def _prune_one(target: str) -> tuple[bool, str]:
180
+ try:
181
+ done = subprocess.run(
182
+ list(TARGETS[target]),
183
+ capture_output=True,
184
+ text=True,
185
+ errors="replace",
186
+ timeout=PRUNE_TIMEOUT,
187
+ )
188
+ except FileNotFoundError:
189
+ return False, "docker no esta en el PATH"
190
+ except subprocess.TimeoutExpired:
191
+ return False, f"excedio los {PRUNE_TIMEOUT:.0f}s"
192
+ except OSError as exc:
193
+ return False, f"no se pudo ejecutar docker: {exc}"
194
+
195
+ if done.returncode != 0:
196
+ return False, _motivo(done, f"{target} prune")
197
+
198
+ # La ultima linea de docker es el "Total reclaimed space". Lo demas es la
199
+ # lista de lo borrado, que en un resumen de una linea no entra.
200
+ lineas = [line.strip() for line in (done.stdout or "").splitlines() if line.strip()]
201
+ reclamado = next((line for line in reversed(lineas) if "reclaimed" in line.lower()), "")
202
+ return True, reclamado.split(":")[-1].strip() if reclamado else "listo"
stackhelx/doctor.py ADDED
@@ -0,0 +1,356 @@
1
+ """Diagnostico: por que este proyecto no arranca, sin arrancarlo.
2
+
3
+ Vive en su propio modulo y no en `cli.py` porque cruza cuatro modulos y porque
4
+ la interfaz web va a querer los mismos chequeos. `run` devuelve datos; imprimir
5
+ es problema de quien llame.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ import subprocess
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from . import config, detect, ports, registry
16
+
17
+ UI_PORT = 7666
18
+ DOCKER_TIMEOUT = 5.0
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Check:
23
+ """Un chequeo y su resultado.
24
+
25
+ `fix` es un comando copiable o una accion de una linea. Un chequeo en rojo
26
+ sin `fix` deja al usuario donde estaba: sabiendo que algo anda mal y no que
27
+ hacer. Por eso todo lo que sale `fail` lo lleva.
28
+ """
29
+
30
+ name: str
31
+ level: str # "ok" | "warn" | "fail"
32
+ detail: str
33
+ fix: str = ""
34
+
35
+
36
+ def run(root: Path) -> list[Check]:
37
+ """Entorno siempre, proyecto si hay algo que revisar."""
38
+ return environment() + project(root)
39
+
40
+
41
+ def blocking(checks: list[Check]) -> bool:
42
+ """Si algo impide arrancar. Los avisos no cuentan, o el codigo de salida
43
+ daria 1 todo el dia y dejaria de significar algo."""
44
+ return any(c.level == "fail" for c in checks)
45
+
46
+
47
+ # entorno ------------------------------------------------------------------
48
+
49
+
50
+ def environment() -> list[Check]:
51
+ return [_token(), _ui_port(), *_registered()]
52
+
53
+
54
+ def _token() -> Check:
55
+ try:
56
+ registry.token()
57
+ except Exception as exc: # noqa: BLE001 - cualquier fallo aca es el mismo problema
58
+ return Check(
59
+ "token",
60
+ "fail",
61
+ f"no se pudo leer ni generar {registry.HOME / 'token'}: {exc}",
62
+ "revisa los permisos de la carpeta, o defini STACKHELX_TOKEN",
63
+ )
64
+ return Check("token", "ok", str(registry.HOME / "token"))
65
+
66
+
67
+ def _ui_port() -> Check:
68
+ status = ports.scan(UI_PORT)
69
+ if status.free:
70
+ return Check("puerto de la interfaz", "ok", f"{UI_PORT} libre")
71
+ quien = status.name or "desconocido"
72
+ return Check(
73
+ "puerto de la interfaz",
74
+ "warn",
75
+ f"{UI_PORT} ocupado por {quien} (pid {status.pid})",
76
+ f"si no es StackHelx: stackhelx free {UI_PORT}",
77
+ )
78
+
79
+
80
+ def _registered() -> list[Check]:
81
+ known = registry.paths()
82
+ if not known:
83
+ return [
84
+ Check("proyectos registrados", "ok", "ninguno", "para registrar: stackhelx add .")
85
+ ]
86
+ perdidos = [p for p in known if not p.is_dir()]
87
+ checks = [Check("proyectos registrados", "ok", f"{len(known)}")]
88
+ for path in perdidos:
89
+ checks.append(
90
+ Check(
91
+ "proyecto sin carpeta",
92
+ "warn",
93
+ str(path),
94
+ f'stackhelx remove "{path}"',
95
+ )
96
+ )
97
+ return checks
98
+
99
+
100
+ # proyecto -----------------------------------------------------------------
101
+
102
+
103
+ def project(root: Path) -> list[Check]:
104
+ try:
105
+ stack = detect.stack_for(root)
106
+ services = stack.resolve()
107
+ except config.ConfigError as exc:
108
+ # Un stack.yaml roto es una falla; no tener ninguno, corriendo doctor en
109
+ # una carpeta cualquiera, es lo normal recien instalado.
110
+ if _archivo(root) is None:
111
+ return [Check("proyecto", "ok", f"{root} no es un proyecto conocido")]
112
+ return [
113
+ Check(
114
+ "stack",
115
+ "fail",
116
+ str(exc),
117
+ "corregi el archivo (ver stack.example.yaml) o borralo para que se detecte",
118
+ )
119
+ ]
120
+
121
+ origen = "detectado" if stack.detected else str(stack.path)
122
+ checks = [Check("stack", "ok", f"{origen} ({len(services)} servicios)")]
123
+ checks += _dotenv(root)
124
+ checks += _executables(services)
125
+ if any(_program(s.command) == "docker" for s in services):
126
+ checks.append(_docker())
127
+ checks += _ports(services)
128
+ checks += _compartidos(stack.root, services)
129
+ return checks
130
+
131
+
132
+ def _compartidos(root: Path, services: list[config.Service]) -> list[Check]:
133
+ """Otros proyectos registrados que declaran los mismos puertos.
134
+
135
+ Aviso y no falla: dos proyectos con el mismo puerto conviven mientras no
136
+ corran a la vez. Lo que hoy no existe es enterarse antes, en vez de cuando
137
+ el segundo no arranca y el error habla de un puerto ocupado sin decir por
138
+ quien.
139
+ """
140
+ wanted = {s.port for s in services if s.port}
141
+ if not wanted:
142
+ return []
143
+ try:
144
+ mio = root.resolve()
145
+ except OSError:
146
+ mio = root
147
+
148
+ duenos = registry.declared_ports()
149
+ checks = []
150
+ for port in sorted(wanted):
151
+ otros = [p for p in duenos.get(port, []) if p != mio]
152
+ if not otros:
153
+ continue
154
+ checks.append(
155
+ Check(
156
+ f"puerto {port} compartido",
157
+ "warn",
158
+ f"tambien lo declara {', '.join(str(p) for p in otros)}",
159
+ "arrancalos de a uno, o cambia el port: en uno de los stack.yaml",
160
+ )
161
+ )
162
+ return checks
163
+
164
+
165
+ def _parse_env_keys(path: Path) -> dict[str, str]:
166
+ """Claves y valores de un .env. Lo que no entienda queda afuera.
167
+
168
+ ponytail: parte en el primer `=`, saltea vacias y comentarios, y nada mas.
169
+ Sin comillas, escapes, `export` ni valores multilinea. El techo: un archivo
170
+ con un valor de varias lineas lee la segunda como una clave rara y la da
171
+ por faltante. El dia que aparezca uno, hay librerias para esto.
172
+ """
173
+ result = {}
174
+ try:
175
+ content = path.read_text(encoding="utf-8", errors="replace")
176
+ except OSError:
177
+ return result
178
+ for line in content.splitlines():
179
+ line = line.strip()
180
+ if not line or line.startswith("#"):
181
+ continue
182
+ if "=" in line:
183
+ k, v = line.split("=", 1)
184
+ result[k.strip()] = v.strip()
185
+ return result
186
+
187
+
188
+ _PLACEHOLDER_SECRETS = {
189
+ "changeme",
190
+ "change_me",
191
+ "your_secret_here",
192
+ "your_api_key",
193
+ "your-secret-here",
194
+ "your-api-key",
195
+ "secret",
196
+ "password",
197
+ "admin",
198
+ "123456",
199
+ "todo",
200
+ }
201
+
202
+
203
+ def _dotenv(root: Path) -> list[Check]:
204
+ examples = [p for p in (root / ".env.example", root / ".env.template") if p.is_file()]
205
+ if not examples:
206
+ return []
207
+ example_path = examples[0]
208
+ ejemplo = example_path.name
209
+ env_path = root / ".env"
210
+
211
+ if not env_path.is_file():
212
+ return [
213
+ Check(
214
+ "variables de entorno",
215
+ "warn",
216
+ f"{ejemplo} existe pero falta .env local",
217
+ f"cp {ejemplo} .env",
218
+ )
219
+ ]
220
+
221
+ # Nombres de claves, nunca valores: esto sale por HTTP en /api/doctor.
222
+ declaradas = _parse_env_keys(example_path)
223
+ propias = _parse_env_keys(env_path)
224
+ faltan = [k for k in declaradas if k not in propias]
225
+ vacias = [k for k in declaradas if k in propias and not propias[k]]
226
+ placeholders = [
227
+ k
228
+ for k, v in propias.items()
229
+ if v.strip().strip("'\"").lower() in _PLACEHOLDER_SECRETS
230
+ or v.strip().strip("'\"").lower().startswith(("your_", "your-"))
231
+ ]
232
+
233
+ # Las advertencias en `warn` y no en `fail`. En este comando `fail` esta reservado
234
+ # para lo que no puede arrancar y se sabe con certeza: un binario que no
235
+ # esta en el PATH, docker apagado, un stack.yaml invalido. Una clave que
236
+ # falta en el .env es una inferencia: puede ser opcional, o venir del
237
+ # entorno o del compose. Un rojo equivocado gasta el unico rojo que hay.
238
+ checks = []
239
+ if faltan:
240
+ checks.append(
241
+ Check(
242
+ "variables de entorno",
243
+ "warn",
244
+ f"{ejemplo} declara claves que el .env no tiene: {', '.join(faltan)}",
245
+ "agregalas al .env, o sacalas del ejemplo si ya no hacen falta",
246
+ )
247
+ )
248
+ if vacias:
249
+ checks.append(
250
+ Check(
251
+ "variables de entorno",
252
+ "warn",
253
+ f"sin valor en el .env: {', '.join(vacias)}",
254
+ "completalas, o dejalas asi si el vacio es a proposito",
255
+ )
256
+ )
257
+ if placeholders:
258
+ checks.append(
259
+ Check(
260
+ "variables de entorno",
261
+ "warn",
262
+ f"valores de ejemplo/inseguros en .env: {', '.join(placeholders)}",
263
+ "reemplaza los placeholders por credenciales reales y seguras",
264
+ )
265
+ )
266
+ return checks or [Check("variables de entorno", "ok", ".env completo")]
267
+
268
+
269
+ def _executables(services: list[config.Service]) -> list[Check]:
270
+ """El primer token de cada comando tiene que estar en el PATH.
271
+
272
+ Cubre de una sola vez el gestor de paquetes del lockfile, docker, uvicorn y
273
+ lo que sea que el usuario haya escrito en su stack.yaml. Un chequeo por
274
+ tecnologia seria la misma pregunta escrita cuatro veces.
275
+ """
276
+ vistos: dict[str, list[str]] = {}
277
+ for service in services:
278
+ vistos.setdefault(_program(service.command), []).append(service.name)
279
+
280
+ checks = []
281
+ for programa, duenos in sorted(vistos.items()):
282
+ if not programa:
283
+ continue
284
+ ruta = shutil.which(programa)
285
+ if ruta:
286
+ checks.append(Check(f"comando {programa}", "ok", ruta))
287
+ else:
288
+ checks.append(
289
+ Check(
290
+ f"comando {programa}",
291
+ "fail",
292
+ f"no esta en el PATH, lo pide {', '.join(duenos)}",
293
+ f"instala {programa}, o corregi el comando en stack.yaml",
294
+ )
295
+ )
296
+ return checks
297
+
298
+
299
+ def _docker() -> Check:
300
+ """El daemon, no el binario. `docker` instalado con Docker Desktop cerrado
301
+ es el caso que mas veces rompe un arranque, y el error que tira compose es
302
+ una linea de 200 caracteres con un named pipe adentro."""
303
+ try:
304
+ done = subprocess.run(
305
+ ["docker", "info", "--format", "{{.ServerVersion}}"],
306
+ capture_output=True,
307
+ text=True,
308
+ errors="replace",
309
+ timeout=DOCKER_TIMEOUT,
310
+ )
311
+ except (OSError, subprocess.TimeoutExpired):
312
+ return Check("daemon de docker", "fail", "no contesto", "abri Docker Desktop")
313
+ if done.returncode != 0:
314
+ return Check("daemon de docker", "fail", "no esta en ejecucion", "abri Docker Desktop")
315
+ return Check("daemon de docker", "ok", f"servidor {done.stdout.strip()}")
316
+
317
+
318
+ def _ports(services: list[config.Service]) -> list[Check]:
319
+ """Solo los puertos declarados. El de un `npm run dev` no se sabe hasta que
320
+ arranca, y adivinarlo seria peor que no decir nada."""
321
+ wanted = [s.port for s in services if s.port]
322
+ if not wanted:
323
+ return [Check("puertos", "ok", "ninguno declarado, se descubren al arrancar")]
324
+
325
+ scanned = ports.scan_many(wanted)
326
+ checks = []
327
+ for service in services:
328
+ if not service.port:
329
+ continue
330
+ status = scanned.get(service.port)
331
+ if status is None or status.free:
332
+ checks.append(Check(f"puerto {service.port}", "ok", f"libre ({service.name})"))
333
+ continue
334
+ quien = status.name or "desconocido"
335
+ checks.append(
336
+ Check(
337
+ f"puerto {service.port}",
338
+ "warn",
339
+ f"ocupado por {quien} (pid {status.pid}), lo pide {service.name}",
340
+ f"stackhelx free {service.port}",
341
+ )
342
+ )
343
+ return checks
344
+
345
+
346
+ def _archivo(root: Path) -> Path | None:
347
+ try:
348
+ return config.find(root)
349
+ except config.ConfigError:
350
+ return None
351
+
352
+
353
+ def _program(command: str) -> str:
354
+ """Primer token del comando, sin comillas."""
355
+ head = command.strip().split(maxsplit=1)
356
+ return head[0].strip('"\'') if head else ""
@@ -0,0 +1,42 @@
1
+ """Validacion de los identificadores que se convierten en nombre de archivo.
2
+
3
+ Aca no hay lista de comandos peligrosos, y es a proposito. Ver la nota en
4
+ `CLAUDE.md`, "Los comandos de stack.yaml no se filtran por contenido".
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ # Longitud máxima permitida para identificadores seguros
12
+ MAX_IDENT_LEN = 64
13
+
14
+ # Identificadores alfanuméricos seguros para IDs de proyecto y servicios
15
+ _IDENT_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$")
16
+
17
+ # Nombres de dispositivos reservados en Windows (DOS Device Names)
18
+ _WINDOWS_RESERVED = {
19
+ "CON", "PRN", "AUX", "NUL",
20
+ "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
21
+ "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
22
+ }
23
+
24
+
25
+ class GuardrailError(ValueError):
26
+ """Identificador rechazado por no ser seguro como nombre de archivo."""
27
+
28
+
29
+ def validate_identifier(ident: str, field_name: str = "identificador") -> str:
30
+ """Valida que un ID de proyecto o servicio sea alfanumérico seguro (sin path traversal ni DOS devices)."""
31
+ clean = str(ident).strip()
32
+ if not clean:
33
+ raise GuardrailError(f"{field_name} no puede estar vacío")
34
+ if len(clean) > MAX_IDENT_LEN:
35
+ raise GuardrailError(f"{field_name} excede la longitud máxima permitida ({MAX_IDENT_LEN} caracteres)")
36
+ if not _IDENT_PATTERN.match(clean):
37
+ raise GuardrailError(
38
+ f"{field_name} inválido ({clean!r}): solo se permiten letras, números, guiones y guiones bajos"
39
+ )
40
+ if clean.upper() in _WINDOWS_RESERVED:
41
+ raise GuardrailError(f"{field_name} inválido ({clean!r}): nombre de dispositivo reservado en Windows")
42
+ return clean
stackhelx/history.py ADDED
@@ -0,0 +1,86 @@
1
+ import collections
2
+ import json
3
+ import threading
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from . import guardrails, registry
9
+
10
+ MAX_LIMIT = 50
11
+ MAX_ENTRIES = 500
12
+ RETAIN_ENTRIES = 250
13
+ _history_lock = threading.Lock()
14
+
15
+
16
+ def _history_file(pid: str) -> Path:
17
+ clean_pid = guardrails.validate_identifier(pid, "pid")
18
+ return (registry.HOME / "history" / f"{clean_pid}.jsonl").resolve()
19
+
20
+
21
+ def _read_tail_lines(path: Path, limit: int) -> list[str]:
22
+ """Lee las últimas `limit` líneas de un archivo sin cortes en líneas largas."""
23
+ if limit <= 0:
24
+ return []
25
+ try:
26
+ with path.open("r", encoding="utf-8", errors="replace") as f:
27
+ return [line.rstrip("\r\n") for line in collections.deque(f, maxlen=limit) if line.strip()]
28
+ except OSError:
29
+ return []
30
+
31
+
32
+ def _rotate_if_needed(path: Path) -> None:
33
+ """Si el archivo supera el umbral de tamaño, lo trunca reteniendo solo los últimos RETAIN_ENTRIES."""
34
+ try:
35
+ if not path.is_file() or path.stat().st_size < 100_000:
36
+ return
37
+ last_lines = _read_tail_lines(path, RETAIN_ENTRIES)
38
+ if last_lines:
39
+ tmp_path = path.with_suffix(".tmp")
40
+ with tmp_path.open("w", encoding="utf-8") as f:
41
+ for line_str in last_lines:
42
+ f.write(line_str.strip() + "\n")
43
+ tmp_path.replace(path)
44
+ except (OSError, ValueError):
45
+ pass
46
+
47
+
48
+ def append(pid: str, data: dict[str, Any]) -> None:
49
+ if "timestamp" not in data:
50
+ data["timestamp"] = datetime.now(timezone.utc).isoformat()
51
+
52
+ try:
53
+ path = _history_file(pid)
54
+ path.parent.mkdir(parents=True, exist_ok=True)
55
+ with _history_lock:
56
+ with path.open("a", encoding="utf-8") as f:
57
+ f.write(json.dumps(data) + "\n")
58
+ _rotate_if_needed(path)
59
+ except (OSError, ValueError):
60
+ pass
61
+
62
+
63
+ def read(pid: str, limit: int = 5) -> list[dict[str, Any]]:
64
+ limit = max(1, min(limit, MAX_LIMIT))
65
+ try:
66
+ path = _history_file(pid)
67
+ except ValueError:
68
+ return []
69
+
70
+ if not path.is_file():
71
+ return []
72
+
73
+ records = []
74
+ with _history_lock:
75
+ raw_lines = _read_tail_lines(path, limit)
76
+
77
+ for line in raw_lines:
78
+ if line.strip():
79
+ try:
80
+ record = json.loads(line)
81
+ if isinstance(record, dict):
82
+ records.append(record)
83
+ except json.JSONDecodeError:
84
+ continue
85
+
86
+ return records