feat: expose Jupyter shell through web app
This commit is contained in:
@@ -23,6 +23,7 @@ dependencies = [
|
|||||||
ipython-demo = "ipython_shell.app:main"
|
ipython-demo = "ipython_shell.app:main"
|
||||||
ipython-shell = "ipython_shell.app:main"
|
ipython-shell = "ipython_shell.app:main"
|
||||||
ipython-webapp = "ipython_webapp.app:main"
|
ipython-webapp = "ipython_webapp.app:main"
|
||||||
|
ipython-jupyter-webapp = "ipython_webapp.jupyter.app:main"
|
||||||
ipython-mcp = "ipython_mcp.app:main"
|
ipython-mcp = "ipython_mcp.app:main"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Small, side-effect-free API for the in-process IPython runner."""
|
"""Small, side-effect-free API for the in-process IPython runner."""
|
||||||
|
|
||||||
from .app import App
|
from .app import App
|
||||||
from .jupyter_app import JupyterApp
|
from .jupyter.app import JupyterApp
|
||||||
from .jupyter_shell import JupyterShell
|
from .jupyter.shell import JupyterShell
|
||||||
from .events import (
|
from .events import (
|
||||||
error_from_execution_result,
|
error_from_execution_result,
|
||||||
event_from_execution_result,
|
event_from_execution_result,
|
||||||
@@ -28,7 +28,7 @@ from .shell import (
|
|||||||
run_cell_and_collect,
|
run_cell_and_collect,
|
||||||
setup_shell,
|
setup_shell,
|
||||||
)
|
)
|
||||||
from .transport import InputRequest, JupyterTransport, KernelMessage
|
from .jupyter.transport import InputRequest, JupyterTransport, KernelMessage
|
||||||
from .utils import generate_good_names
|
from .utils import generate_good_names
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from .app import JupyterApp
|
||||||
|
from .shell import JupyterShell
|
||||||
|
from .transport import InputRequest, JupyterTransport, KernelMessage
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"InputRequest",
|
||||||
|
"JupyterApp",
|
||||||
|
"JupyterShell",
|
||||||
|
"JupyterTransport",
|
||||||
|
"KernelMessage",
|
||||||
|
]
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from .jupyter_shell import JupyterShell
|
from .shell import JupyterShell
|
||||||
from .transport import InputRequest, KernelMessage
|
from .transport import InputRequest, KernelMessage
|
||||||
from .utils import generate_good_names
|
from ..utils import generate_good_names
|
||||||
|
|
||||||
|
|
||||||
class JupyterApp:
|
class JupyterApp:
|
||||||
@@ -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"]
|
||||||
@@ -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()
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from ipython_shell.jupyter_app import JupyterApp
|
from ipython_shell.jupyter.app import JupyterApp
|
||||||
from ipython_shell.jupyter_shell import JupyterShell
|
from ipython_shell.jupyter.shell import JupyterShell
|
||||||
from ipython_shell.transport import InputRequest, KernelMessage
|
from ipython_shell.jupyter.transport import InputRequest, KernelMessage
|
||||||
|
|
||||||
|
|
||||||
class JupyterShellTests(unittest.IsolatedAsyncioTestCase):
|
class JupyterShellTests(unittest.IsolatedAsyncioTestCase):
|
||||||
@@ -48,6 +48,52 @@ class JupyterShellTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class JupyterAppTests(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):
|
async def test_app_routes_input_reply_to_the_call(self):
|
||||||
app = JupyterApp()
|
app = JupyterApp()
|
||||||
stream = app.run_code_stream(
|
stream = app.run_code_stream(
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from ipython_shell.transport import InputRequest, JupyterTransport, KernelMessage
|
from ipython_shell.jupyter.transport import InputRequest, JupyterTransport, KernelMessage
|
||||||
|
|
||||||
|
|
||||||
class TransportTests(unittest.TestCase):
|
class TransportTests(unittest.TestCase):
|
||||||
|
|||||||
Reference in New Issue
Block a user