paramsx 2.5.0__tar.gz → 2.5.2__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 (32) hide show
  1. {paramsx-2.5.0 → paramsx-2.5.2}/CHANGELOG.md +16 -0
  2. {paramsx-2.5.0/paramsx.egg-info → paramsx-2.5.2}/PKG-INFO +1 -1
  3. paramsx-2.5.2/paramsx/__init__.py +1 -0
  4. {paramsx-2.5.0 → paramsx-2.5.2}/paramsx/web/app.py +4 -3
  5. {paramsx-2.5.0 → paramsx-2.5.2}/paramsx/web/ssm.py +66 -7
  6. {paramsx-2.5.0 → paramsx-2.5.2}/paramsx/web/static/paramsx.js +57 -17
  7. {paramsx-2.5.0 → paramsx-2.5.2/paramsx.egg-info}/PKG-INFO +1 -1
  8. {paramsx-2.5.0 → paramsx-2.5.2}/tests/test_web.py +84 -0
  9. paramsx-2.5.0/paramsx/__init__.py +0 -1
  10. {paramsx-2.5.0 → paramsx-2.5.2}/LICENSE +0 -0
  11. {paramsx-2.5.0 → paramsx-2.5.2}/MANIFEST.in +0 -0
  12. {paramsx-2.5.0 → paramsx-2.5.2}/README.md +0 -0
  13. {paramsx-2.5.0 → paramsx-2.5.2}/paramsx/core/__init__.py +0 -0
  14. {paramsx-2.5.0 → paramsx-2.5.2}/paramsx/core/config.py +0 -0
  15. {paramsx-2.5.0 → paramsx-2.5.2}/paramsx/core/rutas.py +0 -0
  16. {paramsx-2.5.0 → paramsx-2.5.2}/paramsx/funcions.py +0 -0
  17. {paramsx-2.5.0 → paramsx-2.5.2}/paramsx/main.py +0 -0
  18. {paramsx-2.5.0 → paramsx-2.5.2}/paramsx/paramsx_config.py +0 -0
  19. {paramsx-2.5.0 → paramsx-2.5.2}/paramsx/text.py +0 -0
  20. {paramsx-2.5.0 → paramsx-2.5.2}/paramsx/web/__init__.py +0 -0
  21. {paramsx-2.5.0 → paramsx-2.5.2}/paramsx/web/static/ajustes.js +0 -0
  22. {paramsx-2.5.0 → paramsx-2.5.2}/paramsx/web/static/app.css +0 -0
  23. {paramsx-2.5.0 → paramsx-2.5.2}/paramsx/web/static/comun.js +0 -0
  24. {paramsx-2.5.0 → paramsx-2.5.2}/paramsx/web/templates/index.html +0 -0
  25. {paramsx-2.5.0 → paramsx-2.5.2}/paramsx.egg-info/SOURCES.txt +0 -0
  26. {paramsx-2.5.0 → paramsx-2.5.2}/paramsx.egg-info/dependency_links.txt +0 -0
  27. {paramsx-2.5.0 → paramsx-2.5.2}/paramsx.egg-info/entry_points.txt +0 -0
  28. {paramsx-2.5.0 → paramsx-2.5.2}/paramsx.egg-info/requires.txt +0 -0
  29. {paramsx-2.5.0 → paramsx-2.5.2}/paramsx.egg-info/top_level.txt +0 -0
  30. {paramsx-2.5.0 → paramsx-2.5.2}/setup.cfg +0 -0
  31. {paramsx-2.5.0 → paramsx-2.5.2}/setup.py +0 -0
  32. {paramsx-2.5.0 → paramsx-2.5.2}/tests/test_core.py +0 -0
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.5.2
4
+
5
+ ### Corregido
6
+
7
+ - **Buscar un parámetro que existe ya no dice que no está**:
8
+ - En «Ruta» vale también el **nombre completo de un parámetro** (antes solo carpetas: «/dev/app/clave» no encontraba nada) y los parámetros **sin carpeta** (`MiParametro`).
9
+ - El buscador de «Ruta» sugiere también los **parámetros**, no solo las carpetas.
10
+ - Con **más de 2.000 parámetros** en la cuenta, el buscador y el filtro de Explorar preguntan también a AWS lo que se escribe; antes solo buscaban en los 2.000 primeros.
11
+ - Explorar muestra los parámetros sin carpeta.
12
+
13
+ ## 2.5.1
14
+
15
+ ### Corregido
16
+
17
+ - **Un borrado ya no pisa el cambio de otro**: justo antes de borrar se vuelve a leer la versión de cada parámetro (sin valores). Si alguien lo ha cambiado desde la revisión (también mientras se creaba la copia de un renombrado), no se borra y se avisa; en un renombrado, se dice que la copia se creó con el valor de antes. Si el rol no puede leer la versión, se borra como antes; si la lectura falla por otro motivo, no se borra.
18
+
3
19
  ## 2.5.0
