chore: auto-commit (43 archivos)
- .mcp.json - bash/functions/infra/write_mcp_jupyter_config.md - bash/functions/infra/write_mcp_jupyter_config.sh - cpp/CMakeLists.txt - cpp/apps/chart_demo - cpp/apps/shaders_lab - cpp/functions/gfx/gl_framebuffer.cpp - cpp/functions/gfx/gl_framebuffer.h - cpp/functions/gfx/gl_framebuffer.md - cpp/functions/gfx/mesh_gpu.md - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
"""Tests para jupyter_run_cells.
|
||||
|
||||
Cubre:
|
||||
- Validacion de indices (rango y tipo de celda).
|
||||
- Comportamiento stop_on_error (True/False).
|
||||
- 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, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
|
||||
|
||||
from python.functions.notebook import jupyter_exec as jx
|
||||
from python.functions.notebook import jupyter_run_cells as jrc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers para mocks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_nb(cells: list[dict]) -> dict:
|
||||
"""Construye un dict de notebook minimo con las celdas dadas."""
|
||||
return {
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5,
|
||||
"metadata": {},
|
||||
"cells": cells,
|
||||
}
|
||||
|
||||
|
||||
def _code_cell(source: str) -> dict:
|
||||
return {
|
||||
"cell_type": "code",
|
||||
"source": source,
|
||||
"outputs": [],
|
||||
"execution_count": None,
|
||||
"metadata": {},
|
||||
"id": "test-id",
|
||||
}
|
||||
|
||||
|
||||
def _markdown_cell(source: str) -> dict:
|
||||
return {
|
||||
"cell_type": "markdown",
|
||||
"source": source,
|
||||
"metadata": {},
|
||||
"id": "md-id",
|
||||
}
|
||||
|
||||
|
||||
def _kernel_result(outputs: list[dict], execution_count: int = 1) -> dict:
|
||||
return {"outputs": outputs, "execution_count": execution_count, "status": "ok"}
|
||||
|
||||
|
||||
def _stream_output(text: str) -> dict:
|
||||
return {"output_type": "stream", "name": "stdout", "text": text}
|
||||
|
||||
|
||||
def _error_output(ename: str = "ValueError", traceback: list[str] | None = None) -> dict:
|
||||
return {
|
||||
"output_type": "error",
|
||||
"ename": ename,
|
||||
"evalue": "bad value",
|
||||
"traceback": traceback or [f"{ename}: bad value"],
|
||||
}
|
||||
|
||||
|
||||
class _FakeKernel:
|
||||
"""Simula KernelClient con una lista de resultados predefinidos."""
|
||||
|
||||
def __init__(self, results: list[dict]):
|
||||
self._results = iter(results)
|
||||
|
||||
def execute(self, source: str, **kwargs) -> dict:
|
||||
return next(self._results)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests unitarios
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _patch_infra(nb: dict, kernel: _FakeKernel):
|
||||
"""Context managers que parchean _ensure_session, _get/put_notebook y KernelClient."""
|
||||
file_node = {"content": nb}
|
||||
return [
|
||||
patch.object(jrc, "_ensure_session", return_value="kernel-abc"),
|
||||
patch.object(jrc, "_get_notebook_content", return_value=file_node),
|
||||
patch.object(jrc, "_put_notebook_content"),
|
||||
patch("python.functions.notebook.jupyter_run_cells.KernelClient", return_value=kernel),
|
||||
]
|
||||
|
||||
|
||||
def test_run_cells_single_cell_returns_output():
|
||||
nb = _make_nb([_code_cell("print(42)")])
|
||||
fake_kernel = _FakeKernel([_kernel_result([_stream_output("42\n")], execution_count=1)])
|
||||
|
||||
with (
|
||||
patch.object(jrc, "_ensure_session", return_value="k1"),
|
||||
patch.object(jrc, "_get_notebook_content", return_value={"content": nb}),
|
||||
patch.object(jrc, "_put_notebook_content") as mock_put,
|
||||
patch("python.functions.notebook.jupyter_run_cells.KernelClient", return_value=fake_kernel),
|
||||
):
|
||||
result = jrc.jupyter_run_cells(
|
||||
"nb.ipynb", [0], server_url="http://localhost:8888", token=""
|
||||
)
|
||||
|
||||
assert result["kernel_id"] == "k1"
|
||||
assert result["stopped_at"] is None
|
||||
assert len(result["executed"]) == 1
|
||||
entry = result["executed"][0]
|
||||
assert entry["cell_index"] == 0
|
||||
assert entry["execution_count"] == 1
|
||||
assert entry["outputs"] == ["42"]
|
||||
assert entry["error"] is None
|
||||
assert entry["duration_s"] >= 0
|
||||
mock_put.assert_called_once()
|
||||
|
||||
|
||||
def test_run_cells_stops_on_error_by_default():
|
||||
nb = _make_nb([_code_cell("x=1"), _code_cell("bad"), _code_cell("ok")])
|
||||
fake_kernel = _FakeKernel([
|
||||
_kernel_result([_stream_output("1")], 1),
|
||||
_kernel_result([_error_output("RuntimeError", ["RuntimeError: bad"])], 2),
|
||||
# la celda 2 nunca deberia ejecutarse
|
||||
])
|
||||
|
||||
with (
|
||||
patch.object(jrc, "_ensure_session", return_value="k2"),
|
||||
patch.object(jrc, "_get_notebook_content", return_value={"content": nb}),
|
||||
patch.object(jrc, "_put_notebook_content"),
|
||||
patch("python.functions.notebook.jupyter_run_cells.KernelClient", return_value=fake_kernel),
|
||||
):
|
||||
result = jrc.jupyter_run_cells("nb.ipynb", [0, 1, 2], stop_on_error=True)
|
||||
|
||||
assert result["stopped_at"] == 1
|
||||
assert len(result["executed"]) == 2
|
||||
assert result["executed"][1]["error"] is not None
|
||||
assert "RuntimeError" in result["executed"][1]["error"]
|
||||
|
||||
|
||||
def test_run_cells_no_stop_on_error_continues():
|
||||
nb = _make_nb([_code_cell("bad"), _code_cell("print('after')")])
|
||||
fake_kernel = _FakeKernel([
|
||||
_kernel_result([_error_output()], 1),
|
||||
_kernel_result([_stream_output("after")], 2),
|
||||
])
|
||||
|
||||
with (
|
||||
patch.object(jrc, "_ensure_session", return_value="k3"),
|
||||
patch.object(jrc, "_get_notebook_content", return_value={"content": nb}),
|
||||
patch.object(jrc, "_put_notebook_content"),
|
||||
patch("python.functions.notebook.jupyter_run_cells.KernelClient", return_value=fake_kernel),
|
||||
):
|
||||
result = jrc.jupyter_run_cells("nb.ipynb", [0, 1], stop_on_error=False)
|
||||
|
||||
assert result["stopped_at"] is None
|
||||
assert len(result["executed"]) == 2
|
||||
assert result["executed"][0]["error"] is not None
|
||||
assert result["executed"][1]["outputs"] == ["after"]
|
||||
|
||||
|
||||
def test_run_cells_invalid_index_raises():
|
||||
nb = _make_nb([_code_cell("x=1")])
|
||||
|
||||
with (
|
||||
patch.object(jrc, "_ensure_session", return_value="k4"),
|
||||
patch.object(jrc, "_get_notebook_content", return_value={"content": nb}),
|
||||
patch.object(jrc, "_put_notebook_content"),
|
||||
patch("python.functions.notebook.jupyter_run_cells.KernelClient"),
|
||||
):
|
||||
with pytest.raises(IndexError, match="fuera de rango"):
|
||||
jrc.jupyter_run_cells("nb.ipynb", [5])
|
||||
|
||||
|
||||
def test_run_cells_non_code_cell_raises():
|
||||
nb = _make_nb([_markdown_cell("# titulo")])
|
||||
|
||||
with (
|
||||
patch.object(jrc, "_ensure_session", return_value="k5"),
|
||||
patch.object(jrc, "_get_notebook_content", return_value={"content": nb}),
|
||||
patch.object(jrc, "_put_notebook_content"),
|
||||
patch("python.functions.notebook.jupyter_run_cells.KernelClient"),
|
||||
):
|
||||
with pytest.raises(ValueError, match="no es de codigo"):
|
||||
jrc.jupyter_run_cells("nb.ipynb", [0])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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_run_cells_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 levanto a tiempo")
|
||||
|
||||
yield server_url, workdir
|
||||
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
def test_e2e_run_cells_batch(jupyter_server):
|
||||
"""Ejecuta 3 celdas en lote y verifica outputs y persistence."""
|
||||
server_url, workdir = jupyter_server
|
||||
|
||||
# Prepara el notebook con 3 celdas via jupyter_exec
|
||||
jx.jupyter_append_execute("notebooks/batch.ipynb", "x = 10", server_url=server_url)
|
||||
jx.jupyter_append_execute("notebooks/batch.ipynb", "y = x * 3", server_url=server_url)
|
||||
jx.jupyter_append_execute("notebooks/batch.ipynb", "print(x + y)", server_url=server_url)
|
||||
|
||||
# Ejecuta el lote
|
||||
result = jrc.jupyter_run_cells(
|
||||
"notebooks/batch.ipynb",
|
||||
[0, 1, 2],
|
||||
server_url=server_url,
|
||||
)
|
||||
|
||||
assert result["stopped_at"] is None
|
||||
assert len(result["executed"]) == 3
|
||||
assert result["executed"][2]["outputs"] == ["40"]
|
||||
assert result["total_duration_s"] > 0
|
||||
assert result["kernel_id"] != ""
|
||||
|
||||
# Verifica persistencia en disco
|
||||
nb = json.loads((workdir / "notebooks" / "batch.ipynb").read_text())
|
||||
assert nb["cells"][2]["execution_count"] is not None
|
||||
|
||||
|
||||
def test_e2e_run_cells_stop_on_error(jupyter_server):
|
||||
"""Verifica que stop_on_error detiene el lote en la celda con error."""
|
||||
server_url, workdir = jupyter_server
|
||||
|
||||
jx.jupyter_append_execute("notebooks/stopper.ipynb", "a = 1", server_url=server_url)
|
||||
jx.jupyter_append_execute("notebooks/stopper.ipynb", "raise ValueError('boom')", server_url=server_url)
|
||||
jx.jupyter_append_execute("notebooks/stopper.ipynb", "print('no llego')", server_url=server_url)
|
||||
|
||||
result = jrc.jupyter_run_cells(
|
||||
"notebooks/stopper.ipynb",
|
||||
[0, 1, 2],
|
||||
server_url=server_url,
|
||||
stop_on_error=True,
|
||||
)
|
||||
|
||||
assert result["stopped_at"] == 1
|
||||
assert len(result["executed"]) == 2
|
||||
assert result["executed"][1]["error"] is not None
|
||||
assert "ValueError" in result["executed"][1]["error"]
|
||||
# La celda 2 no debe aparecer en executed
|
||||
assert all(e["cell_index"] != 2 for e in result["executed"])
|
||||
Reference in New Issue
Block a user