paramsx 2.3.1__tar.gz → 2.4.1__tar.gz

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.
Files changed (35) hide show
  1. {paramsx-2.3.1 → paramsx-2.4.1}/CHANGELOG.md +17 -0
  2. paramsx-2.4.1/MANIFEST.in +4 -0
  3. {paramsx-2.3.1 → paramsx-2.4.1}/PKG-INFO +37 -3
  4. paramsx-2.3.1/paramsx.egg-info/PKG-INFO → paramsx-2.4.1/README.md +33 -22
  5. paramsx-2.4.1/paramsx/__init__.py +1 -0
  6. paramsx-2.4.1/paramsx/core/__init__.py +2 -0
  7. paramsx-2.4.1/paramsx/core/config.py +429 -0
  8. paramsx-2.4.1/paramsx/core/rutas.py +153 -0
  9. {paramsx-2.3.1 → paramsx-2.4.1}/paramsx/funcions.py +71 -90
  10. {paramsx-2.3.1 → paramsx-2.4.1}/paramsx/main.py +216 -254
  11. paramsx-2.4.1/paramsx/text.py +17 -0
  12. paramsx-2.4.1/paramsx/web/__init__.py +10 -0
  13. paramsx-2.4.1/paramsx/web/app.py +417 -0
  14. paramsx-2.4.1/paramsx/web/ssm.py +484 -0
  15. paramsx-2.4.1/paramsx/web/static/ajustes.js +185 -0
  16. paramsx-2.4.1/paramsx/web/static/app.css +262 -0
  17. paramsx-2.4.1/paramsx/web/static/comun.js +58 -0
  18. paramsx-2.4.1/paramsx/web/static/paramsx.js +430 -0
  19. paramsx-2.4.1/paramsx/web/templates/index.html +125 -0
  20. paramsx-2.3.1/README.md → paramsx-2.4.1/paramsx.egg-info/PKG-INFO +56 -2
  21. paramsx-2.4.1/paramsx.egg-info/SOURCES.txt +29 -0
  22. {paramsx-2.3.1 → paramsx-2.4.1}/paramsx.egg-info/dependency_links.txt +0 -0
  23. {paramsx-2.3.1 → paramsx-2.4.1}/paramsx.egg-info/entry_points.txt +0 -0
  24. {paramsx-2.3.1 → paramsx-2.4.1}/paramsx.egg-info/requires.txt +3 -0
  25. {paramsx-2.3.1 → paramsx-2.4.1}/paramsx.egg-info/top_level.txt +0 -0
  26. {paramsx-2.3.1 → paramsx-2.4.1}/setup.py +6 -0
  27. paramsx-2.4.1/tests/test_core.py +129 -0
  28. paramsx-2.4.1/tests/test_web.py +295 -0
  29. paramsx-2.3.1/MANIFEST.in +0 -3
  30. paramsx-2.3.1/paramsx/__init__.py +0 -1
  31. paramsx-2.3.1/paramsx/text.py +0 -12
  32. paramsx-2.3.1/paramsx.egg-info/SOURCES.txt +0 -16
  33. {paramsx-2.3.1 → paramsx-2.4.1}/LICENSE +0 -0
  34. {paramsx-2.3.1 → paramsx-2.4.1}/paramsx/paramsx_config.py +0 -0
  35. {paramsx-2.3.1 → paramsx-2.4.1}/setup.cfg +0 -0
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.4.1
4
+
5
+ ### Cambiado
6
+
7
+ - **La versión web viene incluida**: Flask pasa a ser una dependencia normal, así que basta con `pip install paramsx` (o `pipx install paramsx`) para tener las dos versiones. `paramsx[web]` se sigue aceptando, pero ya no añade nada.
8
+
9
+ ## 2.4.0
10
+
11
+ ### Añadido
12
+
13
+ - **Versión web.** `paramsx` pregunta ahora **Web o Terminal** (o `paramsx --web` / `paramsx --tui`). La web es un Flask local que se abre en el navegador: explorar las rutas de la cuenta, cargar un grupo, editar varios parámetros a la vez, revisar contra AWS en vivo (con detección de conflictos), aplicar, historial de versiones y la configuración en formulario. Es opcional: `pip install 'paramsx[web]'`. La terminal sigue igual.
14
+ - Los grupos de `parameter_list` admiten un `"nombre"` opcional para mostrarlos en la web; la terminal lo ignora.
15
+
16
+ ### Cambiado
17
+
18
+ - La lógica de perfiles, rutas y configuración pasa a `paramsx/core/`, compartida por la terminal y la web. Sin cambios de comportamiento en la terminal.
19
+
3
20
  ## 2.3.1
4
21
 
5
22
  Solo documentación: el programa es idéntico a la 2.3.0. Sale como versión porque la
