paramsx 2.3.1__tar.gz → 2.4.0__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.
- {paramsx-2.3.1 → paramsx-2.4.0}/CHANGELOG.md +11 -0
- paramsx-2.4.0/MANIFEST.in +4 -0
- {paramsx-2.3.1 → paramsx-2.4.0}/PKG-INFO +38 -3
- paramsx-2.3.1/paramsx.egg-info/PKG-INFO → paramsx-2.4.0/README.md +34 -22
- paramsx-2.4.0/paramsx/__init__.py +1 -0
- paramsx-2.4.0/paramsx/core/__init__.py +2 -0
- paramsx-2.4.0/paramsx/core/config.py +429 -0
- paramsx-2.4.0/paramsx/core/rutas.py +153 -0
- {paramsx-2.3.1 → paramsx-2.4.0}/paramsx/funcions.py +71 -90
- {paramsx-2.3.1 → paramsx-2.4.0}/paramsx/main.py +216 -254
- paramsx-2.4.0/paramsx/text.py +17 -0
- paramsx-2.4.0/paramsx/web/__init__.py +10 -0
- paramsx-2.4.0/paramsx/web/app.py +417 -0
- paramsx-2.4.0/paramsx/web/ssm.py +484 -0
- paramsx-2.4.0/paramsx/web/static/ajustes.js +185 -0
- paramsx-2.4.0/paramsx/web/static/app.css +262 -0
- paramsx-2.4.0/paramsx/web/static/comun.js +58 -0
- paramsx-2.4.0/paramsx/web/static/paramsx.js +430 -0
- paramsx-2.4.0/paramsx/web/templates/index.html +125 -0
- paramsx-2.3.1/README.md → paramsx-2.4.0/paramsx.egg-info/PKG-INFO +57 -2
- paramsx-2.4.0/paramsx.egg-info/SOURCES.txt +29 -0
- {paramsx-2.3.1 → paramsx-2.4.0}/paramsx.egg-info/dependency_links.txt +0 -0
- {paramsx-2.3.1 → paramsx-2.4.0}/paramsx.egg-info/entry_points.txt +0 -0
- {paramsx-2.3.1 → paramsx-2.4.0}/paramsx.egg-info/requires.txt +3 -0
- {paramsx-2.3.1 → paramsx-2.4.0}/paramsx.egg-info/top_level.txt +0 -0
- {paramsx-2.3.1 → paramsx-2.4.0}/setup.py +5 -0
- paramsx-2.4.0/tests/test_core.py +129 -0
- paramsx-2.4.0/tests/test_web.py +295 -0
- paramsx-2.3.1/MANIFEST.in +0 -3
- paramsx-2.3.1/paramsx/__init__.py +0 -1
- paramsx-2.3.1/paramsx/text.py +0 -12
- paramsx-2.3.1/paramsx.egg-info/SOURCES.txt +0 -16
- {paramsx-2.3.1 → paramsx-2.4.0}/LICENSE +0 -0
- {paramsx-2.3.1 → paramsx-2.4.0}/paramsx/paramsx_config.py +0 -0
- {paramsx-2.3.1 → paramsx-2.4.0}/setup.cfg +0 -0
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.4.0
|
|
4
|
+
|
|
5
|
+
### Añadido
|
|
6
|
+
|
|
7
|
+
- **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.
|
|
8
|
+
- Los grupos de `parameter_list` admiten un `"nombre"` opcional para mostrarlos en la web; la terminal lo ignora.
|
|
9
|
+
|
|
10
|
+
### Cambiado
|
|
11
|
+
|
|
12
|
+
- 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.
|
|
13
|
+
|
|
3
14
|
## 2.3.1
|
|
4
15
|
|
|
5
16
|
Solo documentación: el programa es idéntico a la 2.3.0. Sale como versión porque la
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: paramsx
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
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
|
+
Provides-Extra: web
|
|
13
|
+
Requires-Dist: flask>=2.2; 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,36 @@ 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
|
+
pip install 'paramsx[web]' # añade Flask
|
|
72
|
+
paramsx --web
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
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.
|
|
76
|
+
|
|
77
|
+
| Pantalla | Qué hace |
|
|
78
|
+
|---|---|
|
|
79
|
+
| **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. |
|
|
80
|
+
| **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>`. |
|
|
81
|
+
| **Historial** | Las versiones de AWS (hasta 100); «Usar esta versión» la pone en el editor. |
|
|
82
|
+
| **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. |
|
|
83
|
+
|
|
84
|
+

|
|
85
|
+

|
|
86
|
+
|
|
87
|
+
Lo que conviene saber:
|
|
88
|
+
|
|
89
|
+
- **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.
|
|
90
|
+
- Lo que tu rol no puede leer o descifrar (KMS) sale con 🔒 en solo lectura, y el resto se edita con normalidad.
|
|
91
|
+
- 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.
|
|
92
|
+
- 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`.
|
|
93
|
+
|
|
61
94
|
Para comprobar la versión instalada:
|
|
62
95
|
|
|
63
96
|
```bash
|
|
@@ -939,7 +972,9 @@ paramsx --version
|
|
|
939
972
|
|
|
940
973
|
| Comando | Descripción |
|
|
941
974
|
|---|---|
|
|
942
|
-
| `paramsx` |
|
|
975
|
+
| `paramsx` | Pregunta: web o terminal. |
|
|
976
|
+
| `paramsx --web` | Abre la versión web (necesita `paramsx[web]`). |
|
|
977
|
+
| `paramsx --tui` | Abre el menú de terminal. |
|
|
943
978
|
| `paramsx configure` | Crea o comprueba la configuración. |
|
|
944
979
|
| `paramsx configure --ejemplo` | Genera la plantilla completa de configuración. |
|
|
945
980
|
| `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,36 @@ 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
|
+
pip install 'paramsx[web]' # añade Flask
|
|
49
|
+
paramsx --web
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
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.
|
|
53
|
+
|
|
54
|
+
| Pantalla | Qué hace |
|
|
55
|
+
|---|---|
|
|
56
|
+
| **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. |
|
|
57
|
+
| **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>`. |
|
|
58
|
+
| **Historial** | Las versiones de AWS (hasta 100); «Usar esta versión» la pone en el editor. |
|
|
59
|
+
| **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. |
|
|
60
|
+
|
|
61
|
+

|
|
62
|
+

|
|
63
|
+
|
|
64
|
+
Lo que conviene saber:
|
|
65
|
+
|
|
66
|
+
- **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.
|
|
67
|
+
- Lo que tu rol no puede leer o descifrar (KMS) sale con 🔒 en solo lectura, y el resto se edita con normalidad.
|
|
68
|
+
- 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.
|
|
69
|
+
- 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`.
|
|
70
|
+
|
|
61
71
|
Para comprobar la versión instalada:
|
|
62
72
|
|
|
63
73
|
```bash
|
|
@@ -939,7 +949,9 @@ paramsx --version
|
|
|
939
949
|
|
|
940
950
|
| Comando | Descripción |
|
|
941
951
|
|---|---|
|
|
942
|
-
| `paramsx` |
|
|
952
|
+
| `paramsx` | Pregunta: web o terminal. |
|
|
953
|
+
| `paramsx --web` | Abre la versión web (necesita `paramsx[web]`). |
|
|
954
|
+
| `paramsx --tui` | Abre el menú de terminal. |
|
|
943
955
|
| `paramsx configure` | Crea o comprueba la configuración. |
|
|
944
956
|
| `paramsx configure --ejemplo` | Genera la plantilla completa de configuración. |
|
|
945
957
|
| `paramsx --version` | Muestra la versión instalada. |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.4.0"
|
|
@@ -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
|