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 ADDED
@@ -0,0 +1,2 @@
1
+ __version__ = "1.0.0"
2
+
stackhelx/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
stackhelx/browse.py ADDED
@@ -0,0 +1,160 @@
1
+ """Exploracion de carpetas para el selector de proyectos de la interfaz.
2
+
3
+ El navegador no puede dar rutas absolutas (webkitdirectory las oculta a
4
+ proposito), asi que el listado sale de aca. Solo nombres de carpetas y de los
5
+ archivos marcadores: nunca contenido, nunca archivos sueltos.
6
+
7
+ Recorrer el disco no tiene nada que ver con servir HTTP, y este modulo no
8
+ importa nada de fastapi: `server` traduce el ValueError a un 400.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from pathlib import Path
15
+
16
+ import psutil
17
+
18
+ from . import detect
19
+
20
+ MARKERS = (
21
+ "stack.yaml", "stack.yml", *detect.COMPOSE_NAMES,
22
+ "package.json", "manage.py", "Cargo.toml",
23
+ "bunfig.toml", "mix.exs",
24
+ "pom.xml", "build.gradle", "build.gradle.kts",
25
+ )
26
+
27
+ # Carpetas que nunca son un proyecto y solo hacen ruido al navegar.
28
+ SKIP = {"node_modules", "__pycache__", "venv", "env", "dist", "build", "target"}
29
+ LIMIT = 300
30
+
31
+
32
+ def listing(raw: str) -> dict:
33
+ """Contenido navegable de `raw`, o las raices si viene vacio.
34
+
35
+ Levanta ValueError con un motivo legible si la ruta no sirve.
36
+ """
37
+ if not raw:
38
+ return roots()
39
+
40
+ path = Path(raw).expanduser()
41
+ if not path.is_absolute():
42
+ raise ValueError(f"la ruta debe ser absoluta: {raw}")
43
+ try:
44
+ path = path.resolve(strict=True)
45
+ except OSError:
46
+ raise ValueError(f"no existe: {raw}") from None
47
+ if not path.is_dir():
48
+ raise ValueError(f"no es un directorio: {path}")
49
+
50
+ names = _subdirs(path)
51
+ return {
52
+ "path": str(path),
53
+ # "" manda a la vista de raices; None es no tener a donde subir.
54
+ "parent": "" if path.parent == path else str(path.parent),
55
+ "markers": markers(path),
56
+ "truncated": len(names) > LIMIT,
57
+ "entries": [
58
+ {"name": name, "path": str(path / name), "markers": markers(path / name)}
59
+ for name in names[:LIMIT]
60
+ ],
61
+ }
62
+
63
+
64
+ def _subdirs(path: Path) -> list[str]:
65
+ found = []
66
+ try:
67
+ with os.scandir(path) as items:
68
+ for item in items:
69
+ if item.name.startswith(".") or item.name in SKIP:
70
+ continue
71
+ try:
72
+ if item.is_dir():
73
+ found.append(item.name)
74
+ except OSError:
75
+ continue # enlace roto o unidad desconectada
76
+ except OSError:
77
+ return [] # sin permisos se ve vacia, que no es un error del usuario
78
+ return sorted(found, key=str.lower)
79
+
80
+
81
+ def _es_archivo(entry: os.DirEntry) -> bool:
82
+ try:
83
+ return entry.is_file()
84
+ except OSError:
85
+ return False # enlace roto o unidad desconectada
86
+
87
+
88
+ def markers(path: Path) -> list[str]:
89
+ """Archivos que delatan un proyecto.
90
+
91
+ Un `scandir` por carpeta y cruce de nombres, y no un `is_file()` por
92
+ marcador, que era el techo que este mismo comentario dejaba anotado: el
93
+ costo estaba atado al largo de `MARKERS`, y sumar lenguajes lo empuja.
94
+ Ahora no crece con la lista, solo con lo que la carpeta tiene de verdad.
95
+
96
+ Se pregunta por la entrada solo cuando el nombre ya coincide con un
97
+ marcador. Sin ese filtro, una carpeta con mil archivos pagaria mil
98
+ preguntas y el cambio seria una regresion en vez de una mejora.
99
+
100
+ **Sin distinguir mayusculas, y no es un detalle.** `(path / "Cargo.toml")
101
+ .is_file()` daba True con un `cargo.toml` en disco, porque el sistema de
102
+ archivos lo resolvia: NTFS y el APFS por defecto de macOS no distinguen.
103
+ Comparar nombres exactos habria borrado el badge en dos de las tres
104
+ plataformas de la CI, en silencio y sin ningun test en rojo. Se compara
105
+ igual en las tres, que ademas es una respuesta y no una casualidad del
106
+ sistema de archivos donde toco correr.
107
+
108
+ El conjunto se arma en cada llamada a proposito: `MARKERS` se puede
109
+ parchear (los tests lo hacen), y un conjunto de modulo quedaria viejo. Es
110
+ un punado de cadenas en memoria contra una llamada al sistema, no se nota.
111
+ """
112
+ objetivo = {name.lower(): name for name in MARKERS}
113
+ try:
114
+ with os.scandir(path) as items:
115
+ presentes = {
116
+ item.name.lower()
117
+ for item in items
118
+ if item.name.lower() in objetivo and _es_archivo(item)
119
+ }
120
+ except OSError:
121
+ return [] # sin permisos o no existe: se ve sin badges, no es un error
122
+ return [name for clave, name in objetivo.items() if clave in presentes]
123
+
124
+
125
+ def roots() -> dict:
126
+ """Punto de partida: la home del usuario y las unidades montadas."""
127
+ seen = {str(Path.home())}
128
+ entries = [{"name": "Inicio", "path": str(Path.home()), "markers": []}]
129
+ try:
130
+ partitions = psutil.disk_partitions(all=False)
131
+ except OSError:
132
+ partitions = []
133
+ for partition in partitions:
134
+ mount = partition.mountpoint
135
+ if mount not in seen and os.path.isdir(mount):
136
+ seen.add(mount)
137
+ entries.append({"name": mount, "path": mount, "markers": []})
138
+ return {"path": "", "parent": None, "markers": [], "truncated": False, "entries": entries}
139
+
140
+
141
+ def frequent_roots(project_paths: list[Path]) -> list[str]:
142
+ """Calcula las carpetas padre mas comunes de los proyectos registrados.
143
+
144
+ Permite saltos directos en el selector de carpetas y autocompletado sin
145
+ recorrer todo el arbol de discos.
146
+ """
147
+ counts: dict[str, int] = {}
148
+ for p in project_paths:
149
+ try:
150
+ parent = p.parent
151
+ if parent != p and parent.is_dir():
152
+ parent_str = str(parent)
153
+ counts[parent_str] = counts.get(parent_str, 0) + 1
154
+ except OSError:
155
+ continue
156
+
157
+ # Ordenar por frecuencia descendente
158
+ sorted_parents = [k for k, _ in sorted(counts.items(), key=lambda item: item[1], reverse=True)]
159
+ return sorted_parents[:6]
160
+