@@ -0,0 +1,4 @@
1
+ include paramsx/paramsx_config.py
2
+ include CHANGELOG.md
3
+ recursive-include paramsx/web/templates *.html
4
+ recursive-include paramsx/web/static *
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: paramsx
3
- Version: 2.3.1
3
+ Version: 2.4.1
4
4
  Summary: Librería para gestionar y respaldar parámetros de AWS SSM de manera sencilla y eficiente.
5
5
  Home-page: https://github.com/pistatxos/paramsx
6
6
  Author: Mariox
@@ -9,18 +9,21 @@ Description-Content-Type: text/markdown
9
9
  License-File: LICENSE
10
10
  Requires-Dist: boto3
11
11
  Requires-Dist: windows-curses; platform_system == "Windows"
12
+ Requires-Dist: flask>=2.2
13
+ Provides-Extra: web
12
14
  Dynamic: author
13
15
  Dynamic: author-email
14
16
  Dynamic: description
15
17
  Dynamic: description-content-type
16
18
  Dynamic: home-page
17
19
  Dynamic: license-file
20
+ Dynamic: provides-extra
18
21
  Dynamic: requires-dist
19
22
  Dynamic: summary
20
23
 
21
24
  # ParamsX
22
25
 
23
- ParamsX es una herramienta de terminal para **gestionar parámetros de AWS Systems Manager Parameter Store** de forma sencilla y controlada.
26
+ ParamsX es una herramienta de terminal **y web** para **gestionar parámetros de AWS Systems Manager Parameter Store** de forma sencilla y controlada.
24
27
 
25
28
  Permite descargar parámetros a un fichero editable, comparar los cambios con AWS antes de aplicarlos, crear backups y añadir nuevos parámetros sin trabajar directamente desde la consola de AWS.
26
29
 
@@ -58,6 +61,35 @@ Y ejecuta la herramienta:
58
61
  paramsx