4
20
 
5
21
  ### Añadido
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: paramsx
3
- Version: 2.5.0
3
+ Version: 2.5.2
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
@@ -0,0 +1 @@
1
+ __version__ = "2.5.2"
@@ -354,13 +354,14 @@ def crear_app(config_path=None, token=None, sesion_factory=None, perfiles_aws=No
354
354
 
355
355
  @app.get("/api/explorar")
356
356
  def api_explorar():
357
- return jsonify(ssm_web.explorar(ssm(), request.args.get("prefijo") or "/"))
357
+ return jsonify(ssm_web.explorar(ssm(), request.args.get("prefijo") or "/",
358
+ str(request.args.get("contiene") or "")[:200]))
358
359
 
359
360
  @app.post("/api/cargar")
360
361
  def api_cargar():
361
362
  ruta = str(cuerpo().get("ruta") or "").strip()
362
- if not ruta.startswith("/"):
363
- raise ErrorWeb("La ruta empieza por /.")
363
+ if not ruta:
364
+ raise ErrorWeb("Escribe una ruta (o el nombre de un parámetro).")
364
365
  return jsonify(ssm_web.cargar(ssm(), ruta))
365
366
 
366
367
  @app.post("/api/plan")
@@ -81,8 +81,11 @@ def _meta(p):
81
81
  }
82
82
 
83
83
 
84
- def describir(ssm, ruta, nombres=None):
85
- """{nombre: metadatos} (sin valores) de una ruta (recursivo) o de unos nombres concretos."""
84
+ def describir(ssm, ruta, nombres=None, contiene=""):
85
+ """{nombre: metadatos} (sin valores) de una ruta (recursivo), de unos nombres concretos o de los
86
+ que llevan un texto (lo filtra AWS, distingue mayúsculas)."""
87
+ if contiene:
88
+ return _describir_filtro(ssm, [{"Key": "Name", "Option": "Contains", "Values": [contiene]}])
86
89
  if nombres is not None:
87
90
  out = {}
88
91
  # El filtro Name admite hasta 50 valores
@@ -170,11 +173,34 @@ def etiquetas(ssm, nombres):
170
173
  return dict(pool.map(una, nombres))
171
174
 
172
175
 
176
+ def _uno(ssm, nombre):
177
+ """Lo escrito en «Ruta» es un parámetro, no una carpeta (o uno sin «/», que no está en
178
+ ninguna): (meta, valores) o None si no existe."""
179
+ try:
180
+ meta = describir(ssm, "", nombres=[nombre])
181
+ except ClientError as exc:
182
+ if not denegado(exc):
183
+ raise ParametrosError(motivo(exc)) from None
184
+ meta = None
185
+ try:
186
+ valores = valores_por_nombre(ssm, [nombre])
187
+ except ClientError as exc:
188
+ raise ParametrosError(motivo(exc)) from None
189
+ if not meta and valores.get(nombre, {}).get("no_existe"):
190
+ return None
191
+ return meta, valores
192
+
193
+
173
194
  def cargar(ssm, ruta):
