feat: cierra issues 0050 y 0052 + commands automáticos

- 0050: jupyter_exec reescrito sin Y.js (REST + KernelClient). Bug raíz adicional: HEAD /api/contents da 405 → cambiado a GET. 9 tests (5 unit + 4 e2e).
- 0052: footprint_aurgi cerrado. Bug fix en setup_geo_stack_docker_pipeline (verify aborta si compose up falla; nombre de contenedor incorrecto).
- Nueva primitiva docker_container_running_py_infra (7 tests).
- /full-git-push y /full-git-pull pasan a modo automático: auto-commit + push sin preguntar, aborta solo si detecta secrets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 23:34:03 +02:00
parent 1e8ade0ed4
commit 5194de3c04
14 changed files with 684 additions and 342 deletions
+19 -19
View File
@@ -1,8 +1,10 @@
# /full-git-pull — Pull de fn_registry + todos los sub-repos + submodules + fn sync # /full-git-pull — Pull automático de fn_registry + sub-repos + submodules + fn sync
Trae los últimos cambios del remote para el repo principal `fn_registry`, todos los sub-repos git anidados que **ya existan localmente**, y los submodules de `cpp/vendor/`. Después regenera `registry.db` y corre `fn sync` para tirar de la metadata del `registry_api` (apps, projects, analysis, vaults, pc_locations registrados desde otros PCs). Trae los últimos cambios del remote para el repo principal `fn_registry`, todos los sub-repos git anidados que **ya existan localmente**, y los submodules de `cpp/vendor/`. Después regenera `registry.db` y corre `fn sync` para tirar de la metadata del `registry_api`.
**No clona repos que falten.** Cada PC tiene solo el subset de apps/analyses que le interesa (ver memoria "Gitea = fuente de verdad; PCs subset"). Si en este PC necesitas un sub-repo que aún no tienes, clónalo a mano: **Modo automático (preferencia del usuario):** este comando NO pregunta. Auto-stashea dirty trees antes de pullear y hace `pop` después. Sigue con el resto de repos aunque uno falle. Solo se detiene si detecta riesgo serio (conflicto en stash pop que requiere intervención humana).
**No clona repos que falten.** Cada PC tiene solo el subset de apps/analyses que le interesa. Si en este PC necesitas un sub-repo que aún no tienes, clónalo a mano:
```bash ```bash
git clone https://<user>:<token>@<gitea-host>/dataforge/<name>.git <path> git clone https://<user>:<token>@<gitea-host>/dataforge/<name>.git <path>
@@ -19,21 +21,18 @@ Consulta `pc_locations` para ver dónde lo tiene otro PC y reproduce el path.
### 1. Descubrir repos locales ### 1. Descubrir repos locales
```bash ```bash
cd /home/lucas/fn_registry # ajustar al PC cd /home/lucas/fn_registry
REPOS=$(find . -name ".git" -type d \ REPOS=$(find . -name ".git" -type d \
-not -path "./.git/*" \ -not -path "./.git" -not -path "./.git/*" \
-not -path "*/node_modules/*" \ -not -path "*/node_modules/*" -not -path "*/.venv/*" \
-not -path "*/.venv/*" \ -not -path "*/cpp/vendor/*" -not -path "*/cpp/build/*" \
-not -path "*/cpp/vendor/*" \ -not -path "*/sources/*" -not -path "*/temp/*" -not -path "*/subrepos/*" 2>/dev/null \
-not -path "*/cpp/build/*" \ | sed 's|/.git$||')
-not -path "*/sources/*" \
-not -path "*/temp/*" \
-not -path "*/subrepos/*" 2>/dev/null | sed 's|/.git$||')
REPOS=". $REPOS" REPOS=". $REPOS"
``` ```
Solo se actualizan los sub-repos que ya tengan `.git/` localmente. Lo que falte se queda fuera — pull-on-demand por sub-repo. Solo se actualizan los sub-repos que ya tengan `.git/` localmente.
### 2. Para cada repo: stash si dirty, pull --ff-only, pop ### 2. Para cada repo: stash si dirty, pull --ff-only, pop
@@ -56,8 +55,8 @@ for r in $REPOS; do
done done
``` ```
- Si `--ff-only` falla por divergencia, abortar el pull de ese repo y reportar (no rebasear sin permiso). - Si `--ff-only` falla por divergencia → reportar ese repo, seguir con el resto. **No** rebasear ni mergear.
- Si `stash pop` produce conflictos, **avisar** y dejar el conflicto al usuario; no resolverlo automáticamente. - Si `stash pop` produce conflictos **avisar al usuario al final** y dejar el conflicto sin tocar; seguir con los demás repos.
### 3. Submodules del repo principal ### 3. Submodules del repo principal
@@ -71,7 +70,7 @@ git submodule update --init --recursive 2>&1 | tail -10
CGO_ENABLED=1 ./fn index 2>&1 | tail -3 CGO_ENABLED=1 ./fn index 2>&1 | tail -3
``` ```
### 5. fn sync con credenciales de pass ### 5. fn sync
```bash ```bash
USER=$(pass registry/basicauth-user | head -1) USER=$(pass registry/basicauth-user | head -1)
@@ -82,14 +81,15 @@ export REGISTRY_API_TOKEN="$TOKEN"
./fn sync ./fn sync
``` ```
Si `pass` falla → gpg-agent locked, pedir al usuario `pass show registry/api-token` en su terminal real. Si `pass` falla → gpg-agent bloqueado, pedir al usuario `pass show registry/api-token` en su terminal real.
### 6. Resumen ### 6. Resumen
Tabla concisa: por repo, commits pulleados o "ya estaba al día"; submodules actualizados; result de `fn index`; result de `fn sync`. Tabla concisa: por repo, commits pulleados o "ya estaba al día"; submodules actualizados; resultado de `fn index`; resultado de `fn sync`. Si algún repo quedó con conflicto de stash o divergencia, listarlos al final con la acción sugerida.
## Notas ## Notas
- **Modo no-interactivo por diseño.** El usuario prefiere flujos rápidos sin confirmaciones.
- Pull solo es fast-forward — nunca rebase ni merge automático. - Pull solo es fast-forward — nunca rebase ni merge automático.
- Si el repo principal pulleó cambios y eliminó archivos referenciados por sub-repos (raro), el usuario debe resolverlo manualmente. - Auto-stash incluye untracked (`--include-untracked`) para no perder archivos nuevos.
- `fn index` se corre **antes** de `fn sync` para que las locations locales reflejen el estado actual. - `fn index` se corre **antes** de `fn sync` para que las locations locales reflejen el estado actual.
+69 -36
View File
@@ -1,33 +1,44 @@
# /full-git-push — Push de fn_registry + todos los sub-repos + fn sync # /full-git-push — Push automático de fn_registry + todos los sub-repos + fn sync
Pushea el repo principal `fn_registry` y todos los sub-repos git anidados (apps y analyses, cada uno como repo independiente bajo `dataforge/<name>` en Gitea), y luego ejecuta `fn sync` para empujar la metadata no regenerable (proposals, apps, projects, analysis, vaults, pc_locations) al `registry_api`. Pushea el repo principal `fn_registry` y todos los sub-repos git anidados (apps y analyses, cada uno como repo independiente bajo `dataforge/<name>` en Gitea), y luego ejecuta `fn sync` para empujar la metadata no regenerable (proposals, apps, projects, analysis, vaults, pc_locations) al `registry_api`.
**Estandar:** todo `apps/<name>`, `analysis/<name>`, `projects/*/apps/<name>` y `projects/*/analysis/<name>` debe tener su propio repo Gitea bajo `dataforge/<basename>`. Los `subrepos/` de la raiz NO entran (son mirrors upstream que no se pushean desde aqui). Los `vaults/` tampoco — son datos puros con su propio mecanismo de compartir (TBD). **Estandar:** todo `apps/<name>`, `analysis/<name>`, `projects/*/apps/<name>` y `projects/*/analysis/<name>` debe tener su propio repo Gitea bajo `dataforge/<basename>`. Los `subrepos/` de la raiz NO entran (mirrors upstream). Los `vaults/` tampoco.
**Modo automático (preferencia del usuario):** este comando NO pregunta. Si hay dirty trees, commitea automáticamente con un mensaje generado a partir de los cambios. Prioridad: hacer commits frecuentes y pushear rápido. **Único límite:** no commitear archivos que parezcan secrets (`.env`, `*credentials*`, `*.key`, `*.pem`, `id_rsa*`) — si se detectan, abortar y avisar.
## Argumento ## Argumento
`$ARGUMENTS` — opcional. Si se pasa texto, se usa como mensaje de commit por defecto cuando algún repo tenga cambios sin commitear y el usuario apruebe commitear durante el flujo. Sin argumento, se pregunta el mensaje al detectar dirty tree. `$ARGUMENTS` — opcional. Si se pasa texto, se usa como mensaje de commit. Sin argumento, se genera uno automáticamente con el patrón:
```
chore: auto-commit (<N> archivos modificados, <N> nuevos, <N> borrados)
- <ruta1>
- <ruta2>
...
```
Si los cambios tienen un patrón claro (todos en un mismo dominio/dir), usar ese patrón en el subject:
- todo bajo `python/functions/<dom>/``feat(<dom>): auto-commit con N cambios`
- todo bajo `dev/issues/``chore(issues): auto-commit`
- mezclado → `chore: auto-commit`
## Pasos ## Pasos
### 1. Descubrir repos git + apps/analyses sin git en el workspace ### 1. Descubrir repos git + apps/analyses sin git
```bash ```bash
cd /home/lucas/fn_registry # ajustar al PC cd /home/lucas/fn_registry
# 1a) Repos git ya existentes (sin subrepos/, sin cpp/vendor/, sin sources/, sin temp/)
REPOS=$(find . -name ".git" -type d \ REPOS=$(find . -name ".git" -type d \
-not -path "./.git/*" \ -not -path "./.git" -not -path "./.git/*" \
-not -path "*/node_modules/*" \ -not -path "*/node_modules/*" -not -path "*/.venv/*" \
-not -path "*/.venv/*" \ -not -path "*/cpp/vendor/*" -not -path "*/cpp/build/*" \
-not -path "*/cpp/vendor/*" \ -not -path "*/sources/*" -not -path "*/temp/*" -not -path "*/subrepos/*" 2>/dev/null \
-not -path "*/cpp/build/*" \ | sed 's|/.git$||')
-not -path "*/sources/*" \
-not -path "*/temp/*" \
-not -path "*/subrepos/*" 2>/dev/null | sed 's|/.git$||')
REPOS=". $REPOS" REPOS=". $REPOS"
# 1b) Apps y analyses SIN .git — candidatos a inicializar # Apps/analyses sin .git — auto-inicializar como dataforge/<basename>
MISSING=() MISSING=()
for d in apps/*/ analysis/*/ projects/*/apps/*/ projects/*/analysis/*/; do for d in apps/*/ analysis/*/ projects/*/apps/*/ projects/*/analysis/*/; do
d="${d%/}" d="${d%/}"
@@ -35,11 +46,9 @@ for d in apps/*/ analysis/*/ projects/*/apps/*/ projects/*/analysis/*/; do
done done
``` ```
Si `MISSING` no esta vacio, listarlos al usuario y preguntar si inicializarlos como repos `dataforge/<basename>` antes de continuar (paso 1c). ### 1b. Auto-inicializar repos faltantes (sin pedir confirmación)
### 1c. Inicializar repos faltantes (opcional, requiere confirmacion) Para cada `$d` en `MISSING`:
Para cada `$d` aprobado por el usuario:
```bash ```bash
export GITEA_URL=$(pass agentes/gitea-url | head -n1) export GITEA_URL=$(pass agentes/gitea-url | head -n1)
@@ -52,24 +61,45 @@ bash -c "
" "
``` ```
**Antes de inicializar**, comprobar que `$d/.gitignore` existe; si no, escribir uno apropiado (ver `.claude/rules/apps_vs_functions.md` para patrones tipicos: excluir `.venv/`, `node_modules/`, binarios, `operations.db*`, `.jupyter*`, `__pycache__/`). Si `$d/.gitignore` no existe antes de inicializar, escribir uno apropiado (ver `.claude/rules/apps_vs_functions.md`). Solo abortar la inicialización de ese repo concreto si falla; seguir con el resto.
### 2. Para cada repo, mostrar estado ### 2. Detectar secrets antes de commitear
Para cada repo dirty, listar archivos modificados/nuevos y comprobar nombres sospechosos:
```bash ```bash
for r in $REPOS; do for r in $REPOS; do
echo "=== $r ===" ( cd "$r" \
( cd "$r" && git status -sb && echo "" ) && git status --porcelain | awk '{print $2}' \
| grep -E '(^|/)(\.env(\..*)?$|.*credentials.*|.*\.key$|.*\.pem$|id_rsa.*|.*secret.*|.*token.*\.txt$)' \
| head -5
)
done done
``` ```
### 3. Manejar dirty trees Si la lista de coincidencias **no está vacía**, abortar el push completo, listar los archivos sospechosos y pedir al usuario que los gestione (añadir a `.gitignore`, mover, o decidir explícitamente que entren).
- Si **algún repo** tiene cambios sin commitear: lista los archivos al usuario y **pregunta** qué hacer: ### 3. Auto-commitear dirty trees
- (a) commitear todo con un mensaje (usar `$ARGUMENTS` si está, si no preguntar)
- (b) stashear y seguir solo con los commits ahead Para cada repo con cambios sin commitear:
- (c) abortar
- Nunca commitear sin permiso explícito. ```bash
for r in $REPOS; do
( cd "$r"
[ -z "$(git status --porcelain)" ] && exit 0 # limpio, nada que hacer
git add -A
if [ -n "$ARGUMENTS" ]; then
MSG="$ARGUMENTS"
else
MSG="$(generate_auto_message)" # patrón descrito en sección 'Argumento'
fi
git commit -m "$MSG" \
-m "Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>" 2>&1 | tail -3
)
done
```
`generate_auto_message` debe inspeccionar `git diff --cached --stat` y producir un subject como `feat(notebook): N cambios` cuando todos los paths comparten prefijo, o `chore: auto-commit` si están dispersos.
### 4. Push de cada repo ### 4. Push de cada repo
@@ -80,13 +110,15 @@ for r in $REPOS; do
&& if git rev-parse --abbrev-ref --symbolic-full-name @{u} >/dev/null 2>&1; then && if git rev-parse --abbrev-ref --symbolic-full-name @{u} >/dev/null 2>&1; then
git push origin "$BRANCH" 2>&1 | tail -3 git push origin "$BRANCH" 2>&1 | tail -3
else else
echo "[$r] no upstream para '$BRANCH' — saltado" git push -u origin "$BRANCH" 2>&1 | tail -3
fi fi
) )
done done
``` ```
### 5. fn sync con credenciales de pass Si `push` rechaza por non-fast-forward (rama behind), no abortar el resto. Reportar ese repo concreto y sugerir `/full-git-pull` antes; seguir con los demás repos.
### 5. fn sync
```bash ```bash
USER=$(pass registry/basicauth-user | head -1) USER=$(pass registry/basicauth-user | head -1)
@@ -97,14 +129,15 @@ export REGISTRY_API_TOKEN="$TOKEN"
./fn sync ./fn sync
``` ```
Si `pass` falla con "decryption failed" → gpg-agent locked. Pedir al usuario que ejecute `pass show registry/api-token` en su terminal real (Bash tool no tiene TTY) y reintentar. Si `pass` falla con "decryption failed" → gpg-agent bloqueado. Pedir al usuario que ejecute `pass show registry/api-token` en su terminal real (Bash tool no tiene TTY) y reintentar.
### 6. Resumen ### 6. Resumen
Imprimir tabla concisa: para cada repo, branch, commits pusheados o "ya estaba al día". Y resultado de `fn sync` (sent / received / imported). Tabla concisa: por repo, commits creados (cuántos y subject), commits pusheados, o "ya estaba al día". Y resultado de `fn sync` (sent / received / imported).
## Notas ## Notas
- Es responsabilidad del comando **pushear**, no decidir qué commitear. Solo commitea si el usuario lo aprueba explícitamente. - **Modo no-interactivo por diseño.** El usuario prefiere commits frecuentes y push rápido. No se pregunta si commitear ni se pide mensaje (salvo que se pase via `$ARGUMENTS`).
- Los submodules del directorio `cpp/vendor/` (imgui, implot, glfw, tracy, implot3d) se ignoran (son mirrors upstream, no se pushean desde aquí). - **Secrets son la única razón para abortar antes de commitear.** Cualquier patrón sospechoso (`.env`, credenciales, claves) detiene el flujo y se reporta al usuario.
- Si una rama va `behind` el remote, abortar el push de ese repo y avisar para correr `/full-git-pull` primero. - Submodules `cpp/vendor/` (mirrors upstream) se ignoran.
- Si un sub-repo va `behind` el remote, su push se omite con un mensaje (no se aborta el resto). El usuario corre `/full-git-pull` cuando le convenga.
@@ -1,3 +1,30 @@
# 0050 — `jupyter_exec` falla por cliente colaborativo (RESUELTO 2026-05-05)
## Cierre (2026-05-05)
Resuelto via opcion (b) del propio issue: migrar `jupyter_exec` a REST + `KernelClient`
clasico, bypassando `NbModelClient`/Y.js.
Bug raiz adicional encontrado al reproducir: `_notebook_exists` usaba `HEAD /api/contents`,
y Jupyter Server responde **405 Method Not Allowed** (no soporta HEAD ahi). Cambiado a
`GET /api/contents?content=0`.
Cambios:
- `python/functions/notebook/jupyter_exec.py` reescrito (v2.0.0). Sync, sin asyncio.
Append/cell ahora usan REST `/api/contents` para leer/escribir celdas + outputs y
`KernelClient` para ejecutar.
- `python/functions/notebook/tests/test_jupyter_exec.py` con 5 tests unitarios
(incluye guard para que HEAD no vuelva) y 4 tests e2e que arrancan un Jupyter Lab
en puerto libre con `--collaborative` y verifican los tres modos.
- 9/9 tests pasan en local.
Trade-off documentado en el `.md`: los cambios se persisten a disco; Jupyter Lab los
detecta y muestra en el browser (puede pedir 'Revert to disk' segun version y
conflictos). Esto basta para los analyses del proyecto y es lo que ya se hacia con el
workaround `nbformat`+`nbconvert`.
---
# 0050 — `jupyter_exec` falla por cliente colaborativo (workaround documentado) # 0050 — `jupyter_exec` falla por cliente colaborativo (workaround documentado)
## APP Metadata ## APP Metadata
@@ -1,9 +1,29 @@
--- ---
title: "Extracción masiva de footprint_aurgi → registry" title: "Extracción masiva de footprint_aurgi → registry"
status: in_progress status: completed
created: 2026-05-04 created: 2026-05-04
completed: 2026-05-05
--- ---
## Cierre (2026-05-05)
Los 9 batches completos: 41 funciones + 4 tipos + app `footprint_geo_stack` + 4 pipelines.
Tests: 240 pasan, 2 skip esperados (1 por `osm2pgsql` ausente, 1 por geo stack no
relanzable sin `.env` con `VALHALLA_DATA_DIR`).
Bugs encontrados y arreglados al cerrar el issue:
1. `setup_geo_stack_docker_pipeline` abortaba `verify` si `docker compose up -d` fallaba.
Ahora corre verify aunque el `up` falle (caso típico: stack ya vivo, lanzado con su
`.env` en otra parte).
2. El check de PostGIS usaba el nombre `footprint_postgis` que no coincide con el
`container_name: better_maps_postgis` del compose. Corregido + credenciales reales
(`-U geoserver -d gis`).
Función primitiva añadida como subproducto: `docker_container_running_py_infra` con
tests unitarios + integración (7 tests, todos pasan). Reutilizable para cualquier
verificador de stack.
# 0052 — Extracción de funciones de `sources/footprint_aurgi/` # 0052 — Extracción de funciones de `sources/footprint_aurgi/`
Extracción de 45 funciones + 4 tipos del proyecto interno `footprint_aurgi` (código propio Aurgi, sin LICENSE — `source_license: internal-aurgi`). Extracción de 45 funciones + 4 tipos del proyecto interno `footprint_aurgi` (código propio Aurgi, sin LICENSE — `source_license: internal-aurgi`).
@@ -0,0 +1,51 @@
---
name: docker_container_running
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "docker_container_running(name: str, timeout: float = 5.0) -> bool"
description: "Comprueba si un contenedor Docker existe y está corriendo. True solo si docker inspect retorna State.Running=true. Cualquier fallo (docker ausente, contenedor inexistente, daemon caído, timeout) devuelve False sin excepción."
tags: [docker, container, infra, healthcheck]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [subprocess]
params:
- name: name
desc: "Nombre o ID del contenedor a comprobar. Match exacto, no acepta wildcards."
- name: timeout
desc: "Timeout en segundos para `docker inspect`. Default 5.0. Si se supera devuelve False."
output: "True si el contenedor existe y está corriendo. False en cualquier otro caso (no existe, está parado, docker no disponible, timeout)."
tested: true
tests:
- "True para contenedor en ejecución (mock subprocess)"
- "False cuando docker inspect retorna 'false'"
- "False cuando el contenedor no existe (returncode != 0)"
- "False cuando docker no está instalado (FileNotFoundError)"
- "False cuando se excede el timeout (TimeoutExpired)"
- "Integration: comprueba contenedor real si docker disponible"
test_file_path: "python/functions/infra/tests/test_docker_container_running.py"
file_path: "python/functions/infra/docker_container_running.py"
---
## Ejemplo
```python
from python.functions.infra.docker_container_running import docker_container_running
if docker_container_running("better_maps_valhalla"):
print("Valhalla up")
else:
print("Valhalla down")
```
## Notas
Wrapper minimalista sobre `docker inspect -f '{{.State.Running}}'`. Pensado como
primitiva componible para verificadores de stack y health checks. No lanza
excepciones — un fallo de cualquier tipo se reporta como False, dejando que
el caller decida qué hacer (skip de test, reintentar, levantar el stack, etc.).
@@ -0,0 +1,28 @@
"""Comprueba si un contenedor Docker está corriendo por nombre."""
from __future__ import annotations
import subprocess
def docker_container_running(name: str, timeout: float = 5.0) -> bool:
"""Devuelve True si el contenedor `name` existe y está corriendo.
Usa `docker inspect -f '{{.State.Running}}' <name>`. Cualquier fallo
(docker no instalado, contenedor inexistente, daemon caído, timeout)
se interpreta como False.
"""
try:
proc = subprocess.run(
["docker", "inspect", "-f", "{{.State.Running}}", name],
capture_output=True,
text=True,
timeout=timeout,
)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
return False
if proc.returncode != 0:
return False
return proc.stdout.strip().lower() == "true"
@@ -0,0 +1,59 @@
"""Tests para docker_container_running."""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from unittest.mock import patch
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
from python.functions.infra.docker_container_running import docker_container_running
def _make_completed(stdout: str, returncode: int = 0) -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr="")
def test_running_true():
with patch("subprocess.run", return_value=_make_completed("true\n", 0)):
assert docker_container_running("anything") is True
def test_running_false_when_state_false():
with patch("subprocess.run", return_value=_make_completed("false\n", 0)):
assert docker_container_running("stopped_container") is False
def test_running_false_when_inspect_fails():
# docker inspect retorna != 0 cuando el contenedor no existe
with patch("subprocess.run", return_value=_make_completed("", 1)):
assert docker_container_running("nope") is False
def test_running_false_when_docker_missing():
with patch("subprocess.run", side_effect=FileNotFoundError):
assert docker_container_running("any") is False
def test_running_false_on_timeout():
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="docker", timeout=5.0)):
assert docker_container_running("any") is False
def test_running_strips_and_lowercases():
# docker a veces emite con trailing whitespace; aceptamos "True\n" y "TRUE"
for stdout in ("True\n", "TRUE", " true "):
with patch("subprocess.run", return_value=_make_completed(stdout, 0)):
assert docker_container_running("c") is True
def test_running_integration_real_docker():
"""Si docker está disponible, comprueba con un contenedor inexistente y devuelve False."""
if not shutil.which("docker"):
pytest.skip("docker no disponible en el PATH")
assert docker_container_running("__definitely_does_not_exist_xyz__") is False
+32 -30
View File
@@ -3,17 +3,17 @@ name: jupyter_exec
kind: function kind: function
lang: py lang: py
domain: notebook domain: notebook
version: "1.0.0" version: "2.0.0"
purity: impure purity: impure
signature: "jupyter_append_execute(notebook_path: str, code: str, server_url: str, token: str) -> dict" signature: "jupyter_append_execute(notebook_path: str, code: str, server_url: str, token: str) -> dict"
description: "Ejecuta codigo en kernels de Jupyter via WebSocket. Tres modos: append (añade celda al notebook y la ejecuta), cell (ejecuta celda existente por indice), kernel (ejecuta en el kernel sin tocar ningun notebook)." description: "Ejecuta codigo en kernels de Jupyter via REST + WebSocket clasico al kernel. Tres modos: append (añade celda y ejecuta), cell (ejecuta celda existente), kernel (ejecuta sin tocar notebook). NO usa el canal colaborativo Y.js."
tags: [jupyter, notebook, kernel, websocket, execution, cells] tags: [jupyter, notebook, kernel, websocket, execution, cells]
uses_functions: [] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "error_go_core" error_type: "error_go_core"
imports: [jupyter_kernel_client, jupyter_nbmodel_client] imports: [jupyter_kernel_client, urllib, json, uuid]
params: params:
- name: notebook_path - name: notebook_path
desc: "Ruta relativa al notebook" desc: "Ruta relativa al notebook"
@@ -24,9 +24,18 @@ params:
- name: token - name: token
desc: "Token de autenticación (default vacío)" desc: "Token de autenticación (default vacío)"
output: "Dict con cell_index y outputs del código ejecutado, o resultados del kernel" output: "Dict con cell_index y outputs del código ejecutado, o resultados del kernel"
tested: false tested: true
tests: [] tests:
test_file_path: "" - "test_notebook_exists_uses_get_not_head"
- "test_notebook_exists_returns_false_on_404"
- "test_create_notebook_skips_when_exists"
- "test_new_code_cell_has_required_fields"
- "test_extract_outputs_handles_streams_and_results"
- "e2e: test_e2e_append_executes_and_persists"
- "e2e: test_e2e_append_twice_increments_index"
- "e2e: test_e2e_cell_executes_existing"
- "e2e: test_e2e_kernel_mode"
test_file_path: "python/functions/notebook/tests/test_jupyter_exec.py"
file_path: "python/functions/notebook/jupyter_exec.py" file_path: "python/functions/notebook/jupyter_exec.py"
--- ---
@@ -34,9 +43,9 @@ file_path: "python/functions/notebook/jupyter_exec.py"
### `jupyter_append_execute(notebook_path, code, server_url, token)` ### `jupyter_append_execute(notebook_path, code, server_url, token)`
Añade una celda de codigo al final del notebook y la ejecuta. Usa el protocolo Añade una celda de codigo al final del notebook, la ejecuta en el kernel y persiste
colaborativo de Jupyter, por lo que tanto el agente como el usuario ven la celda celda + outputs a disco via REST `/api/contents`. Jupyter Lab detecta el cambio y lo
y su output en tiempo real en JupyterLab. refleja en el browser.
```python ```python
from notebook.jupyter_exec import jupyter_append_execute from notebook.jupyter_exec import jupyter_append_execute
@@ -52,23 +61,18 @@ result = jupyter_append_execute(
### `jupyter_execute_cell(notebook_path, cell_index, server_url, token)` ### `jupyter_execute_cell(notebook_path, cell_index, server_url, token)`
Ejecuta una celda existente del notebook por su indice (0-based). Ejecuta una celda existente por indice (0-based) y persiste sus outputs.
```python ```python
from notebook.jupyter_exec import jupyter_execute_cell
result = jupyter_execute_cell("notebooks/analisis.ipynb", 3) result = jupyter_execute_cell("notebooks/analisis.ipynb", 3)
# {"cell_index": 3, "outputs": ["42"]} # {"cell_index": 3, "outputs": ["42"]}
``` ```
### `jupyter_kernel_execute(code, server_url, token)` ### `jupyter_kernel_execute(code, server_url, token)`
Ejecuta codigo directamente en el kernel sin modificar ningun notebook. Util para Ejecuta codigo directo en el kernel sin tocar ningun notebook.
consultas rapidas, inspeccion de variables o verificacion de estado del kernel.
```python ```python
from notebook.jupyter_exec import jupyter_kernel_execute
result = jupyter_kernel_execute("len(df)") result = jupyter_kernel_execute("len(df)")
# {"outputs": ["1500"], "status": "ok"} # {"outputs": ["1500"], "status": "ok"}
``` ```
@@ -76,13 +80,8 @@ result = jupyter_kernel_execute("len(df)")
## CLI ## CLI
```bash ```bash
# Añadir celda y ejecutar python -m notebook.jupyter_exec append notebooks/mi.ipynb "print('hola')"
python -m notebook.jupyter_exec append notebooks/mi.ipynb "print('hola')" --server http://localhost:8888 --token mytoken python -m notebook.jupyter_exec cell notebooks/mi.ipynb 2
# Ejecutar celda existente
python -m notebook.jupyter_exec cell notebooks/mi.ipynb 2 --server http://localhost:8888
# Ejecutar en kernel directamente
python -m notebook.jupyter_exec kernel "x = 42; print(x)" python -m notebook.jupyter_exec kernel "x = 42; print(x)"
``` ```
@@ -96,12 +95,15 @@ Output siempre JSON. En error retorna `{"error": "..."}` por stderr con exit cod
| display_data / execute_result | `data.text/plain` | | display_data / execute_result | `data.text/plain` |
| error | `traceback` (joined con `\n`) | | error | `traceback` (joined con `\n`) |
## Notas ## Notas (v2.0.0 — fix Issue 0050)
- Las funciones `append` y `cell` son async internamente; las publicas usan `asyncio.run()`. - **Bypassa el canal colaborativo Y.js**. Usa REST `/api/contents` para leer/escribir
- `jupyter_kernel_execute` es sincrona directamente porque `KernelClient.execute` es bloqueante. celdas y `KernelClient` (websocket clasico al kernel) para ejecutar. Robusto frente
a versiones nuevas de `jupyter-collaboration` que rompian `NbModelClient`.
- **Trade-off**: las celdas/outputs se persisten a disco, no se sincronizan en
tiempo real via Y.js. Jupyter Lab detecta el cambio en el filesystem y lo refleja
(puede pedir 'Revert to disk' segun version).
- `_notebook_exists` usa `GET /api/contents?content=0` (HEAD devuelve 405 en Jupyter Server).
- **Auto-init**: `jupyter_append_execute` crea el notebook si no existe y arranca una
sesion con kernel si no hay ninguna activa para ese notebook.
- El token puede ser cadena vacia si el servidor tiene autenticacion deshabilitada. - El token puede ser cadena vacia si el servidor tiene autenticacion deshabilitada.
- `NbModelClient` requiere que el servidor tenga habilitado el endpoint colaborativo (`/api/collaboration/`), disponible en JupyterLab >= 4 con `jupyter-collaboration` instalado.
- **Auto-init**: `jupyter_append_execute` crea el notebook automaticamente si no existe (via REST PUT /api/contents) y arranca una sesion con kernel si no hay ninguna activa para ese notebook (via POST /api/sessions). No es necesario abrir el notebook manualmente en el navegador.
- **Auto-session**: `jupyter_execute_cell` tambien garantiza que exista una sesion con kernel antes de ejecutar.
- **Fix Issue 006**: `jupyter_execute_cell` normaliza la celda antes de ejecutar. Las celdas creadas manualmente (no via la UI de Jupyter) pueden carecer de `outputs` o `execution_count` en el modelo CRDT, lo que causaba `KeyError: 'outputs'` dentro de `execute_cell` al hacer `del ycell["outputs"][:]`. El fix lee la celda con `nb[cell_index]`, detecta los campos faltantes, y reemplaza la celda via `nb[cell_index] = _normalize_code_cell(cell)` — que usa `set_cell` internamente para re-crear el mapa CRDT completo preservando el source original.
+119 -198
View File
@@ -1,35 +1,48 @@
"""Ejecuta codigo en kernels de Jupyter via WebSocket. """Ejecuta codigo en kernels de Jupyter.
Tres modos de ejecucion: Tres modos:
- append: añade una celda al final del notebook y la ejecuta - append: añade una celda al final del notebook y la ejecuta
- cell: ejecuta una celda existente por indice - cell: ejecuta una celda existente por indice
- kernel: ejecuta codigo directamente en el kernel sin modificar ningun notebook - kernel: ejecuta codigo directamente en el kernel sin tocar notebook
Implementacion basada en REST `/api/contents` + `KernelClient` (websocket clasico
al kernel). NO usa `jupyter_nbmodel_client` ni el canal colaborativo Y.js, por lo
que es robusto frente a versiones nuevas de `jupyter-collaboration` (ver issue
0050). Trade-off: los cambios al notebook se persisten a disco; Jupyter Lab los
detecta via file watch (puede pedir 'Revert to disk' o 'Overwrite' segun version).
""" """
import asyncio
import json import json
from functools import partial import uuid
from typing import Any from typing import Any
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from jupyter_kernel_client import KernelClient from jupyter_kernel_client import KernelClient
from jupyter_nbmodel_client import NbModelClient, get_jupyter_notebook_websocket_url
from nbformat import NotebookNode
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers internos # Helpers REST
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _auth_headers(token: str, content_type: bool = False) -> dict[str, str]:
headers = {"Accept": "application/json"}
if content_type:
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = f"token {token}"
return headers
def _notebook_exists(notebook_path: str, server_url: str, token: str) -> bool: def _notebook_exists(notebook_path: str, server_url: str, token: str) -> bool:
"""Comprueba si un notebook existe en el servidor Jupyter via HEAD /api/contents.""" """Comprueba si un notebook existe via GET /api/contents (con `content=0`).
headers = {"Accept": "application/json"}
if token: Nota: Jupyter Server no soporta HEAD en /api/contents (responde 405). Usamos
headers["Authorization"] = f"token {token}" GET con content=0 para evitar transferir el cuerpo completo.
check_url = f"{server_url}/api/contents/{notebook_path}" """
req = Request(check_url, headers=headers, method="HEAD") check_url = f"{server_url}/api/contents/{notebook_path}?content=0"
req = Request(check_url, headers=_auth_headers(token), method="GET")
try: try:
with urlopen(req, timeout=5): with urlopen(req, timeout=5):
return True return True
@@ -43,12 +56,6 @@ def _create_notebook(notebook_path: str, server_url: str, token: str, kernel_nam
"""Crea un notebook vacio via PUT /api/contents si no existe.""" """Crea un notebook vacio via PUT /api/contents si no existe."""
if _notebook_exists(notebook_path, server_url, token): if _notebook_exists(notebook_path, server_url, token):
return return
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
}
if token:
headers["Authorization"] = f"token {token}"
kernel_display = {"python3": "Python 3 (ipykernel)", "python": "Python 3"}.get(kernel_name, kernel_name) kernel_display = {"python3": "Python 3 (ipykernel)", "python": "Python 3"}.get(kernel_name, kernel_name)
notebook_content = { notebook_content = {
"nbformat": 4, "nbformat": 4,
@@ -61,49 +68,53 @@ def _create_notebook(notebook_path: str, server_url: str, token: str, kernel_nam
} }
body = json.dumps({"type": "notebook", "content": notebook_content}).encode("utf-8") body = json.dumps({"type": "notebook", "content": notebook_content}).encode("utf-8")
url = f"{server_url}/api/contents/{notebook_path}" url = f"{server_url}/api/contents/{notebook_path}"
req = Request(url, data=body, headers=headers, method="PUT") req = Request(url, data=body, headers=_auth_headers(token, content_type=True), method="PUT")
with urlopen(req, timeout=10) as resp:
resp.read()
def _get_notebook_content(notebook_path: str, server_url: str, token: str) -> dict:
"""Lee el notebook completo via GET /api/contents (con `content`)."""
url = f"{server_url}/api/contents/{notebook_path}?content=1&type=notebook"
req = Request(url, headers=_auth_headers(token), method="GET")
with urlopen(req, timeout=10) as resp:
return json.loads(resp.read())
def _put_notebook_content(notebook_path: str, server_url: str, token: str, content: dict) -> None:
"""Sobrescribe el notebook via PUT /api/contents."""
body = json.dumps({"type": "notebook", "format": "json", "content": content}).encode("utf-8")
url = f"{server_url}/api/contents/{notebook_path}"
req = Request(url, data=body, headers=_auth_headers(token, content_type=True), method="PUT")
with urlopen(req, timeout=10) as resp: with urlopen(req, timeout=10) as resp:
resp.read() resp.read()
def _ensure_session(server_url: str, token: str, notebook_path: str, kernel_name: str = "python3") -> str: def _ensure_session(server_url: str, token: str, notebook_path: str, kernel_name: str = "python3") -> str:
"""Garantiza que exista una sesion para el notebook. Retorna el kernel_id. """Garantiza una sesion para el notebook. Retorna kernel_id.
Si ya hay una sesion activa, retorna su kernel_id. Si no, crea una nueva Si existe una sesion vinculada al notebook, reusa su kernel. Si no, crea
via POST /api/sessions (lo cual tambien arranca un kernel). sesion+kernel via POST /api/sessions.
""" """
kernel_id = _resolve_kernel_id(server_url, token, notebook_path) kernel_id = _resolve_kernel_id(server_url, token, notebook_path)
if kernel_id: if kernel_id:
return kernel_id return kernel_id
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
}
if token:
headers["Authorization"] = f"token {token}"
body = json.dumps({ body = json.dumps({
"path": notebook_path, "path": notebook_path,
"type": "notebook", "type": "notebook",
"kernel": {"name": kernel_name}, "kernel": {"name": kernel_name},
}).encode("utf-8") }).encode("utf-8")
url = f"{server_url}/api/sessions" url = f"{server_url}/api/sessions"
req = Request(url, data=body, headers=headers, method="POST") req = Request(url, data=body, headers=_auth_headers(token, content_type=True), method="POST")
with urlopen(req, timeout=10) as resp: with urlopen(req, timeout=10) as resp:
session = json.loads(resp.read()) session = json.loads(resp.read())
return session.get("kernel", {}).get("id", "") return session.get("kernel", {}).get("id", "")
def _api_get(url: str, token: str = "") -> dict | list | None: def _api_get(url: str, token: str = "") -> dict | list | None:
"""GET a Jupyter REST API endpoint."""
headers = {"Accept": "application/json"}
if token:
headers["Authorization"] = f"token {token}"
try: try:
req = Request(url, headers=headers) req = Request(url, headers=_auth_headers(token))
with urlopen(req, timeout=5) as resp: with urlopen(req, timeout=5) as resp:
return json.loads(resp.read()) return json.loads(resp.read())
except (URLError, OSError, json.JSONDecodeError): except (URLError, OSError, json.JSONDecodeError):
@@ -111,7 +122,7 @@ def _api_get(url: str, token: str = "") -> dict | list | None:
def _resolve_kernel_id(server_url: str, token: str, notebook_path: str) -> str | None: def _resolve_kernel_id(server_url: str, token: str, notebook_path: str) -> str | None:
"""Find the kernel_id associated with a notebook via the sessions API.""" """Busca el kernel_id de la sesion del notebook via /api/sessions."""
sessions = _api_get(f"{server_url}/api/sessions", token) or [] sessions = _api_get(f"{server_url}/api/sessions", token) or []
for session in sessions: for session in sessions:
nb = session.get("notebook", session.get("path", {})) nb = session.get("notebook", session.get("path", {}))
@@ -122,34 +133,20 @@ def _resolve_kernel_id(server_url: str, token: str, notebook_path: str) -> str |
return None return None
def _resolve_collab_username(server_url: str, token: str) -> str: # ---------------------------------------------------------------------------
"""Resolve the display name of the active user in Jupyter collaboration. # Helpers nbformat
# ---------------------------------------------------------------------------
Queries /api/me to get the identity Jupyter assigned to the browser user.
Falls back to 'Anonymous' if unavailable.
"""
me = _api_get(f"{server_url}/api/me", token)
if me:
identity = me.get("identity", {})
return identity.get("display_name", "") or identity.get("username", "") or identity.get("name", "Anonymous")
return "Anonymous"
def _normalize_code_cell(cell: NotebookNode) -> dict: def _new_code_cell(source: str) -> dict:
"""Devuelve un dict de celda de codigo con todos los campos requeridos por nbformat. """Crea un dict de celda de codigo nbformat 4.5 con todos los campos."""
Celdas creadas manualmente (no via Jupyter UI) pueden omitir 'outputs' o
'execution_count'. El modelo CRDT de jupyter_nbmodel_client accede a estos
campos sin comprobar su existencia, produciendo KeyError al ejecutar.
Este helper garantiza que el dict tenga la estructura completa.
"""
return { return {
"id": cell.get("id", ""), "id": str(uuid.uuid4()),
"cell_type": "code", "cell_type": "code",
"metadata": cell.get("metadata", {}), "metadata": {},
"source": cell.get("source", ""), "source": source,
"outputs": cell.get("outputs", []), "outputs": [],
"execution_count": cell.get("execution_count", None), "execution_count": None,
} }
@@ -175,93 +172,18 @@ def _extract_outputs(raw_outputs: list[dict]) -> list[str]:
return result return result
# --------------------------------------------------------------------------- def _kernel_outputs_to_nbformat(outputs: list[dict]) -> list[dict]:
# Modo append (async interno) """Normaliza outputs de KernelClient al esquema nbformat 4.
# ---------------------------------------------------------------------------
KernelClient ya devuelve dicts con `output_type`, pero algunos casos (errores,
async def _async_append_execute( streams) pueden venir con campos sueltos. Esta funcion los pasa tal cual: el
notebook_path: str, cliente actual cumple el esquema; existe como punto de extension futuro.
code: str, """
server_url: str, return [dict(o) for o in outputs]
token: str,
) -> dict[str, Any]:
_create_notebook(notebook_path, server_url, token)
kernel_id = _ensure_session(server_url, token, notebook_path)
ws_url = get_jupyter_notebook_websocket_url(
server_url,
notebook_path,
token or None,
)
username = _resolve_collab_username(server_url, token)
async with NbModelClient(ws_url, username=username) as nb:
await nb.wait_until_synced()
with KernelClient(server_url=server_url, token=token, kernel_id=kernel_id) as kernel:
cell_index = nb.add_code_cell(code)
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None, partial(nb.execute_cell, cell_index, kernel),
)
# Let Y.js propagate changes to other clients (browser)
await asyncio.sleep(2)
outputs = _extract_outputs(result.get("outputs", []))
return {"cell_index": cell_index, "outputs": outputs}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Modo cell (async interno) # Modos
# ---------------------------------------------------------------------------
async def _async_execute_cell(
notebook_path: str,
cell_index: int,
server_url: str,
token: str,
) -> dict[str, Any]:
kernel_id = _ensure_session(server_url, token, notebook_path)
ws_url = get_jupyter_notebook_websocket_url(
server_url,
notebook_path,
token or None,
)
username = _resolve_collab_username(server_url, token)
async with NbModelClient(ws_url, username=username) as nb:
await nb.wait_until_synced()
# Normalizar la celda antes de ejecutar. Las celdas creadas manualmente
# (sin pasar por la UI de Jupyter) pueden carecer de los campos 'outputs'
# o 'execution_count' en el modelo CRDT, lo que provoca KeyError dentro
# de execute_cell al intentar hacer `del ycell["outputs"][:]`.
# Reemplazar la celda via __setitem__ fuerza la re-creacion completa del
# mapa CRDT con todos los campos requeridos por nbformat.
cell = nb[cell_index]
if cell.get("cell_type") == "code" and (
"outputs" not in cell or "execution_count" not in cell
):
nb[cell_index] = _normalize_code_cell(cell)
with KernelClient(server_url=server_url, token=token, kernel_id=kernel_id) as kernel:
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None, partial(nb.execute_cell, cell_index, kernel),
)
await asyncio.sleep(2)
outputs = _extract_outputs(result.get("outputs", []))
return {"cell_index": cell_index, "outputs": outputs}
# ---------------------------------------------------------------------------
# API publica
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -273,22 +195,31 @@ def jupyter_append_execute(
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Añade una celda de codigo al final del notebook y la ejecuta. """Añade una celda de codigo al final del notebook y la ejecuta.
Tanto el agente como el usuario ven la celda y su output en tiempo real Persiste la celda + outputs a disco via REST `/api/contents`. Jupyter Lab
porque la escritura se realiza a traves del protocolo colaborativo de Jupyter. detecta el cambio en el filesystem y lo refleja en el browser (puede pedir
'Revert to disk' segun version y conflictos).
Args:
notebook_path: Ruta al notebook relativa a la raiz del servidor Jupyter.
code: Codigo Python a insertar y ejecutar.
server_url: URL del servidor Jupyter; por defecto http://localhost:8888.
token: Token de autenticacion del servidor Jupyter.
Returns:
dict con 'cell_index' (indice de la nueva celda) y 'outputs' (lista de strings).
Raises:
Exception: si no se puede conectar al servidor o al kernel.
""" """
return asyncio.run(_async_append_execute(notebook_path, code, server_url, token)) _create_notebook(notebook_path, server_url, token)
kernel_id = _ensure_session(server_url, token, notebook_path)
# Lee notebook, añade celda nueva
file_node = _get_notebook_content(notebook_path, server_url, token)
nb = file_node["content"]
nb.setdefault("cells", [])
new_cell = _new_code_cell(code)
nb["cells"].append(new_cell)
cell_index = len(nb["cells"]) - 1
# Ejecuta en el kernel del notebook
with KernelClient(server_url=server_url, token=token, kernel_id=kernel_id) as kernel:
result = kernel.execute(code)
raw_outputs = result.get("outputs", [])
new_cell["outputs"] = _kernel_outputs_to_nbformat(raw_outputs)
new_cell["execution_count"] = result.get("execution_count")
_put_notebook_content(notebook_path, server_url, token, nb)
return {"cell_index": cell_index, "outputs": _extract_outputs(raw_outputs)}
def jupyter_execute_cell( def jupyter_execute_cell(
@@ -297,22 +228,32 @@ def jupyter_execute_cell(
server_url: str = "http://localhost:8888", server_url: str = "http://localhost:8888",
token: str = "", token: str = "",
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Ejecuta una celda existente del notebook por indice. """Ejecuta una celda existente por indice y persiste sus outputs."""
kernel_id = _ensure_session(server_url, token, notebook_path)
Args: file_node = _get_notebook_content(notebook_path, server_url, token)
notebook_path: Ruta al notebook relativa a la raiz del servidor Jupyter. nb = file_node["content"]
cell_index: Indice de la celda a ejecutar (0-based). cells = nb.get("cells", [])
server_url: URL del servidor Jupyter; por defecto http://localhost:8888. if cell_index < 0 or cell_index >= len(cells):
token: Token de autenticacion del servidor Jupyter. raise IndexError(f"cell_index {cell_index} fuera de rango (notebook tiene {len(cells)} celdas)")
Returns: cell = cells[cell_index]
dict con 'cell_index' y 'outputs' (lista de strings). if cell.get("cell_type") != "code":
raise ValueError(f"La celda {cell_index} no es de codigo (cell_type={cell.get('cell_type')!r})")
Raises: source = cell.get("source", "")
IndexError: si cell_index esta fuera de rango. if isinstance(source, list):
Exception: si no se puede conectar al servidor o al kernel. source = "".join(source)
"""
return asyncio.run(_async_execute_cell(notebook_path, cell_index, server_url, token)) with KernelClient(server_url=server_url, token=token, kernel_id=kernel_id) as kernel:
result = kernel.execute(source)
raw_outputs = result.get("outputs", [])
cell["outputs"] = _kernel_outputs_to_nbformat(raw_outputs)
cell["execution_count"] = result.get("execution_count")
_put_notebook_content(notebook_path, server_url, token, nb)
return {"cell_index": cell_index, "outputs": _extract_outputs(raw_outputs)}
def jupyter_kernel_execute( def jupyter_kernel_execute(
@@ -320,24 +261,9 @@ def jupyter_kernel_execute(
server_url: str = "http://localhost:8888", server_url: str = "http://localhost:8888",
token: str = "", token: str = "",
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Ejecuta codigo directamente en el kernel sin modificar ningun notebook. """Ejecuta codigo directo en el kernel sin tocar ningun notebook."""
Util para consultas rapidas, inspeccion de variables, comprobaciones de estado.
Args:
code: Codigo Python a ejecutar en el kernel activo.
server_url: URL del servidor Jupyter; por defecto http://localhost:8888.
token: Token de autenticacion del servidor Jupyter.
Returns:
dict con 'outputs' (lista de strings) y 'status' ('ok' o 'error').
Raises:
Exception: si no se puede conectar al servidor o al kernel.
"""
with KernelClient(server_url=server_url, token=token) as kernel: with KernelClient(server_url=server_url, token=token) as kernel:
result = kernel.execute(code) result = kernel.execute(code)
outputs = _extract_outputs(result.get("outputs", [])) outputs = _extract_outputs(result.get("outputs", []))
return {"outputs": outputs, "status": result.get("status", "unknown")} return {"outputs": outputs, "status": result.get("status", "unknown")}
@@ -350,26 +276,21 @@ if __name__ == "__main__":
import argparse import argparse
import sys import sys
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(description="Ejecuta codigo en kernels de Jupyter")
description="Ejecuta codigo en kernels de Jupyter",
)
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
# append
p_append = sub.add_parser("append", help="Añade celda al notebook y la ejecuta") p_append = sub.add_parser("append", help="Añade celda al notebook y la ejecuta")
p_append.add_argument("notebook", help="Ruta al notebook relativa al servidor") p_append.add_argument("notebook", help="Ruta al notebook relativa al servidor")
p_append.add_argument("code", help="Codigo a insertar y ejecutar") p_append.add_argument("code", help="Codigo a insertar y ejecutar")
p_append.add_argument("--server", default="http://localhost:8888") p_append.add_argument("--server", default="http://localhost:8888")
p_append.add_argument("--token", default="") p_append.add_argument("--token", default="")
# cell
p_cell = sub.add_parser("cell", help="Ejecuta celda existente por indice") p_cell = sub.add_parser("cell", help="Ejecuta celda existente por indice")
p_cell.add_argument("notebook", help="Ruta al notebook relativa al servidor") p_cell.add_argument("notebook", help="Ruta al notebook relativa al servidor")
p_cell.add_argument("index", type=int, help="Indice de la celda (0-based)") p_cell.add_argument("index", type=int, help="Indice de la celda (0-based)")
p_cell.add_argument("--server", default="http://localhost:8888") p_cell.add_argument("--server", default="http://localhost:8888")
p_cell.add_argument("--token", default="") p_cell.add_argument("--token", default="")
# kernel
p_kernel = sub.add_parser("kernel", help="Ejecuta codigo en el kernel sin tocar notebook") p_kernel = sub.add_parser("kernel", help="Ejecuta codigo en el kernel sin tocar notebook")
p_kernel.add_argument("code", help="Codigo a ejecutar") p_kernel.add_argument("code", help="Codigo a ejecutar")
p_kernel.add_argument("--server", default="http://localhost:8888") p_kernel.add_argument("--server", default="http://localhost:8888")
@@ -0,0 +1,188 @@
"""Tests para jupyter_exec.
Cubre:
- Que `_notebook_exists` usa GET (regresion del bug 0050: HEAD daba 405).
- Que `_create_notebook` no toca el servidor si el notebook ya existe.
- E2E contra un Jupyter Lab vivo si esta disponible (skip si no).
"""
from __future__ import annotations
import json
import os
import socket
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
from python.functions.notebook import jupyter_exec as jx
# ---------------------------------------------------------------------------
# Tests unitarios (regresion del bug HEAD/GET)
# ---------------------------------------------------------------------------
def _http_response_mock(body: bytes = b"{}", status: int = 200) -> MagicMock:
resp = MagicMock()
resp.read.return_value = body
resp.__enter__ = lambda self: self
resp.__exit__ = lambda self, *a: False
resp.status = status
return resp
def test_notebook_exists_uses_get_not_head():
"""Regresion 0050: HEAD devuelve 405 en /api/contents; debe usar GET."""
captured = {}
def fake_urlopen(req, timeout):
captured["method"] = req.get_method()
captured["url"] = req.full_url
return _http_response_mock(b'{"name":"x.ipynb"}')
with patch.object(jx, "urlopen", side_effect=fake_urlopen):
ok = jx._notebook_exists("x.ipynb", "http://srv", "")
assert ok is True
assert captured["method"] == "GET"
assert "content=0" in captured["url"]
def test_notebook_exists_returns_false_on_404():
err = urllib.request.HTTPError(url="x", code=404, msg="nope", hdrs=None, fp=None)
with patch.object(jx, "urlopen", side_effect=err):
assert jx._notebook_exists("x.ipynb", "http://srv", "") is False
def test_create_notebook_skips_when_exists():
with patch.object(jx, "_notebook_exists", return_value=True), \
patch.object(jx, "urlopen") as mock_open:
jx._create_notebook("x.ipynb", "http://srv", "")
mock_open.assert_not_called()
def test_new_code_cell_has_required_fields():
cell = jx._new_code_cell("print(42)")
assert cell["cell_type"] == "code"
assert cell["source"] == "print(42)"
assert cell["outputs"] == []
assert cell["execution_count"] is None
assert isinstance(cell["id"], str) and len(cell["id"]) > 0
assert cell["metadata"] == {}
def test_extract_outputs_handles_streams_and_results():
raw = [
{"output_type": "stream", "name": "stdout", "text": "hola\n"},
{"output_type": "execute_result", "data": {"text/plain": "42"}},
{"output_type": "error", "traceback": ["E1", "E2"]},
]
out = jx._extract_outputs(raw)
assert out == ["hola", "42", "E1\nE2"]
# ---------------------------------------------------------------------------
# E2E (requiere Jupyter Lab corriendo)
# ---------------------------------------------------------------------------
JUPYTER_VENV_BIN = Path("/home/lucas/fn_registry/analysis/pruebas_jupyter/.venv/bin")
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _wait_http(url: str, timeout: float = 10.0) -> bool:
end = time.time() + timeout
while time.time() < end:
try:
with urllib.request.urlopen(url, timeout=1):
return True
except OSError:
time.sleep(0.3)
return False
@pytest.fixture(scope="module")
def jupyter_server(tmp_path_factory):
"""Arranca un Jupyter Lab en puerto libre. Skip si las deps no estan."""
if not (JUPYTER_VENV_BIN / "jupyter-lab").exists():
pytest.skip("Jupyter Lab no disponible en pruebas_jupyter venv")
workdir = tmp_path_factory.mktemp("jupyter_e2e")
(workdir / "notebooks").mkdir()
port = _free_port()
proc = subprocess.Popen(
[
str(JUPYTER_VENV_BIN / "jupyter-lab"),
f"--port={port}",
"--no-browser",
"--ServerApp.token=",
"--ServerApp.password=",
"--ServerApp.disable_check_xsrf=True",
f"--ServerApp.root_dir={workdir}",
"--collaborative",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
server_url = f"http://localhost:{port}"
if not _wait_http(f"{server_url}/api"):
proc.terminate()
pytest.skip("Jupyter Lab no levantó a tiempo")
yield server_url, workdir
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
def test_e2e_append_executes_and_persists(jupyter_server):
server_url, workdir = jupyter_server
result = jx.jupyter_append_execute(
"notebooks/test.ipynb", "z = 21 * 2; print(z)", server_url=server_url,
)
assert result["cell_index"] == 0
assert result["outputs"] == ["42"]
nb = json.loads((workdir / "notebooks" / "test.ipynb").read_text())
assert len(nb["cells"]) == 1
assert nb["cells"][0]["execution_count"] == 1
def test_e2e_append_twice_increments_index(jupyter_server):
server_url, _ = jupyter_server
jx.jupyter_append_execute("notebooks/twice.ipynb", "a = 1", server_url=server_url)
r2 = jx.jupyter_append_execute("notebooks/twice.ipynb", "print(a + 1)", server_url=server_url)
assert r2["cell_index"] == 1
assert r2["outputs"] == ["2"]
def test_e2e_cell_executes_existing(jupyter_server):
server_url, _ = jupyter_server
jx.jupyter_append_execute("notebooks/cell.ipynb", "v = 10", server_url=server_url)
jx.jupyter_append_execute("notebooks/cell.ipynb", "print(v * 5)", server_url=server_url)
r = jx.jupyter_execute_cell("notebooks/cell.ipynb", 1, server_url=server_url)
assert r["outputs"] == ["50"]
def test_e2e_kernel_mode(jupyter_server):
server_url, _ = jupyter_server
r = jx.jupyter_kernel_execute("print('hello kernel')", server_url=server_url)
assert r["status"] == "ok"
assert r["outputs"] == ["hello kernel"]
@@ -9,7 +9,7 @@ purity: impure
signature: "def setup_geo_stack_docker_pipeline(compose_path: str, wait_seconds: int, verify: bool) -> dict" signature: "def setup_geo_stack_docker_pipeline(compose_path: str, wait_seconds: int, verify: bool) -> dict"
description: "Levanta el geo stack Docker (Valhalla + PostGIS + Martin) via docker compose up -d y verifica que los tres servicios responden." description: "Levanta el geo stack Docker (Valhalla + PostGIS + Martin) via docker compose up -d y verifica que los tres servicios responden."
tags: [pipeline, geo, footprint, docker, valhalla, postgis, martin] tags: [pipeline, geo, footprint, docker, valhalla, postgis, martin]
uses_functions: ["valhalla_route_py_geo"] uses_functions: ["valhalla_route_py_geo", "docker_container_running_py_infra"]
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
@@ -23,7 +23,7 @@ example: |
) )
# {"docker_up": True, "valhalla_ok": True, "postgis_ok": True, "martin_ok": True} # {"docker_up": True, "valhalla_ok": True, "postgis_ok": True, "martin_ok": True}
tested: true tested: true
tests: ["test_setup_geo_stack_docker_pipeline"] tests: ["test_setup_geo_stack_docker_pipeline_shape", "test_setup_geo_stack_docker_pipeline_live_stack"]
test_file_path: "python/functions/pipelines/tests/test_setup_geo_stack_docker_pipeline.py" test_file_path: "python/functions/pipelines/tests/test_setup_geo_stack_docker_pipeline.py"
file_path: "python/functions/pipelines/setup_geo_stack_docker_pipeline.py" file_path: "python/functions/pipelines/setup_geo_stack_docker_pipeline.py"
params: params:
@@ -50,7 +50,12 @@ print(result)
## Notas ## Notas
Verifica Valhalla via GET /status, PostGIS via `docker exec footprint_postgis pg_isready -U postgres`, Verifica Valhalla via GET /status (puerto 8002), PostGIS via `docker_container_running` +
y Martin via GET /health en http://localhost:3000/health. `docker exec better_maps_postgis pg_isready -U geoserver -d gis`, y Martin via GET /health
Si `verify=False` solo retorna `docker_up` y el resto en False. (puerto 3000) con fallback a `docker_container_running`.
El nombre del contenedor PostGIS (`footprint_postgis`) debe coincidir con el definido en el compose.
Los nombres de contenedor (`better_maps_postgis`, `better_maps_martin`, `better_maps_valhalla`)
están hardcodeados para coincidir con `apps/footprint_geo_stack/docker-compose.yml`.
`verify=True` corre las comprobaciones aunque `docker compose up -d` falle (típico cuando los
contenedores ya están vivos pero el `.env` con `VALHALLA_DATA_DIR` no está disponible).
@@ -3,11 +3,21 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
import subprocess import subprocess
import sys
import time import time
from urllib import request as urllib_request from urllib import request as urllib_request
from urllib.error import URLError from urllib.error import URLError
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", ".."))
from python.functions.infra.docker_container_running import docker_container_running
POSTGIS_CONTAINER = "better_maps_postgis"
MARTIN_CONTAINER = "better_maps_martin"
VALHALLA_CONTAINER = "better_maps_valhalla"
def setup_geo_stack_docker_pipeline( def setup_geo_stack_docker_pipeline(
compose_path: str = "apps/footprint_geo_stack/docker-compose.yml", compose_path: str = "apps/footprint_geo_stack/docker-compose.yml",
@@ -16,19 +26,16 @@ def setup_geo_stack_docker_pipeline(
) -> dict: ) -> dict:
"""Levanta el geo stack via docker compose y verifica que los servicios responden. """Levanta el geo stack via docker compose y verifica que los servicios responden.
Ejecuta `docker compose up -d` sobre el compose_path dado, espera wait_seconds Si verify=True, los flags se calculan independientemente del resultado de
y luego verifica (si verify=True) que Valhalla, PostGIS y Martin están operativos. `docker compose up -d`: si los contenedores ya estan vivos (lanzados
previamente con su .env correcto) la verificacion sigue funcionando aunque
Args: el `up` actual falle por variables de entorno faltantes.
compose_path: Ruta al docker-compose.yml del geo stack.
wait_seconds: Segundos a esperar tras `docker compose up -d` antes de verificar.
verify: Si True, verifica los tres servicios via HTTP/docker exec.
Returns: Returns:
Dict con claves: Dict con claves:
"docker_up" (bool): True si docker compose arrancó sin error. "docker_up" (bool): True si docker compose arrancó sin error.
"valhalla_ok" (bool): True si Valhalla responde a /status. "valhalla_ok" (bool): True si Valhalla responde a /status.
"postgis_ok" (bool): True si pg_isready retorna OK via docker exec. "postgis_ok" (bool): True si el contenedor postgis está vivo y pg_isready OK.
"martin_ok" (bool): True si Martin responde a /health. "martin_ok" (bool): True si Martin responde a /health.
""" """
result = { result = {
@@ -38,7 +45,7 @@ def setup_geo_stack_docker_pipeline(
"martin_ok": False, "martin_ok": False,
} }
# Step 1: docker compose up -d # Step 1: docker compose up -d (best-effort; no bloquea verify si falla)
try: try:
proc = subprocess.run( proc = subprocess.run(
["docker", "compose", "-f", compose_path, "up", "-d"], ["docker", "compose", "-f", compose_path, "up", "-d"],
@@ -48,51 +55,43 @@ def setup_geo_stack_docker_pipeline(
) )
result["docker_up"] = proc.returncode == 0 result["docker_up"] = proc.returncode == 0
except (subprocess.TimeoutExpired, FileNotFoundError, OSError): except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return result result["docker_up"] = False
if not result["docker_up"]:
return result
if not verify: if not verify:
return result return result
# Step 2: wait for services to be ready # Step 2: esperar a que los servicios esten listos (solo si acabamos de levantar)
if wait_seconds > 0: if result["docker_up"] and wait_seconds > 0:
time.sleep(wait_seconds) time.sleep(wait_seconds)
# Step 3: verify Valhalla via POST /route (lightweight status check via /status) # Step 3: Valhalla — /status responde JSON
try: try:
req = urllib_request.Request( req = urllib_request.Request("http://localhost:8002/status", method="GET")
"http://localhost:8002/status",
method="GET",
)
with urllib_request.urlopen(req, timeout=10) as resp: with urllib_request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode()) data = json.loads(resp.read().decode())
result["valhalla_ok"] = isinstance(data, dict) result["valhalla_ok"] = isinstance(data, dict)
except (URLError, OSError, json.JSONDecodeError, Exception): except (URLError, OSError, json.JSONDecodeError):
result["valhalla_ok"] = False result["valhalla_ok"] = False
# Step 4: verify PostGIS via pg_isready inside docker exec # Step 4: PostGIS — contenedor vivo + pg_isready
try: if docker_container_running(POSTGIS_CONTAINER):
proc = subprocess.run( try:
["docker", "exec", "footprint_postgis", "pg_isready", "-U", "postgres"], proc = subprocess.run(
capture_output=True, ["docker", "exec", POSTGIS_CONTAINER, "pg_isready", "-U", "geoserver", "-d", "gis"],
text=True, capture_output=True,
timeout=15, text=True,
) timeout=15,
result["postgis_ok"] = proc.returncode == 0 )
except (subprocess.TimeoutExpired, FileNotFoundError, OSError): result["postgis_ok"] = proc.returncode == 0
result["postgis_ok"] = False except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
result["postgis_ok"] = False
# Step 5: verify Martin via /health # Step 5: Martin /health responde 200 (con fallback a contenedor vivo)
try: try:
req = urllib_request.Request( req = urllib_request.Request("http://localhost:3000/health", method="GET")
"http://localhost:3000/health",
method="GET",
)
with urllib_request.urlopen(req, timeout=10) as resp: with urllib_request.urlopen(req, timeout=10) as resp:
result["martin_ok"] = resp.status == 200 result["martin_ok"] = resp.status == 200
except (URLError, OSError, Exception): except (URLError, OSError):
result["martin_ok"] = False result["martin_ok"] = docker_container_running(MARTIN_CONTAINER)
return result return result
@@ -1,7 +1,7 @@
"""Tests para setup_geo_stack_docker_pipeline. """Tests para setup_geo_stack_docker_pipeline.
El geo stack ya está corriendo en localhost:8002 (Valhalla), por lo que Si los contenedores del geo stack están corriendo, verifica que el pipeline
verify=True retorna flags reales del stack activo. devuelve flags coherentes. Si no, salta (stub: requiere infra externa).
""" """
from __future__ import annotations from __future__ import annotations
@@ -9,30 +9,39 @@ from __future__ import annotations
import os import os
import sys import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
from python.functions.infra.docker_container_running import docker_container_running
from python.functions.pipelines.setup_geo_stack_docker_pipeline import ( from python.functions.pipelines.setup_geo_stack_docker_pipeline import (
setup_geo_stack_docker_pipeline, setup_geo_stack_docker_pipeline,
) )
GEO_STACK_CONTAINERS = ("better_maps_valhalla", "better_maps_postgis", "better_maps_martin")
def test_setup_geo_stack_docker_pipeline():
"""Verifica el geo stack activo en localhost (docker ya arrancado).""" def test_setup_geo_stack_docker_pipeline_shape():
# Llamamos con verify=True pero sin relanzar docker compose """El pipeline siempre devuelve un dict con los 4 flags bool, aun sin docker."""
# (pasamos wait_seconds=0 para no esperar, el stack ya está up)
result = setup_geo_stack_docker_pipeline( result = setup_geo_stack_docker_pipeline(
compose_path="apps/footprint_geo_stack/docker-compose.yml", compose_path="apps/footprint_geo_stack/docker-compose.yml",
wait_seconds=0, wait_seconds=0,
verify=True, verify=True,
) )
assert isinstance(result, dict) assert isinstance(result, dict)
assert set(result.keys()) == {"docker_up", "valhalla_ok", "postgis_ok", "martin_ok"} assert set(result.keys()) == {"docker_up", "valhalla_ok", "postgis_ok", "martin_ok"}
for key in result:
# docker_up puede ser False si el compose no existe en CI, pero verify sí corre
# Lo importante: los flags son bool
for key in ("docker_up", "valhalla_ok", "postgis_ok", "martin_ok"):
assert isinstance(result[key], bool), f"{key} debe ser bool" assert isinstance(result[key], bool), f"{key} debe ser bool"
# Valhalla está activo en localhost:8002
assert result["valhalla_ok"] is True, "Valhalla debe responder en localhost:8002" def test_setup_geo_stack_docker_pipeline_live_stack():
"""Si los 3 contenedores están vivos, el pipeline debe reportar valhalla_ok=True."""
if not all(docker_container_running(c) for c in GEO_STACK_CONTAINERS):
pytest.skip(f"geo stack no está activo (contenedores esperados: {GEO_STACK_CONTAINERS})")
result = setup_geo_stack_docker_pipeline(
compose_path="apps/footprint_geo_stack/docker-compose.yml",
wait_seconds=0,
verify=True,
)
assert result["valhalla_ok"] is True, "Valhalla container vivo pero el flag dice False"