59
62
  ```
60
63
 
64
+ Al arrancar pregunta **1. Web** o **2. Terminal** (o directo: `paramsx --web` / `paramsx --tui`).
65
+
66
+ ---
67
+
68
+ ## Versión web
69
+
70
+ ```bash
71
+ paramsx --web
72
+ ```
73
+
74
+ Abre el navegador en `http://127.0.0.1:8765/?token=…` (si el puerto está ocupado, el siguiente libre). Ctrl+C para cerrar. No necesita `paramsx configure`: la configuración se hace en **Ajustes** y se guarda en el mismo `~/.xsoft/paramsx_config.py` que lee la terminal.
75
+
76
+ | Pantalla | Qué hace |
77
+ |---|---|
78
+ | **ParamsX** | Explora las rutas de la cuenta (solo nombres), carga un grupo × entorno o una ruta, y edita todos sus parámetros a la vez: valor, descripción, tipo y etiquetas. |
79
+ | **Revisar** | Compara con AWS **en ese momento**: si alguien ha cambiado un parámetro desde que lo cargaste, sale como conflicto y no se toca. Para aplicar hay que escribir `APLICAR EN <cuenta>`. |
80
+ | **Historial** | Las versiones de AWS (hasta 100); «Usar esta versión» la pone en el editor. |
81
+ | **Ajustes** | Perfil de AWS y región, entornos, perfiles, grupos (`parameter_list`) y etiquetas obligatorias. «Buscar en la cuenta» añade una carpeta como grupo detectando dónde va el entorno. |
82
+
83
+ ![Editar un grupo](https://raw.githubusercontent.com/Pistatxos/paramsx/main/docs/img/web-editar.png)
84
+ ![Revisar con un conflicto](https://raw.githubusercontent.com/Pistatxos/paramsx/main/docs/img/web-revisar.png)
85
+
86
+ Lo que conviene saber:
87
+
88
+ - **Solo escucha en 127.0.0.1** y exige el token de la URL de arranque. Los valores **no se escriben nunca a disco**: viven en la página y desaparecen al cerrarla.
89
+ - Lo que tu rol no puede leer o descifrar (KMS) sale con 🔒 en solo lectura, y el resto se edita con normalidad.
90
+ - Al guardar se conserva la clave KMS propia, la descripción, el tier Advanced y el `DataType`. Los cambios solo de etiquetas no crean versión nueva.
91
+ - La web lee tu configuración **como datos**, sin ejecutarla: si usa variables o `import`, edítala a mano. Al guardar se conservan tus comentarios y el fichero anterior queda como `paramsx_config.py.bak`.
92
+
61
93
  Para comprobar la versión instalada:
62
94
 
63
95
  ```bash
@@ -939,7 +971,9 @@ paramsx --version
939
971
 
940
972
  | Comando | Descripción |
941
973
  |---|---|
942
- | `paramsx` | Abre el menú interactivo. |
974
+ | `paramsx` | Pregunta: web o terminal. |
975
+ | `paramsx --web` | Abre la versión web en el navegador. |
976
+ | `paramsx --tui` | Abre el menú de terminal. |
943
977
  | `paramsx configure` | Crea o comprueba la configuración. |
944
978
  | `paramsx configure --ejemplo` | Genera la plantilla completa de configuración. |
945
979
  | `paramsx --version` | Muestra la versión instalada. |
@@ -1,26 +1,6 @@
1
- Metadata-Version: 2.4
2
- Name: paramsx
3
- Version: 2.3.1
4
- Summary: Librería para gestionar y respaldar parámetros de AWS SSM de manera sencilla y eficiente.
5
- Home-page: https://github.com/pistatxos/paramsx
6
- Author: Mariox
7
- Author-email: info@tomonota.net
8
- Description-Content-Type: text/markdown
9
- License-File: LICENSE
10
- Requires-Dist: boto3
11
- Requires-Dist: windows-curses; platform_system == "Windows"
12
- Dynamic: author
13
- Dynamic: author-email
14
- Dynamic: description
15
- Dynamic: description-content-type
16
- Dynamic: home-page
17
- Dynamic: license-file
18
- Dynamic: requires-dist
19
- Dynamic: summary
20
-
21
1
  # ParamsX
22
2
 
23
- ParamsX es una herramienta de terminal para **gestionar parámetros de AWS Systems Manager Parameter Store** de forma sencilla y controlada.
3
+ ParamsX es una herramienta de terminal **y web** para **gestionar parámetros de AWS Systems Manager Parameter Store** de forma sencilla y controlada.
24
4
 
25
5
  Permite descargar parámetros a un fichero editable, comparar los cambios con AWS antes de aplicarlos, crear backups y añadir nuevos parámetros sin trabajar directamente desde la consola de AWS.
26
6
 
@@ -58,6 +38,35 @@ Y ejecuta la herramienta:
58
38
  paramsx
59
39
  ```
60
40
 
41
+ Al arrancar pregunta **1. Web** o **2. Terminal** (o directo: `paramsx --web` / `paramsx --tui`).
42
+
43
+ ---
44
+
45
+ ## Versión web
46
+
47
+ ```bash
48
+ paramsx --web
49
+ ```
50
+
51
+ Abre el navegador en `http://127.0.0.1:8765/?token=…` (si el puerto está ocupado, el siguiente libre). Ctrl+C para cerrar. No necesita `paramsx configure`: la configuración se hace en **Ajustes** y se guarda en el mismo `~/.xsoft/paramsx_config.py` que lee la terminal.
52
+
53
+ | Pantalla | Qué hace |
54
+ |---|---|
55
+ | **ParamsX** | Explora las rutas de la cuenta (solo nombres), carga un grupo × entorno o una ruta, y edita todos sus parámetros a la vez: valor, descripción, tipo y etiquetas. |
56
+ | **Revisar** | Compara con AWS **en ese momento**: si alguien ha cambiado un parámetro desde que lo cargaste, sale como conflicto y no se toca. Para aplicar hay que escribir `APLICAR EN <cuenta>`. |
57
+ | **Historial** | Las versiones de AWS (hasta 100); «Usar esta versión» la pone en el editor. |
58
+ | **Ajustes** | Perfil de AWS y región, entornos, perfiles, grupos (`parameter_list`) y etiquetas obligatorias. «Buscar en la cuenta» añade una carpeta como grupo detectando dónde va el entorno. |
59
+
60
+ ![Editar un grupo](https://raw.githubusercontent.com/Pistatxos/paramsx/main/docs/img/web-editar.png)
61
+ ![Revisar con un conflicto](https://raw.githubusercontent.com/Pistatxos/paramsx/main/docs/img/web-revisar.png)
62
+
63
+ Lo que conviene saber:
64
+
65
+ - **Solo escucha en 127.0.0.1** y exige el token de la URL de arranque. Los valores **no se escriben nunca a disco**: viven en la página y desaparecen al cerrarla.
66
+ - Lo que tu rol no puede leer o descifrar (KMS) sale con 🔒 en solo lectura, y el resto se edita con normalidad.
67
+ - Al guardar se conserva la clave KMS propia, la descripción, el tier Advanced y el `DataType`. Los cambios solo de etiquetas no crean versión nueva.
68
+ - La web lee tu configuración **como datos**, sin ejecutarla: si usa variables o `import`, edítala a mano. Al guardar se conservan tus comentarios y el fichero anterior queda como `paramsx_config.py.bak`.
69
+
61
70
  Para comprobar la versión instalada:
62
71
 
63
72
  ```bash
@@ -939,7 +948,9 @@ paramsx --version
939
948
 
940
949
  | Comando | Descripción |
941
950
  |---|---|
942
- | `paramsx` | Abre el menú interactivo. |
951
+ | `paramsx` | Pregunta: web o terminal. |
952
+ | `paramsx --web` | Abre la versión web en el navegador. |
953
+ | `paramsx --tui` | Abre el menú de terminal. |
943
954
  | `paramsx configure` | Crea o comprueba la configuración. |
944
955
  | `paramsx configure --ejemplo` | Genera la plantilla completa de configuración. |
945
956
  | `paramsx --version` | Muestra la versión instalada. |
@@ -0,0 +1 @@
1
+ __version__ = "2.4.1"
@@ -0,0 +1,2 @@
1
+ """Lógica compartida entre la terminal y la web: perfiles, rutas y configuración.
2
+ Nada de aquí importa curses ni Flask."""
@@ -0,0 +1,429 @@
1
+ import ast
2
+ import json
3
+ import os
4
+
5
+ from .. import paramsx_config as plantilla_config
6
+ from .rutas import (
7
+ POSICIONES_ENTORNO, CASES_ENTORNO, CASES_RUTA, build_full_path, validar_marcador,
8
+ )
9
+
10
+
11
+ # Ruta de la configuración personalizada
12
+ CONFIG_PATH = os.path.expanduser("~/.xsoft/paramsx_config.py")
13
+
14
+ # Ruta de la plantilla que se copia con 'paramsx configure'
15
+ PLANTILLA_PATH = os.path.join(
16
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "paramsx_config.py"
17
+ )
18
+
19
+ MENSAJE_MIGRACION = """BREAKING CHANGE en la configuración de ParamsX
20
+ ------------------------------------------------
21
+ 'parameter_list' ya no es una lista de strings: ahora cada entrada es un diccionario
22
+ que declara qué perfil usa. Edita a mano ~/.xsoft/paramsx_config.py:
23
+
24
+ "entornos": ['dev', 'pre', 'prod'], # SIEMPRE en minúscula
25
+ "parameter_list": [
26
+ {"path": "/common", "perfil": "min"}, # /common + dev -> /dev/common
27
+ {"path": "/rds", "perfil": "min"}, # /rds + dev -> /dev/rds
28
+ {"path": "/EMAIL", "perfil": "max"}, # /EMAIL + dev -> /EMAIL/DEV
29
+ {"path": "/API/STA", "perfil": "max"}, # /API/STA + dev -> /API/STA/DEV
30
+ ]
31
+
32
+ Los perfiles se definen en el diccionario 'perfiles' del mismo fichero, con la posición
33
+ y el case del entorno. 'min' y 'max' vienen predefinidos en la plantilla.
34
+
35
+ Ya no existe 'entornos_old': el entorno se escribe a partir de la lista 'entornos',
36
+ que va siempre en minúscula, aplicándole el 'case_entorno' del perfil."""
37
+
38
+
39
+ # Opciones que viven fuera de 'configuraciones' y son opcionales: si el usuario no las
40
+ # tiene, se usa el valor de la plantilla. Se listan para poder decirle qué le falta.
41
+ CLAVES_OPCIONALES = (
42
+ "perfiles", "perfil_nuevos", "fichero_por_ruta", "forzar_securestring",
43
+ "tags_activas", "obligatorias_vacias", "tags_obligatorias",
44
+ )
45
+
46
+ # Nombres antiguos que se siguen aceptando -> nombre actual. Se traducen al cargar, así
47
+ # que dentro del código solo existe el nombre nuevo.
48
+ NOMBRES_ANTIGUOS = {
49
+ "naming": "perfiles", # 2.2.0
50
+ "convencion_nuevos": "perfil_nuevos", # 2.2.0
51
+ "abac": "tags_activas", # 2.0.0 y 2.1.0
52
+ }
53
+
54
+
55
+ # La configuración a partir de los nombres definidos en el fichero del usuario (un dict
56
+ # nombre -> valor). Lo usan la terminal, que ejecuta el fichero, y la web, que lo lee
57
+ # como datos: así las dos rellenan los valores por defecto igual.
58
+ def config_desde_valores(valores):
59
+ if not isinstance(valores.get("configuraciones"), dict):
60
+ raise ValueError("Falta el diccionario 'configuraciones'.")
61
+
62
+ config = dict(valores["configuraciones"])
63
+ # Todo lo que va fuera de 'configuraciones' es opcional en el fichero del usuario:
64
+ # si no está, se usa el valor de la plantilla que trae el paquete.
65
+ # 'naming' es el nombre que tuvo 'perfiles' en la 2.2.0
66
+ config["perfiles"] = dict(
67
+ valores.get("perfiles", valores.get("naming", plantilla_config.perfiles))
68
+ )
69
+ # 'abac' es el nombre antiguo (2.0/2.1) de 'tags_activas': se sigue aceptando.
70
+ config["tags_activas"] = bool(
71
+ valores.get("tags_activas", valores.get("abac", plantilla_config.tags_activas))
72
+ )
73
+ config["obligatorias_vacias"] = bool(
74
+ valores.get("obligatorias_vacias", plantilla_config.obligatorias_vacias)
75
+ )
76
+ config["tags_obligatorias"] = list(
77
+ valores.get("tags_obligatorias", plantilla_config.tags_obligatorias)
78
+ )
79
+ config["fichero_por_ruta"] = bool(
80
+ valores.get("fichero_por_ruta", plantilla_config.fichero_por_ruta)
81
+ )
82
+ config["forzar_securestring"] = bool(
83
+ valores.get("forzar_securestring", plantilla_config.forzar_securestring)
84
+ )
85
+ # Si no se declara, los parámetros nuevos usan el primer perfil definido
86
+ # ('convencion_nuevos' es el nombre que tuvo en la 2.2.0)
87
+ primer_perfil = next(iter(config["perfiles"]), None)
88
+ config["perfil_nuevos"] = (
89
+ valores.get("perfil_nuevos")
90
+ or valores.get("convencion_nuevos")
91
+ or primer_perfil
92
+ )
93
+ return config
94
+
95
+
96
+ # Validar la configuración del usuario. Devuelve (errores, avisos).
97
+ def validate_config(config):
98
+ errores = []
99
+ avisos = []
100
+
101
+ for clave in ("profile_name", "region_name", "entornos", "parameter_list"):
102
+ if clave not in config:
103
+ errores.append(f"Falta la clave '{clave}' en configuraciones.")
104
+
105
+ entornos = config.get("entornos")
106
+ if isinstance(entornos, (list, tuple)) and entornos:
107
+ if any(str(e) != str(e).lower() for e in entornos):
108
+ avisos.append(
109
+ "Aviso: 'entornos' debe escribirse en minúscula ('dev', 'pre', 'prod'). "
110
+ "Se normalizará automáticamente, pero actualiza tu configuración."
111
+ )
112
+ elif "entornos" in config:
113
+ errores.append("'entornos' debe ser una lista no vacía de entornos en minúscula.")
114
+
115
+ perfiles = config.get("perfiles")
116
+ if not isinstance(perfiles, dict) or not perfiles:
117
+ errores.append(
118
+ "'perfiles' debe ser un diccionario con al menos un perfil "
119
+ "(ver la plantilla en el propio fichero de configuración)."
120
+ )
121
+ perfiles = {}
122
+ else:
123
+ errores.extend(validar_perfiles(perfiles))
124
+
125
+ perfil_nuevos = config.get("perfil_nuevos")
126
+ if perfiles and perfil_nuevos not in perfiles:
127
+ errores.append(
128
+ f"'perfil_nuevos' apunta a un perfil que no existe: {perfil_nuevos!r}. "
129
+ f"Perfiles definidos en 'perfiles': {', '.join(sorted(perfiles))}."
130
+ )
131
+
132
+ parameter_list = config.get("parameter_list")
133
+ if not isinstance(parameter_list, (list, tuple)) or not parameter_list:
134
+ if "parameter_list" in config:
135
+ errores.append("'parameter_list' debe ser una lista no vacía.")
136
+ return errores, avisos
137
+
138
+ if any(isinstance(entrada, str) for entrada in parameter_list):
139
+ errores.append(MENSAJE_MIGRACION)
140
+ return errores, avisos
141
+
142
+ for entrada in parameter_list:
143
+ if not isinstance(entrada, dict):
144
+ errores.append(f"Entrada inválida en parameter_list: {entrada!r}")
145
+ continue
146
+ path = entrada.get("path")
147
+ nombre_perfil = perfil_de(entrada)
148
+ if not isinstance(path, str) or not path.strip("/"):
149
+ errores.append(f"'path' inválido o vacío en parameter_list: {entrada!r}")
150
+ continue
151
+ if not path.startswith("/"):
152
+ errores.append(f"El 'path' debe empezar por '/': {path!r}")
153
+ if nombre_perfil not in perfiles:
154
+ errores.append(
155
+ f"'perfil' inválido en {path!r}: {nombre_perfil!r}. "
156
+ f"Perfiles definidos en 'perfiles': {', '.join(sorted(perfiles)) or '(ninguno)'}."
157
+ )
158
+ continue
159
+ errores.extend(validar_marcador(path, nombre_perfil, perfiles[nombre_perfil]))
160
+
161
+ # Dos entradas que resuelvan a la misma ruta no rompen nada, pero duplican trabajo
162
+ # y confunden en el menú, así que se avisa.
163
+ vistas = {}
164
+ entornos_muestra = config.get("entornos") or ["dev"]
165
+ for entrada in parameter_list:
166
+ perfil = perfiles.get(perfil_de(entrada)) if isinstance(entrada, dict) else None
167
+ if not isinstance(perfil, dict):
168
+ continue
169
+ try:
170
+ resuelta = build_full_path(entrada["path"], perfil, str(entornos_muestra[0]).lower())
171
+ except ValueError:
172
+ continue
173
+ anterior = vistas.get(resuelta)
174
+ if anterior and anterior != entrada["path"]:
175
+ avisos.append(
176
+ f"Aviso: '{anterior}' y '{entrada['path']}' resuelven a la misma ruta "
177
+ f"({resuelta}). Aparecerán dos veces en el menú."
178
+ )
179
+ vistas.setdefault(resuelta, entrada["path"])
180
+
181
+ return errores, avisos
182
+
183
+
184
+ # Nombre del perfil que usa una entrada. 'convencion' es el nombre que tuvo esta clave
185
+ # en la 2.0, la 2.1 y la 2.2.0, y se sigue aceptando.
186
+ def perfil_de(entrada):
187
+ if not isinstance(entrada, dict):
188
+ return None
189
+ return entrada.get("perfil", entrada.get("convencion"))
190
+
191
+
192
+ # Validar los perfiles. Devuelve la lista de errores.
193
+ def validar_perfiles(perfiles):
194
+ errores = []
195
+ for nombre, perfil in perfiles.items():
196
+ if not isinstance(perfil, dict):
197
+ errores.append(f"El perfil {nombre!r} debe ser un diccionario.")
198
+ continue
199
+
200
+ posicion = perfil.get("posicion_entorno")
201
+ if posicion not in POSICIONES_ENTORNO:
202
+ errores.append(
203
+ f"'posicion_entorno' inválida en el perfil {nombre!r}: {posicion!r}. "
204
+ f"Usa una de: {', '.join(POSICIONES_ENTORNO)}."
205
+ )
206
+
207
+ case_entorno = perfil.get("case_entorno", "lower")
208
+ if case_entorno not in CASES_ENTORNO:
209
+ errores.append(
210
+ f"'case_entorno' inválido en el perfil {nombre!r}: {case_entorno!r}. "
211
+ f"Usa uno de: {', '.join(CASES_ENTORNO)}."
212
+ )
213
+
214
+ case_ruta = perfil.get("case_ruta", "ninguno")
215
+ if case_ruta not in CASES_RUTA:
216
+ errores.append(
217
+ f"'case_ruta' inválido en el perfil {nombre!r}: {case_ruta!r}. "
218
+ f"Usa uno de: {', '.join(CASES_RUTA)}."
219
+ )
220
+
221
+ return errores
222
+
223
+
224
+ # Normalizar la configuración ya validada
225
+ def normalize_config(config):
226
+ config["entornos"] = [str(e).lower() for e in config["entornos"]]
227
+ # Se normaliza la clave del perfil: aunque el usuario haya escrito 'convencion',
228
+ # dentro del programa las entradas solo tienen 'perfil'.
229
+ config["parameter_list"] = [
230
+ {"path": "/" + entrada["path"].strip("/"), "perfil": perfil_de(entrada)}
231
+ for entrada in config["parameter_list"]
232
+ ]
233
+ return config
234
+
235
+
236
+ # ---------------------------------------------------------------------------------------
237
+ # Leer y escribir el fichero como DATOS (lo usa la web: nunca ejecuta el fichero)
238
+ # ---------------------------------------------------------------------------------------
239
+
240
+ class ConfigNoLegible(ValueError):
241
+ """El fichero tiene algo que no es un literal (un import, una variable...) y no se
242
+ puede leer ni reescribir sin ejecutarlo."""
243
+
244
+
245
+ # Las asignaciones de primer nivel del fichero (nombre = literal). Lo que no sea un
246
+ # literal de Python se lista aparte: con eso la web no se arriesga a reescribirlo.
247
+ def leer_asignaciones(texto):
248
+ try:
249
+ arbol = ast.parse(texto or "")
250
+ except SyntaxError as e:
251
+ raise ConfigNoLegible(f"Error de sintaxis en la línea {e.lineno}: {e.msg}.") from None
252
+
253
+ valores, no_literales = {}, []
254
+ for nodo in arbol.body:
255
+ if (isinstance(nodo, ast.Assign) and len(nodo.targets) == 1
256
+ and isinstance(nodo.targets[0], ast.Name)):
257
+ nombre = nodo.targets[0].id
258
+ try:
259
+ valores[nombre] = ast.literal_eval(nodo.value)
260
+ except ValueError:
261
+ no_literales.append(nombre)
262
+ return valores, no_literales
263
+
264
+
265
+ # La configuración del fichero, leída sin ejecutarlo. Devuelve el mismo dict que
266
+ # config_desde_valores (sin normalizar, para no perder los avisos de validate_config).
267
+ def cargar_config_literal(path=CONFIG_PATH):
268
+ with open(path, "r", encoding="utf-8") as f:
269
+ valores, no_literales = leer_asignaciones(f.read())
270
+ relevantes = set(CLAVES_OPCIONALES) | set(NOMBRES_ANTIGUOS) | {"configuraciones"}
271
+ problemas = [n for n in no_literales if n in relevantes]
272
+ if problemas:
273
+ raise ConfigNoLegible(
274
+ f"{', '.join(problemas)} no se puede leer como datos (usa variables, imports o "
275
+ "llamadas). La web no ejecuta el fichero: déjalo con valores literales."
276
+ )
277
+ return config_desde_valores(valores)
278
+
279
+
280
+ # La configuración por defecto (la plantilla), para quien aún no tiene fichero
281
+ def config_plantilla():
282
+ with open(PLANTILLA_PATH, "r", encoding="utf-8") as f:
283
+ valores, _ = leer_asignaciones(f.read())
284
+ return config_desde_valores(valores)
285
+
286
+
287
+ def _literal(valor):
288
+ if isinstance(valor, str):
289
+ return json.dumps(valor, ensure_ascii=False)
290
+ if isinstance(valor, bool) or valor is None:
291
+ return repr(valor)
292
+ if isinstance(valor, (int, float)):
293
+ return repr(valor)
294
+ if isinstance(valor, (list, tuple)):
295
+ return "[" + ", ".join(_literal(v) for v in valor) + "]"
296
+ if isinstance(valor, dict):
297
+ return "{" + ", ".join(f"{_literal(k)}: {_literal(v)}" for k, v in valor.items()) + "}"
298
+ raise TypeError(f"No se puede escribir {type(valor).__name__} en la configuración.")
299
+
300
+
301
+ def _bloque_dict(nombre, valor, comentario=None):
302
+ lineas = [f"{nombre} = {{"]
303
+ for clave, v in valor.items():
304
+ linea = f" {_literal(clave)}: {_literal(v)},"
305
+ if comentario:
306
+ nota = comentario(clave, v)
307
+ if nota:
308
+ linea += f" # {nota}"
309
+ lineas.append(linea)
310
+ lineas.append("}")
311
+ return "\n".join(lineas)
312
+
313
+
314
+ # Las asignaciones nuevas, escritas como Python legible (una entrada por línea)
315
+ def _bloques(config, configuraciones_previas):
316
+ perfiles = config["perfiles"]
317
+ entornos = config["entornos"]
318
+
319
+ def ruta_ejemplo(entrada):
320
+ perfil = perfiles.get(entrada.get("perfil"))
321
+ if not perfil or not entornos:
322
+ return None
323
+ try:
324
+ return "-> " + build_full_path(entrada["path"], perfil, str(entornos[0]).lower())
325
+ except ValueError:
326
+ return None
327
+
328
+ lista = ["["]
329
+ for entrada in config["parameter_list"]:
330
+ linea = f" {_literal(entrada)},"
331
+ nota = ruta_ejemplo(entrada)
332
+ lista.append(linea + (f" # {nota}" if nota else ""))
333
+ lista.append(" ]")
334
+
335
+ # Las claves de 'configuraciones' que la web no gestiona se conservan tal cual
336
+ configuraciones = dict(configuraciones_previas or {})
337
+ for clave in ("profile_name", "region_name", "entornos", "parameter_list"):
338
+ configuraciones.pop(clave, None)
339
+ lineas_conf = [
340
+ "configuraciones = {",
341
+ f' "profile_name": {_literal(config["profile_name"])}, # perfil de ~/.aws/credentials',
342
+ f' "region_name": {_literal(config["region_name"])}, # región de AWS',
343
+ f' "entornos": {_literal(list(entornos))}, # SIEMPRE en minúscula',
344
+ ' "parameter_list": ' + "\n".join(lista) + ",",
345
+ ]
346
+ lineas_conf += [f" {_literal(k)}: {_literal(v)}," for k, v in configuraciones.items()]
347
+ lineas_conf.append("}")
348
+
349
+ tags = ["tags_obligatorias = ["] + [f" {_literal(t)}," for t in config["tags_obligatorias"]] + ["]"]
350
+
351
+ return {
352
+ "perfiles": _bloque_dict("perfiles", perfiles),
353
+ "configuraciones": "\n".join(lineas_conf),
354
+ "perfil_nuevos": f"perfil_nuevos = {_literal(config['perfil_nuevos'])}",
355
+ "fichero_por_ruta": f"fichero_por_ruta = {_literal(bool(config.get('fichero_por_ruta', False)))}",
356
+ "forzar_securestring": f"forzar_securestring = {_literal(bool(config['forzar_securestring']))}",
357
+ "tags_activas": f"tags_activas = {_literal(bool(config['tags_activas']))}",
358
+ "obligatorias_vacias": f"obligatorias_vacias = {_literal(bool(config['obligatorias_vacias']))}",
359
+ "tags_obligatorias": "\n".join(tags),
360
+ }
361
+
362
+
363
+ # Reescribir el fichero con la configuración nueva conservando todo lo demás: los
364
+ # comentarios de fuera de cada asignación se quedan donde estaban y solo se sustituyen
365
+ # las asignaciones. Los nombres antiguos (naming, abac...) se cambian por los actuales.
366
+ # Si el fichero no existe, se parte de la plantilla comentada.
367
+ def texto_config(config, texto_actual):
368
+ arbol = ast.parse(texto_actual)
369
+ valores, _ = leer_asignaciones(texto_actual)
370
+ bloques = _bloques(config, valores.get("configuraciones"))
371
+ lineas = texto_actual.splitlines(keepends=True)
372
+
373
+ # (inicio, fin, nombre_actual) de cada asignación que se sustituye, en orden inverso
374
+ tramos = []
375
+ for nodo in arbol.body:
376
+ if not (isinstance(nodo, ast.Assign) and len(nodo.targets) == 1
377
+ and isinstance(nodo.targets[0], ast.Name)):
378
+ continue
379
+ nombre = NOMBRES_ANTIGUOS.get(nodo.targets[0].id, nodo.targets[0].id)
380
+ if nombre in bloques:
381
+ tramos.append((nodo.lineno, nodo.end_lineno, nodo.end_col_offset, nombre))
382
+
383
+ escritos = set()
384
+ for inicio, fin, col_fin, nombre in sorted(tramos, reverse=True):
385
+ # Un comentario detrás de la asignación, en su última línea, se conserva
386
+ ultima = lineas[fin - 1].encode("utf-8")
387
+ resto = ultima[col_fin:].decode("utf-8").rstrip("\n")
388
+ cola = f" {resto.strip()}" if resto.strip().startswith("#") else ""
389
+ # Si un nombre aparece dos veces (el nuevo y el antiguo), solo se escribe una
390
+ sustituto = "" if nombre in escritos else bloques[nombre] + cola + "\n"
391
+ escritos.add(nombre)
392
+ lineas[inicio - 1:fin] = [sustituto]
393
+
394
+ texto = "".join(lineas)
395
+ faltan = [n for n in bloques if n not in escritos]
396
+ if faltan:
397
+ texto = texto.rstrip("\n") + "\n\n## Añadido desde paramsx web\n"
398
+ texto += "\n".join(bloques[n] for n in faltan) + "\n"
399
+ return texto
400
+
401
+
402
+ # Guardar la configuración en disco. Antes de escribir se comprueba que lo que se va a
403
+ # escribir se vuelve a leer exactamente igual; el fichero anterior queda como .bak.
404
+ def guardar_config(config, path=CONFIG_PATH):
405
+ if os.path.exists(path):
406
+ with open(path, "r", encoding="utf-8") as f:
407
+ actual = f.read()
408
+ else:
409
+ with open(PLANTILLA_PATH, "r", encoding="utf-8") as f:
410
+ actual = f.read()
411
+
412
+ texto = texto_config(config, actual)
413
+ valores, no_literales = leer_asignaciones(texto)
414
+ releido = config_desde_valores(valores)
415
+ for clave in ("profile_name", "region_name", "entornos", "parameter_list", "perfiles",
416
+ "perfil_nuevos", "forzar_securestring", "tags_activas",
417
+ "obligatorias_vacias", "tags_obligatorias"):
418
+ if releido.get(clave) != config.get(clave):
419
+ raise ValueError(f"No se ha guardado: '{clave}' no se releía igual.")
420
+
421
+ os.makedirs(os.path.dirname(path), exist_ok=True)
422
+ if os.path.exists(path):
423
+ with open(path + ".bak", "w", encoding="utf-8") as f:
424
+ f.write(actual)
425
+ temporal = path + ".tmp"
426
+ with open(temporal, "w", encoding="utf-8") as f:
427
+ f.write(texto)
428
+ os.replace(temporal, path)
429
+ return texto