174
195
  """Los parámetros de una ruta (recursivo) con valor, metadatos y etiquetas. Lo que el rol no
175
- puede leer va con `bloqueado`. ParametrosError si no puede ni listar ni leer la ruta."""
176
- ruta = "/" + ruta.strip("/") if ruta.strip("/") else "/"
196
+ puede leer va con `bloqueado`. ParametrosError si no puede ni listar ni leer la ruta. Si la
197
+ ruta es el nombre de un parámetro (o uno sin «/»), carga ese."""
177
198
  avisos = []
199
+ if not ruta.strip().startswith("/"): # parámetro sin carpeta (p. ej. «MiParametro»)
200
+ nombre = ruta.strip()
201
+ meta, valores = _uno(ssm, nombre) or ({}, {})
202
+ return _montar(ssm, nombre, meta, valores, avisos)
203
+ ruta = "/" + ruta.strip("/") if ruta.strip("/") else "/"
178
204
  try:
179
205
  meta = describir(ssm, ruta)
180
206
  except ClientError as exc:
@@ -200,6 +226,14 @@ def cargar(ssm, ruta):
200
226
  # La ruta entera no se deja (o un descifrado tumba la página): uno a uno
201
227
  valores = valores_por_nombre(ssm, sorted(meta))
202
228
 
229
+ if not meta and not valores and ruta != "/": # nada debajo: ¿es el nombre de un parámetro?
230
+ uno = _uno(ssm, ruta)
231
+ if uno:
232
+ meta, valores = uno
233
+ return _montar(ssm, ruta, meta, valores, avisos)
234
+
235
+
236
+ def _montar(ssm, ruta, meta, valores, avisos):
203
237
  nombres = sorted(n for n in set(meta or {}) | set(valores)
204
238
  if not valores.get(n, {}).get("no_existe"))
205
239
  legibles = [n for n in nombres if "bloqueado" not in valores.get(n, {})]
@@ -221,10 +255,11 @@ def cargar(ssm, ruta):
221
255
  return {"ruta": ruta, "parametros": parametros, "avisos": avisos}
222
256
 
223
257
 
224
- def explorar(ssm, prefijo="/"):
225
- """Nombres (sin valores) para el árbol de «Explorar» y la búsqueda de Ajustes."""
258
+ def explorar(ssm, prefijo="/", contiene=""):
259
+ """Nombres (sin valores) para el árbol de «Explorar» y la búsqueda de Ajustes. Con `contiene`,
260
+ solo los que llevan ese texto: así se encuentran también cuando hay más de MAX_LISTADO."""
226
261
  try:
227
- meta = describir(ssm, prefijo or "/")
262
+ meta = describir(ssm, prefijo or "/", contiene=contiene)
228
263
  except ClientError as exc:
229
264
  raise ParametrosError(
230
265
  "Tu rol de IAM no puede listar parámetros (ssm:DescribeParameters)."
@@ -440,6 +475,30 @@ def aplicar(ssm, items, cfg):
440
475
  borrar.remove(b)
441
476
  resultados.append(b)
442
477
 
478
+ # Justo antes de borrar, otra vez la versión de cada uno (sin valores ni descifrar): si alguien lo ha cambiado desde
479
+ # la revisión (también mientras se creaban los renombrados), no se borra. SSM no tiene «borrar solo si sigue en la
480
+ # versión N»: entre esta lectura y el borrado queda una ventana de milisegundos que no se puede cerrar desde aquí.
481
+ esperada = {it.get("name"): it.get("version") for it in items if it.get("accion") == "borrar" and it.get("version")}
482
+ copia_de = {it.get("de"): it.get("name") for it in items if it.get("accion") == "nuevo" and it.get("de")}
483
+ try:
484
+ ahora = describir(ssm, "", [b["name"] for b in borrar]) if borrar else {}
485
+ except (ClientError, BotoCoreError) as exc:
486
+ if not denegado(exc):
487
+ # Otro fallo (red, límite de llamadas...): sin comprobar no se borra, pero lo ya aplicado se devuelve
488
+ for b in borrar:
489
+ b["error"] = f"No se pudo comprobar la versión antes de borrar ({motivo(exc)}): no se borra."
490
+ resultados += borrar
491
+ return resultados
492
+ ahora = None # sin permiso para describir: se borra como antes (ya se comprobó al revisar)
493
+ for b in [b for b in borrar if ahora is not None and b["name"] in ahora and b["name"] in esperada
494
+ and ahora[b["name"]].get("version") != esperada[b["name"]]]:
495
+ b["error"] = (f"Ha cambiado en AWS mientras se aplicaba (versión {esperada[b['name']]} → "
496
+ f"{ahora[b['name']].get('version')}): no se borra. "
497
+ + (f"Su copia «{copia_de[b['name']]}» se creó con el valor de antes: revisa cuál dejar."
498
+ if b["name"] in copia_de else "Recarga el grupo."))
499
+ borrar.remove(b)
500
+ resultados.append(b)
501
+
443
502
  # Borrados de 10 en 10 (el máximo de DeleteParameters)
444
503
  for i in range(0, len(borrar), 10):
445
504
  lote = borrar[i:i + 10]
@@ -55,7 +55,7 @@
55
55
  PX.oyentes.push(st => {
56
56
  const donde = st.config ? `${st.config.profile_name}|${st.config.region_name}` : '';
57
57
  if (donde !== S.donde) { // otro perfil o región: lo cargado era de otra cuenta
58
- S.donde = donde; S.items = []; S.carpetas = null; S.ruta = ''; $('pBarra').hidden = true; $('pLista').innerHTML = '';
58
+ S.donde = donde; S.items = []; S.carpetas = null; B.enAws = null; S.ruta = ''; $('pBarra').hidden = true; $('pLista').innerHTML = '';
59
59
  $('pEstado').textContent = '';
60
60
  pintarDonde();
61
61
  }
@@ -90,6 +90,7 @@
90
90
  B.pidiendo = B.pidiendo || api('/api/explorar').then(r => {
91
91
  const c = {};
92
92
  r.nombres.forEach(n => { const seg = n.split('/').filter(Boolean); for (let i = 1; i < seg.length; i++) { const k = '/' + seg.slice(0, i).join('/'); c[k] = (c[k] || 0) + 1; } });
93
+ S.nombres = new Set(r.nombres); S.limitado = r.limitado; // los parámetros también se buscan, no solo las carpetas
93
94
  S.carpetas = c; return c;
94
95
  }).finally(() => { B.pidiendo = null; });
95
96
  return B.pidiendo;
@@ -111,12 +112,33 @@
111
112
  try { c = await carpetas(); } catch (e) { caja.innerHTML = `<div class="sug-ayuda error">${esc(e.message)}</div>`; return; }
112
113
  if ($('pRuta').value.trim().toLowerCase() !== q) return; // ya se ha escrito otra cosa
113
114
  // Primero las que empiezan por lo escrito, luego las más cortas
114
- B.lista = Object.keys(c).filter(k => k.toLowerCase().includes(q))
115
- .sort((a, b) => (b.toLowerCase().startsWith(q) - a.toLowerCase().startsWith(q)) || a.split('/').length - b.split('/').length || a.localeCompare(b)).slice(0, 200);
116
- caja.innerHTML = B.lista.map((k, i) => `<div class="sug" role="option" data-i="${i}"><code>${resaltar(k, q)}</code><span class="exp-n">${c[k]}</span><button type="button" class="secundario sug-grupo" data-grupo-de="${esc(k)}" title="Guardar esta carpeta como grupo (se detecta dónde va el entorno)" tabindex="-1">+ Grupo</button></div>`).join('')
117
- + `<div class="sug-ayuda">${B.lista.length ? '<kbd>↑</kbd> <kbd>↓</kbd> para elegir · «+ Grupo» la guarda en tus grupos · ' : 'Ninguna carpeta con eso. '}<kbd>Intro</kbd> carga ${B.lista.length ? 'la elegida' : 'la ruta tal cual'}${q.startsWith('/') ? '' : ' (las rutas empiezan por /)'}</div>`;
115
+ if (S.limitado && q.length >= 2 && !B.enAws?.has(q)) buscarEnAws(q);
116
+ const orden = (a, b) => (b.toLowerCase().startsWith(q) - a.toLowerCase().startsWith(q)) || a.split('/').length - b.split('/').length || a.localeCompare(b);
117
+ const dirs = Object.keys(c).filter(k => k.toLowerCase().includes(q)).sort(orden).slice(0, 200);
118
+ const params = [...(S.nombres || [])].filter(n => !c[n] && n.toLowerCase().includes(q)).sort(orden).slice(0, 100); // los parámetros mismos, después
119
+ B.lista = [...dirs, ...params];
120
+ caja.innerHTML = B.lista.map((k, i) => i < dirs.length
121
+ ? `<div class="sug" role="option" data-i="${i}"><code>${resaltar(k, q)}</code><span class="exp-n">${c[k]}</span><button type="button" class="secundario sug-grupo" data-grupo-de="${esc(k)}" title="Guardar esta carpeta como grupo (se detecta dónde va el entorno)" tabindex="-1">+ Grupo</button></div>`
122
+ : `<div class="sug sug-param" role="option" data-i="${i}" title="Parámetro: se carga solo este"><code>${resaltar(k, q)}</code><span class="exp-n">parámetro</span></div>`).join('')
123
+ + `<div class="sug-ayuda">${B.enAws?.get(q) === 'buscando' ? 'Buscando también en AWS… · ' : ''}${B.lista.length ? '<kbd>↑</kbd> <kbd>↓</kbd> para elegir · «+ Grupo» la guarda en tus grupos · ' : 'Ninguna carpeta ni parámetro con eso. '}<kbd>Intro</kbd> carga ${B.lista.length ? 'la elegida' : 'la ruta tal cual'}${q.startsWith('/') ? '' : ' (las rutas empiezan por /)'}</div>`;
118
124
  marcarActiva(B.lista.length && !q.startsWith('/') ? 0 : -1);
119
125
  }
126
+ // Con más parámetros de los que caben en la lista, lo escrito se pide a AWS (distingue mayúsculas: tal cual, en minúsculas y en mayúsculas)
127
+ function buscarEnAws(q) {
128
+ B.enAws = B.enAws || new Map(); B.enAws.set(q, 'buscando');
129
+ clearTimeout(B.espera);
130
+ B.espera = setTimeout(async () => {
131
+ try {
132
+ const vistas = await Promise.all([...new Set([q, q.toLowerCase(), q.toUpperCase()])].map(t => api('/api/explorar?contiene=' + encodeURIComponent(t))));
133
+ vistas.forEach(v => v.nombres.forEach(n => {
134
+ if (S.nombres.has(n)) return; S.nombres.add(n);
135
+ const seg = n.split('/').filter(Boolean); for (let i = 1; i < seg.length; i++) { const k = '/' + seg.slice(0, i).join('/'); S.carpetas[k] = (S.carpetas[k] || 0) + 1; }
136
+ }));
137
+ B.enAws.set(q, 'hecho');
138
+ } catch (e) { B.enAws.delete(q); toast(e.message, true); }
139
+ if ($('pRuta').value.trim().toLowerCase() === q) sugerir();
140
+ }, 400);
141
+ }
120
142
  // «+ Grupo»: guarda la carpeta en la config detectando dónde va el entorno (lo mismo que Explorar y Ajustes)
121
143
  async function anadirGrupo(carpeta) {
122
144
  try {
@@ -155,7 +177,7 @@
155
177
  // ------------------------------------------------ cargar y pintar
156
178
  async function cargar() {
157
179
  const ruta = $('pRuta').value.trim();
158
- if (!ruta.startsWith('/')) return toast('La ruta empieza por /', true);
180
+ if (!ruta) return toast('Escribe una ruta o el nombre de un parámetro', true);
159
181
  $('pEstado').textContent = `Leyendo ${ruta} de AWS…`; $('pLista').innerHTML = ''; $('pBarra').hidden = true;
160
182
  try {
161
183
  const r = await post('/api/cargar', { ruta });
@@ -365,7 +387,7 @@
365
387
  const hechos = new Set(res.resultados.filter(x => x.ok).map(x => x.name));
366
388
  const sinHacer = pendientes().filter(p => !hechos.has(nombreEnvio(p))).map(p => ({ ...p, name: nombreEnvio(p) }));
367
389
  const conflictos = new Set([...r.plan.filter(f => f.conflicto).map(f => f.name), ...mal.filter(x => /^Conflicto/.test(x.error)).map(x => x.name)]);
368
- S.items = []; S.carpetas = null;
390
+ S.items = []; S.carpetas = null; B.enAws = null;
369
391
  await cargar();
370
392
  if (sinHacer.length && S.ruta) { reponer(S.items, sinHacer, conflictos); pintar(); }
371
393
  toast(mal.length ? `${ok} aplicados, ${mal.length} con error` : `${ok} cambios aplicados`, !!mal.length);
@@ -500,28 +522,46 @@
500
522
  modal.abrir('<h2>Explorar</h2><p>Leyendo los nombres (sin valores)…</p>');
501
523
  let r;
502
524
  try { r = await api('/api/explorar'); } catch (e) { return modal.abrir(`<h2>Explorar</h2><p class="error">${esc(e.message)}</p>`); }
525
+ const nombres = new Set(r.nombres); // con más de los que caben, lo que se filtra se pide también a AWS y se añade aquí
503
526
  const arbol = q => {
504
527
  const raiz = {};
505
- r.nombres.filter(n => !q || n.toLowerCase().includes(q)).forEach(n => {
528
+ [...nombres].filter(n => !q || n.toLowerCase().includes(q)).forEach(n => {
529
+ const seg = n.split('/').filter(Boolean);
530
+ if (seg.length === 1) { raiz[n] = raiz[n] || { _n: 1, _hoja: n }; return; } // sin carpeta: el parámetro mismo
506
531
  let nodo = raiz;
507
- n.split('/').filter(Boolean).slice(0, -1).forEach(x => { nodo[x] = nodo[x] || { _n: 0 }; nodo[x]._n++; nodo = nodo[x]; });
532
+ seg.slice(0, -1).forEach(x => { nodo[x] = nodo[x] || { _n: 0 }; nodo[x]._n++; nodo = nodo[x]; });
508
533
  });
509
534
  return raiz;
510
535
  };
511
- const fila = (k, ruta, n, nivel) => {
536
+ const fila = (k, ruta, n, nivel, hoja) => {
512
537
  const e = entornoDe(k);
513
538
  return `<span class="exp-nombre nivel-${Math.min(nivel, 3)}${e ? ' entorno ' + claseEntorno(e) : ''}">${esc(k)}</span><span class="exp-n" title="Parámetros por debajo">${n}</span>
514
- <span class="exp-acciones"><button type="button" class="secundario" data-ruta="${esc(ruta)}">Cargar</button><button type="button" class="secundario" data-grupo-de="${esc(ruta)}" title="Guardar esta carpeta como grupo (se detecta dónde va el entorno)">+ Grupo</button></span>`;
539
+ <span class="exp-acciones"><button type="button" class="secundario" data-ruta="${esc(ruta)}">Cargar</button>${hoja ? '' : `<button type="button" class="secundario" data-grupo-de="${esc(ruta)}" title="Guardar esta carpeta como grupo (se detecta dónde va el entorno)">+ Grupo</button>`}</span>`;
515
540
  };
516
- const rama = (nodo, ruta, nivel, abierto) => Object.keys(nodo).filter(k => k !== '_n').sort((a, b) => a.localeCompare(b, 'es', { sensitivity: 'base' })).map(k => {
517
- const r2 = `${ruta}/${k}`, hijos = rama(nodo[k], r2, nivel + 1, abierto);
518
- return hijos ? `<details class="rama" ${abierto ? 'open' : ''}><summary class="exp-fila">${fila(k, r2, nodo[k]._n, nivel)}</summary><div class="hijos">${hijos}</div></details>`
519
- : `<div class="exp-fila exp-hoja">${fila(k, r2, nodo[k]._n, nivel)}</div>`;
541
+ const rama = (nodo, ruta, nivel, abierto) => Object.keys(nodo).filter(k => k !== '_n' && k !== '_hoja').sort((a, b) => a.localeCompare(b, 'es', { sensitivity: 'base' })).map(k => {
542
+ const r2 = nodo[k]._hoja || `${ruta}/${k}`, hijos = rama(nodo[k], r2, nivel + 1, abierto);
543
+ return hijos ? `<details class="rama" ${abierto ? 'open' : ''}><summary class="exp-fila">${fila(k, r2, nodo[k]._n, nivel, nodo[k]._hoja)}</summary><div class="hijos">${hijos}</div></details>`
544
+ : `<div class="exp-fila exp-hoja">${fila(k, r2, nodo[k]._n, nivel, nodo[k]._hoja)}</div>`;
520
545
  }).join('');
521
- modal.abrir(`<h2>Explorar · ${r.nombres.length} parámetros</h2><p>Solo nombres. «Cargar» abre la carpeta; «+ Grupo» la guarda en tu configuración para elegirla luego con su entorno.${r.limitado ? ' <span class="warn">Hay más de los que se muestran: filtra.</span>' : ''}</p>
546
+ modal.abrir(`<h2>Explorar · ${r.nombres.length} parámetros</h2><p>Solo nombres. «Cargar» abre la carpeta; «+ Grupo» la guarda en tu configuración para elegirla luego con su entorno.${r.limitado ? ' <span class="warn">Hay más de los que se muestran: al filtrar se buscan también en AWS.</span>' : ''}</p>
522
547
  <div class="exp-cab"><input type="search" id="pExpFiltro" placeholder="Filtrar rutas… (p. ej. rds, api)" ${NOFILL}><span class="leyenda">${cfg().entornos.map(e => `<span class="entorno ${claseEntorno(e)}">${esc(e)}</span>`).join('')}</span></div>
523
548
  <div class="arbol" id="pArbol">${rama(arbol(''), '', 0, false) || '<p class="muted">No hay parámetros.</p>'}</div>`);
524
- $('pExpFiltro').addEventListener('input', e => { const q = e.target.value.trim().toLowerCase(); $('pArbol').innerHTML = rama(arbol(q), '', 0, !!q) || '<p class="muted">Ninguna ruta con eso.</p>'; });
549
+ let buscando = false, espera;
550
+ const pintarArbol = () => { const q = $('pExpFiltro').value.trim().toLowerCase(); $('pArbol').innerHTML = rama(arbol(q), '', 0, !!q) || `<p class="muted">${buscando ? 'Buscando en AWS…' : 'Ninguna ruta con eso.'}</p>`; };
551
+ $('pExpFiltro').addEventListener('input', () => {
552
+ pintarArbol();
553
+ const q = $('pExpFiltro').value.trim();
554
+ clearTimeout(espera);
555
+ if (!r.limitado || q.length < 2) return;
556
+ buscando = true; pintarArbol();
557
+ espera = setTimeout(async () => { // AWS distingue mayúsculas: tal cual, en minúsculas y en mayúsculas
558
+ try {
559
+ const vistas = await Promise.all([...new Set([q, q.toLowerCase(), q.toUpperCase()])].map(t => api('/api/explorar?contiene=' + encodeURIComponent(t))));
560
+ vistas.forEach(v => v.nombres.forEach(n => nombres.add(n)));
561
+ } catch (e) { toast(e.message, true); }
562
+ buscando = false; if ($('pExpFiltro')?.value.trim() === q) pintarArbol();
563
+ }, 400);
564
+ });
525
565
  $('pExpFiltro').focus();
526
566
  $('pArbol').addEventListener('click', async e => {
527
567
  const g = e.target.closest('[data-grupo-de]');
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: paramsx
3
- Version: 2.5.0
3
+ Version: 2.5.2
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
@@ -333,6 +333,71 @@ def test_renombrar_conserva_la_clave_kms_y_nunca_pierde_el_viejo(cliente, ssm, m
333
333
  assert "/dev/app/url" in ssm.params and not [o for o in ssm.ops("DeleteParameters") if "/dev/app/url" in o["Names"]]
334
334
 
335
335
 
336
+ def test_si_el_original_cambia_mientras_se_aplica_no_se_borra(cliente, ssm, monkeypatch):
337
+ ssm.poner("/dev/app/url", "https://x", tipo="String", tags=TAGS)
338
+ ssm.poner("/dev/app/b", "b", tipo="String", tags=TAGS)
339
+ ps = cargar(cliente)
340
+ aplicar = lambda items: {x["name"]: x for x in cliente.post("/api/aplicar", json={ # noqa: E731
341
+ "items": items, "confirmacion": CONFIRMA, "borrados": "1"}).get_json()["resultados"]}
342
+
343
+ # Mientras se crea la copia renombrada, otro cambia el original
344
+ real = ssm.put_parameter
345
+
346
+ def y_otro_cambia_el_original(**kw):
347
+ r = real(**kw)
348
+ if kw["Name"] == "/dev/app/url2":
349
+ real(Name="/dev/app/url", Value="https://nuevo", Overwrite=True)
350
+ return r
351
+ monkeypatch.setattr(ssm, "put_parameter", y_otro_cambia_el_original)
352
+ r = aplicar(renombrar(ps["/dev/app/url"], "/dev/app/url2"))
353
+ assert r["/dev/app/url2"]["ok"] and not r["/dev/app/url"]["ok"]
354
+ assert "Ha cambiado en AWS mientras se aplicaba (versión 1 → 2)" in r["/dev/app/url"]["error"]
355
+ assert "«/dev/app/url2»" in r["/dev/app/url"]["error"] # avisa de la copia con el valor de antes
356
+ assert ssm.params["/dev/app/url"]["value"] == "https://nuevo" # el cambio del otro no se pierde
357
+ assert ssm.ops("DeleteParameters") == []
358
+ monkeypatch.setattr(ssm, "put_parameter", real)
359
+
360
+ # Un borrado normal: lo cambian entre la revisión y el borrado
361
+ revisar_real = ssm_web._revisar
362
+
363
+ def revisa_y_cambia(*a, **k):
364
+ out = revisar_real(*a, **k)
365
+ ssm.poner("/dev/app/b", "b-de-otro", tipo="String")
366
+ return out
367
+ monkeypatch.setattr(ssm_web, "_revisar", revisa_y_cambia)
368
+ r = aplicar([item(ps["/dev/app/b"], accion="borrar")])
369
+ assert not r["/dev/app/b"]["ok"] and "Recarga el grupo" in r["/dev/app/b"]["error"]
370
+ assert "/dev/app/b" in ssm.params and ssm.ops("DeleteParameters") == []
371
+
372
+ # Sin permiso para volver a leerla: se borra como antes (ya se comprobó al revisar)
373
+ def revisa_y_quita_describe(*a, **k):
374
+ out = revisar_real(*a, **k)
375
+ ssm.sin_describe = True
376
+ return out
377
+ monkeypatch.setattr(ssm_web, "_revisar", revisa_y_quita_describe)
378
+ ssm.poner("/dev/app/c", "c", tipo="String")
379
+ r = aplicar([{"name": "/dev/app/c", "accion": "borrar", "version": 1}])
380
+ assert r["/dev/app/c"]["ok"] and "/dev/app/c" not in ssm.params
381
+ ssm.sin_describe = False
382
+
383
+ # Si falla de otra forma al leerla, no se borra (sin comprobar no se toca)
384
+ def revisa_y_rompe(*a, **k):
385
+ out = revisar_real(*a, **k)
386
+ monkeypatch.setattr(ssm, "describe_parameters", lambda **kw: (_ for _ in ()).throw(
387
+ ssm_web.ClientError({"Error": {"Code": "ThrottlingException", "Message": "Rate exceeded"}}, "Op")))
388
+ return out
389
+ monkeypatch.setattr(ssm_web, "_revisar", revisa_y_rompe)
390
+ ssm.poner("/dev/app/d", "d", tipo="String")
391
+ r = aplicar([{"name": "/dev/app/d", "accion": "borrar", "version": 1}])
392
+ assert not r["/dev/app/d"]["ok"] and "No se pudo comprobar" in r["/dev/app/d"]["error"]
393
+ assert "/dev/app/d" in ssm.params
394
+ monkeypatch.undo()
395
+
396
+ # Sin cambios, se borra como siempre (b va ya por la versión 2)
397
+ r = aplicar([{"name": "/dev/app/b", "accion": "borrar", "version": 2}])
398
+ assert r["/dev/app/b"]["ok"] and "/dev/app/b" not in ssm.params
399
+
400
+
336
401
  # ---------------------------------------------------------------- nombre sin barra final
337
402
 
338
403
  def test_nuevo_con_barra_final_se_quita(cliente, ssm):
@@ -407,3 +472,22 @@ def test_js_reponer_lo_no_aplicado(tmp_path):
407
472
  assert por["/r/d"]["borrar"] is True
408
473
  # Lo que ya no está en AWS no se inventa
409
474
  assert "/r/ya-no-esta" not in por and len(out) == 6
475
+
476
+
477
+ # ---------------------------------------------------------------- buscar un parámetro
478
+
479
+ def test_en_ruta_vale_el_nombre_de_un_parametro_o_uno_sin_carpeta(cliente, ssm):
480
+ ssm.poner("/dev/app/clave", "x1")
481
+ ssm.poner("SinCarpeta", "raiz", tipo="String")
482
+ assert list(cargar(cliente, "/dev/app/clave")) == ["/dev/app/clave"] # el nombre completo, no su carpeta
483
+ assert cargar(cliente, "/dev/app/clave")["/dev/app/clave"]["value"] == "x1"
484
+ assert cargar(cliente, "SinCarpeta")["SinCarpeta"]["value"] == "raiz"
485
+ assert cargar(cliente, "/dev/no/existe") == {}
486
+ assert cargar(cliente, "NoExiste") == {}
487
+
488
+
489
+ def test_explorar_busca_en_aws_lo_que_no_cabe_en_la_lista(cliente, ssm):
490
+ ssm.poner("/dev/app/clave", "x1")
491
+ ssm.poner("/prod/otra/Cosa", "x2")
492
+ r = cliente.get("/api/explorar?contiene=Cosa").get_json()
493
+ assert r["nombres"] == ["/prod/otra/Cosa"]
@@ -1 +0,0 @@
1
- __version__ = "2.5.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes