From 6315235e1652c0b5a88660c0deb7270e5c745cb8 Mon Sep 17 00:00:00 2001 From: lda Date: Sun, 30 Aug 2026 19:19:49 +0700 Subject: [PATCH] feat: expose Jupyter shell through web app --- pyproject.toml | 1 + src/ipython_shell/__init__.py | 6 +- src/ipython_shell/jupyter/__init__.py | 11 +++ .../{jupyter_app.py => jupyter/app.py} | 4 +- .../{jupyter_shell.py => jupyter/shell.py} | 0 src/ipython_shell/{ => jupyter}/transport.py | 0 src/ipython_webapp/jupyter/__init__.py | 5 ++ src/ipython_webapp/jupyter/app.py | 87 +++++++++++++++++++ tests/test_jupyter_app.py | 52 ++++++++++- tests/test_jupyter_webapp.py | 61 +++++++++++++ tests/test_transport.py | 2 +- 11 files changed, 220 insertions(+), 9 deletions(-) create mode 100644 src/ipython_shell/jupyter/__init__.py rename src/ipython_shell/{jupyter_app.py => jupyter/app.py} (96%) rename src/ipython_shell/{jupyter_shell.py => jupyter/shell.py} (100%) rename src/ipython_shell/{ => jupyter}/transport.py (100%) create mode 100644 src/ipython_webapp/jupyter/__init__.py create mode 100644 src/ipython_webapp/jupyter/app.py create mode 100644 tests/test_jupyter_webapp.py diff --git a/pyproject.toml b/pyproject.toml index 0f12d5d..09cb244 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ ipython-demo = "ipython_shell.app:main" ipython-shell = "ipython_shell.app:main" ipython-webapp = "ipython_webapp.app:main" +ipython-jupyter-webapp = "ipython_webapp.jupyter.app:main" ipython-mcp = "ipython_mcp.app:main" [build-system] diff --git a/src/ipython_shell/__init__.py b/src/ipython_shell/__init__.py index 8cf4763..5b9c675 100644 --- a/src/ipython_shell/__init__.py +++ b/src/ipython_shell/__init__.py @@ -1,8 +1,8 @@ """Small, side-effect-free API for the in-process IPython runner.""" from .app import App -from .jupyter_app import JupyterApp -from .jupyter_shell import JupyterShell +from .jupyter.app import JupyterApp +from .jupyter.shell import JupyterShell from .events import ( error_from_execution_result, event_from_execution_result, @@ -28,7 +28,7 @@ from .shell import ( run_cell_and_collect, setup_shell, ) -from .transport import InputRequest, JupyterTransport, KernelMessage +from .jupyter.transport import InputRequest, JupyterTransport, KernelMessage from .utils import generate_good_names __all__ = [ diff --git a/src/ipython_shell/jupyter/__init__.py b/src/ipython_shell/jupyter/__init__.py new file mode 100644 index 0000000..3fe577c --- /dev/null +++ b/src/ipython_shell/jupyter/__init__.py @@ -0,0 +1,11 @@ +from .app import JupyterApp +from .shell import JupyterShell +from .transport import InputRequest, JupyterTransport, KernelMessage + +__all__ = [ + "InputRequest", + "JupyterApp", + "JupyterShell", + "JupyterTransport", + "KernelMessage", +] diff --git a/src/ipython_shell/jupyter_app.py b/src/ipython_shell/jupyter/app.py similarity index 96% rename from src/ipython_shell/jupyter_app.py rename to src/ipython_shell/jupyter/app.py index 35e3caf..9f0471a 100644 --- a/src/ipython_shell/jupyter_app.py +++ b/src/ipython_shell/jupyter/app.py @@ -1,9 +1,9 @@ from collections.abc import AsyncIterator from uuid import uuid4 -from .jupyter_shell import JupyterShell +from .shell import JupyterShell from .transport import InputRequest, KernelMessage -from .utils import generate_good_names +from ..utils import generate_good_names class JupyterApp: diff --git a/src/ipython_shell/jupyter_shell.py b/src/ipython_shell/jupyter/shell.py similarity index 100% rename from src/ipython_shell/jupyter_shell.py rename to src/ipython_shell/jupyter/shell.py diff --git a/src/ipython_shell/transport.py b/src/ipython_shell/jupyter/transport.py similarity index 100% rename from src/ipython_shell/transport.py rename to src/ipython_shell/jupyter/transport.py diff --git a/src/ipython_webapp/jupyter/__init__.py b/src/ipython_webapp/jupyter/__init__.py new file mode 100644 index 0000000..5aabd2c --- /dev/null +++ b/src/ipython_webapp/jupyter/__init__.py @@ -0,0 +1,5 @@ +"""FastAPI adapter for the subprocess-backed Jupyter shell app.""" + +from .app import InputReply, app, serialize_event, shell_app + +__all__ = ["InputReply", "app", "serialize_event", "shell_app"] diff --git a/src/ipython_webapp/jupyter/app.py b/src/ipython_webapp/jupyter/app.py new file mode 100644 index 0000000..a0c07d4 --- /dev/null +++ b/src/ipython_webapp/jupyter/app.py @@ -0,0 +1,87 @@ +import base64 +import json +from collections.abc import AsyncIterator +from typing import Annotated + +from fastapi import Body, FastAPI, HTTPException, Path, Response +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +from ipython_shell.jupyter import InputRequest, JupyterApp, KernelMessage + +app = FastAPI(title="IPython Jupyter Web App", version="0.1.0") +shell_app = JupyterApp() + + +class InputReply(BaseModel): + """Value supplied by a client answering a kernel input request.""" + + value: str + + +def serialize_event(event: KernelMessage | InputRequest) -> dict[str, object]: + """Turn a shell event into JSON-safe data for a web client.""" + if isinstance(event, InputRequest): + return { + "type": "input_request", + "call_id": event.call_id, + "prompt": event.prompt, + "password": event.password, + } + + # Jupyter keeps binary MIME payloads in message buffers. Base64 makes + # those buffers safe to carry in the same NDJSON stream as text events. + return { + "type": "kernel_message", + "msg_type": event.msg_type, + "parent_id": event.parent_id, + "content": event.content, + "buffers": [base64.b64encode(buffer).decode("ascii") for buffer in event.buffers], + } + + +async def _stream_events(code: str, shell_name: str) -> AsyncIterator[str]: + """Encode one execution's events as newline-delimited JSON.""" + async for event in shell_app.run_code_stream(code, shell_name): + yield json.dumps(serialize_event(event), ensure_ascii=False) + "\n" + + +@app.post("/shells/{shell_name}/run") +async def run_code( + shell_name: Annotated[ + str, + Path(..., description="The Jupyter shell to run code in"), + ], + code: Annotated[ + str, + Body(..., description="The code to execute", media_type="text/plain"), + ], +) -> StreamingResponse: + """Run code and stream each Jupyter event as one JSON line.""" + return StreamingResponse( + _stream_events(code, shell_name), + media_type="application/x-ndjson", + ) + + +@app.post("/calls/{call_id}/input", status_code=204) +async def reply_to_input(call_id: str, reply: InputReply) -> Response: + """Answer an input request belonging to a currently running call.""" + try: + await shell_app.reply_to_input(call_id, reply.value) + except KeyError as error: + raise HTTPException(status_code=404, detail=str(error)) from error + except RuntimeError as error: + raise HTTPException(status_code=409, detail=str(error)) from error + return Response(status_code=204) + + +def main() -> None: + """Run the Jupyter-backed HTTP API with Uvicorn.""" + import uvicorn + + uvicorn.run(app, host="::", port=8001, log_level="info") + + +if __name__ == "__main__": + main() diff --git a/tests/test_jupyter_app.py b/tests/test_jupyter_app.py index f54f2d9..5379d3c 100644 --- a/tests/test_jupyter_app.py +++ b/tests/test_jupyter_app.py @@ -1,9 +1,9 @@ import asyncio import unittest -from ipython_shell.jupyter_app import JupyterApp -from ipython_shell.jupyter_shell import JupyterShell -from ipython_shell.transport import InputRequest, KernelMessage +from ipython_shell.jupyter.app import JupyterApp +from ipython_shell.jupyter.shell import JupyterShell +from ipython_shell.jupyter.transport import InputRequest, KernelMessage class JupyterShellTests(unittest.IsolatedAsyncioTestCase): @@ -48,6 +48,52 @@ class JupyterShellTests(unittest.IsolatedAsyncioTestCase): class JupyterAppTests(unittest.IsolatedAsyncioTestCase): + async def test_named_shells_have_isolated_namespaces(self): + app = JupyterApp() + try: + _alpha_setup = [ + message + async for message in app.run_code_stream( + "alpha_only = 'alpha'", + shell_name="alpha", + ) + ] + beta_view = [ + message + async for message in app.run_code_stream( + "'alpha_only' in globals()", + shell_name="beta", + ) + ] + alpha_view = [ + message + async for message in app.run_code_stream( + "alpha_only", + shell_name="alpha", + ) + ] + finally: + await app.shutdown() + + self.assertEqual( + next( + message.content["data"]["text/plain"] + for message in beta_view + if isinstance(message, KernelMessage) + and message.msg_type == "execute_result" + ), + "False", + ) + self.assertEqual( + next( + message.content["data"]["text/plain"] + for message in alpha_view + if isinstance(message, KernelMessage) + and message.msg_type == "execute_result" + ), + "'alpha'", + ) + async def test_app_routes_input_reply_to_the_call(self): app = JupyterApp() stream = app.run_code_stream( diff --git a/tests/test_jupyter_webapp.py b/tests/test_jupyter_webapp.py new file mode 100644 index 0000000..5d4520d --- /dev/null +++ b/tests/test_jupyter_webapp.py @@ -0,0 +1,61 @@ +import json +import unittest + +import httpx + +from ipython_webapp.jupyter.app import ( + InputReply, + app, + reply_to_input, + run_code, + shell_app, +) + + +class JupyterWebAppTests(unittest.IsolatedAsyncioTestCase): + async def test_run_endpoint_streams_json_events_with_mime_data(self): + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + ) as client: + try: + response = await client.post( + "/shells/new/run", + content="{'answer': 42}", + headers={"content-type": "text/plain"}, + ) + finally: + await shell_app.shutdown() + + self.assertEqual(response.status_code, 200) + self.assertTrue( + response.headers["content-type"].startswith("application/x-ndjson") + ) + events = [json.loads(line) for line in response.text.splitlines()] + result = next(event for event in events if event["msg_type"] == "execute_result") + self.assertEqual(result["content"]["data"]["text/plain"], "{'answer': 42}") + + async def test_input_request_can_be_replied_to_while_run_streams(self): + try: + response = await run_code("new", "answer = input('name? '); answer") + self.assertEqual(response.media_type, "application/x-ndjson") + events = [] + async for line in response.body_iterator: + event = json.loads(line) + events.append(event) + if event["type"] == "input_request": + reply = await reply_to_input( + event["call_id"], + InputReply(value="Ada"), + ) + self.assertEqual(reply.status_code, 204) + finally: + await shell_app.shutdown() + + result = next(event for event in events if event.get("msg_type") == "execute_result") + self.assertEqual(result["content"]["data"]["text/plain"], "'Ada'") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_transport.py b/tests/test_transport.py index 78d1b23..64ce8e8 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -1,7 +1,7 @@ import asyncio import unittest -from ipython_shell.transport import InputRequest, JupyterTransport, KernelMessage +from ipython_shell.jupyter.transport import InputRequest, JupyterTransport, KernelMessage class TransportTests(unittest.TestCase):