Compare commits
3
Commits
ba49cae434
...
d9ef330beb
No files matched your search
@@ -3,8 +3,7 @@
|
|||||||
The application-facing API is:
|
The application-facing API is:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from ipython_demo import App
|
from ipython_shell import App, CallOptions
|
||||||
from ipython_demo import CallOptions
|
|
||||||
|
|
||||||
app = App()
|
app = App()
|
||||||
call = app.run_code(
|
call = app.run_code(
|
||||||
|
|||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -7,8 +7,8 @@ from .events import (
|
|||||||
event_from_history_output,
|
event_from_history_output,
|
||||||
)
|
)
|
||||||
from .jupyter.app import JupyterApp
|
from .jupyter.app import JupyterApp
|
||||||
from .jupyter.shell import JupyterShell
|
|
||||||
from .jupyter.messages import JupyterMessage, KernelMessage, ParsedJupyterMessage
|
from .jupyter.messages import JupyterMessage, KernelMessage, ParsedJupyterMessage
|
||||||
|
from .jupyter.shell import JupyterShell
|
||||||
from .jupyter.transport import InputRequest, JupyterTransport
|
from .jupyter.transport import InputRequest, JupyterTransport
|
||||||
from .models import (
|
from .models import (
|
||||||
AppInfo,
|
AppInfo,
|
||||||
@@ -46,9 +46,9 @@ __all__ = [
|
|||||||
"InputRequest",
|
"InputRequest",
|
||||||
"JSONValue",
|
"JSONValue",
|
||||||
"JupyterApp",
|
"JupyterApp",
|
||||||
|
"JupyterMessage",
|
||||||
"JupyterShell",
|
"JupyterShell",
|
||||||
"JupyterTransport",
|
"JupyterTransport",
|
||||||
"JupyterMessage",
|
|
||||||
"KernelMessage",
|
"KernelMessage",
|
||||||
"ParsedJupyterMessage",
|
"ParsedJupyterMessage",
|
||||||
"Shell",
|
"Shell",
|
||||||
|
|||||||
@@ -18,17 +18,17 @@ from .shell import JupyterShell
|
|||||||
from .transport import InputRequest, JupyterTransport
|
from .transport import InputRequest, JupyterTransport
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"InputRequest",
|
|
||||||
"DisplayDataMessage",
|
"DisplayDataMessage",
|
||||||
"ErrorMessage",
|
"ErrorMessage",
|
||||||
"ExecuteReplyMessage",
|
"ExecuteReplyMessage",
|
||||||
"ExecuteResultMessage",
|
"ExecuteResultMessage",
|
||||||
|
"InputRequest",
|
||||||
"InputRequestMessage",
|
"InputRequestMessage",
|
||||||
"JupyterApp",
|
"JupyterApp",
|
||||||
"JupyterMessage",
|
"JupyterMessage",
|
||||||
"KernelMessage",
|
|
||||||
"JupyterShell",
|
"JupyterShell",
|
||||||
"JupyterTransport",
|
"JupyterTransport",
|
||||||
|
"KernelMessage",
|
||||||
"KnownJupyterMessage",
|
"KnownJupyterMessage",
|
||||||
"ParsedJupyterMessage",
|
"ParsedJupyterMessage",
|
||||||
"StatusMessage",
|
"StatusMessage",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from time import monotonic
|
from time import monotonic
|
||||||
@@ -97,6 +98,10 @@ class JupyterApp:
|
|||||||
|
|
||||||
async def shutdown(self) -> None:
|
async def shutdown(self) -> None:
|
||||||
"""Stop all kernel processes owned by this app."""
|
"""Stop all kernel processes owned by this app."""
|
||||||
awaitables = [shell.shutdown() for shell in self.shells.values()]
|
results = await asyncio.gather(
|
||||||
for awaitable in awaitables:
|
*(shell.shutdown() for shell in self.shells.values()),
|
||||||
await awaitable
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
errors = [result for result in results if isinstance(result, BaseException)]
|
||||||
|
if errors:
|
||||||
|
raise BaseExceptionGroup("Kernel shutdown failed", errors)
|
||||||
@@ -1,21 +1,20 @@
|
|||||||
from typing import Annotated, ClassVar, Final, Literal
|
from typing import Annotated, ClassVar, Final, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
|
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
|
||||||
|
|
||||||
|
|
||||||
type JupyterChannel = Literal["iopub", "shell", "stdin"]
|
type JupyterChannel = Literal["iopub", "shell", "stdin"]
|
||||||
type MessageStatus = Literal["ok", "error", "abort"]
|
type MessageStatus = Literal["ok", "error", "abort"]
|
||||||
|
|
||||||
STREAM: Final[Literal["stream"]] = "stream"
|
STREAM: Final = "stream"
|
||||||
DISPLAY_DATA: Final[Literal["display_data"]] = "display_data"
|
DISPLAY_DATA: Final = "display_data"
|
||||||
UPDATE_DISPLAY_DATA: Final[Literal["update_display_data"]] = "update_display_data"
|
UPDATE_DISPLAY_DATA: Final = "update_display_data"
|
||||||
EXECUTE_RESULT: Final[Literal["execute_result"]] = "execute_result"
|
EXECUTE_RESULT: Final = "execute_result"
|
||||||
ERROR: Final[Literal["error"]] = "error"
|
ERROR: Final = "error"
|
||||||
STATUS: Final[Literal["status"]] = "status"
|
STATUS: Final = "status"
|
||||||
EXECUTE_INPUT: Final[Literal["execute_input"]] = "execute_input"
|
EXECUTE_INPUT: Final = "execute_input"
|
||||||
CLEAR_OUTPUT: Final[Literal["clear_output"]] = "clear_output"
|
CLEAR_OUTPUT: Final = "clear_output"
|
||||||
EXECUTE_REPLY: Final[Literal["execute_reply"]] = "execute_reply"
|
EXECUTE_REPLY: Final = "execute_reply"
|
||||||
INPUT_REQUEST: Final[Literal["input_request"]] = "input_request"
|
INPUT_REQUEST: Final = "input_request"
|
||||||
|
|
||||||
|
|
||||||
class MimeContent(BaseModel):
|
class MimeContent(BaseModel):
|
||||||
@@ -176,6 +175,9 @@ class InputRequestMessage(_TypedMessage):
|
|||||||
class UnknownJupyterMessage(JupyterMessage):
|
class UnknownJupyterMessage(JupyterMessage):
|
||||||
"""An extension or future message preserved without lossy parsing."""
|
"""An extension or future message preserved without lossy parsing."""
|
||||||
|
|
||||||
|
# Allow any channel value so known-but-malformed fallbacks never fail
|
||||||
|
# on a strict Literal. The parser preserves the transport channel verbatim.
|
||||||
|
channel: str = "iopub" # type: ignore[assignment]
|
||||||
raw: dict[str, object] = Field(default_factory=dict)
|
raw: dict[str, object] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@@ -252,7 +254,14 @@ def parse_jupyter_message(
|
|||||||
|
|
||||||
if values["msg_type"] not in _KNOWN_MESSAGE_TYPES:
|
if values["msg_type"] not in _KNOWN_MESSAGE_TYPES:
|
||||||
return UnknownJupyterMessage.model_validate({**values, "raw": raw})
|
return UnknownJupyterMessage.model_validate({**values, "raw": raw})
|
||||||
return _KNOWN_MESSAGE_ADAPTER.validate_python(values)
|
try:
|
||||||
|
return _KNOWN_MESSAGE_ADAPTER.validate_python(values)
|
||||||
|
except ValidationError:
|
||||||
|
# Known msg_type but content or channel doesn't match the strict
|
||||||
|
# model (e.g., extra execution_state, wrong stdout/stderr name,
|
||||||
|
# or msg delivered on an unexpected channel). Degrade losslessly
|
||||||
|
# instead of aborting the whole call.
|
||||||
|
return UnknownJupyterMessage.model_validate({**values, "raw": raw})
|
||||||
|
|
||||||
|
|
||||||
# Existing callers use this name for the transport-level message. Keep it as
|
# Existing callers use this name for the transport-level message. Keep it as
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ from .messages import (
|
|||||||
ExecuteReplyMessage,
|
ExecuteReplyMessage,
|
||||||
InputRequestMessage,
|
InputRequestMessage,
|
||||||
JupyterChannel,
|
JupyterChannel,
|
||||||
JupyterMessage,
|
|
||||||
KernelMessage,
|
|
||||||
ParsedJupyterMessage,
|
ParsedJupyterMessage,
|
||||||
StatusMessage,
|
StatusMessage,
|
||||||
parse_jupyter_message,
|
parse_jupyter_message,
|
||||||
@@ -48,6 +46,7 @@ class JupyterTransport:
|
|||||||
asyncio.Queue[tuple[JupyterChannel, dict[str, Any] | BaseException]] | None
|
asyncio.Queue[tuple[JupyterChannel, dict[str, Any] | BaseException]] | None
|
||||||
) = None
|
) = None
|
||||||
self._reader_tasks: set[asyncio.Task[None]] = set()
|
self._reader_tasks: set[asyncio.Task[None]] = set()
|
||||||
|
self._reader_failure: BaseException | None = None
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the kernel and its persistent channel readers."""
|
"""Start the kernel and its persistent channel readers."""
|
||||||
@@ -100,6 +99,7 @@ class JupyterTransport:
|
|||||||
raise
|
raise
|
||||||
except BaseException as error:
|
except BaseException as error:
|
||||||
# Surface reader failures instead of leaving messages_for() stuck.
|
# Surface reader failures instead of leaving messages_for() stuck.
|
||||||
|
self._reader_failure = error
|
||||||
await self._message_queue.put((channel, error))
|
await self._message_queue.put((channel, error))
|
||||||
|
|
||||||
async def _stop_readers(self) -> None:
|
async def _stop_readers(self) -> None:
|
||||||
@@ -113,6 +113,10 @@ class JupyterTransport:
|
|||||||
|
|
||||||
async def execute(self, code: str) -> str:
|
async def execute(self, code: str) -> str:
|
||||||
"""Submit one cell and return its Jupyter message ID."""
|
"""Submit one cell and return its Jupyter message ID."""
|
||||||
|
if self._reader_failure is not None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"JupyterTransport channel reader failed"
|
||||||
|
) from self._reader_failure
|
||||||
if self.client is None:
|
if self.client is None:
|
||||||
raise RuntimeError("JupyterTransport has not been started")
|
raise RuntimeError("JupyterTransport has not been started")
|
||||||
|
|
||||||
@@ -126,6 +130,10 @@ class JupyterTransport:
|
|||||||
self, call_id: str
|
self, call_id: str
|
||||||
) -> AsyncIterator[ParsedJupyterMessage | InputRequest]:
|
) -> AsyncIterator[ParsedJupyterMessage | InputRequest]:
|
||||||
"""Yield decoded output, input, and completion messages for one call."""
|
"""Yield decoded output, input, and completion messages for one call."""
|
||||||
|
if self._reader_failure is not None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"JupyterTransport channel reader failed"
|
||||||
|
) from self._reader_failure
|
||||||
if self.client is None:
|
if self.client is None:
|
||||||
raise RuntimeError("JupyterTransport has not been started")
|
raise RuntimeError("JupyterTransport has not been started")
|
||||||
if self._active_call != call_id:
|
if self._active_call != call_id:
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ class Shell:
|
|||||||
else:
|
else:
|
||||||
self.status = "ready"
|
self.status = "ready"
|
||||||
self.last_used_at = datetime.now(UTC).isoformat()
|
self.last_used_at = datetime.now(UTC).isoformat()
|
||||||
self._last_execution_count = call_result.execution_count or 0
|
if call_result.execution_count is not None:
|
||||||
|
self._last_execution_count = call_result.execution_count
|
||||||
return call_result, events
|
return call_result, events
|
||||||
|
|
||||||
def describe(self) -> ShellInfo:
|
def describe(self) -> ShellInfo:
|
||||||
|
|||||||
+29
-5
@@ -10,8 +10,7 @@ from pathlib import Path
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from ipython_shell.jupyter import InputRequest, JupyterApp, ParsedJupyterMessage
|
from ipython_shell.jupyter import InputRequest, JupyterApp, ParsedJupyterMessage
|
||||||
|
from st_demo.ui import event_to_record, latex_body, preferred_mime
|
||||||
from st_demo.ui import event_to_record, preferred_mime
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -28,7 +27,9 @@ class _CallFailed:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class _BackgroundCall:
|
class _BackgroundCall:
|
||||||
events: queue.Queue[ParsedJupyterMessage | InputRequest | _CallFinished | _CallFailed]
|
events: queue.Queue[
|
||||||
|
ParsedJupyterMessage | InputRequest | _CallFinished | _CallFailed
|
||||||
|
]
|
||||||
task: Future[None] | None = None
|
task: Future[None] | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -174,7 +175,7 @@ def _render_mime(data: dict[str, object]) -> None:
|
|||||||
elif mime == "application/javascript":
|
elif mime == "application/javascript":
|
||||||
st.code(str(value), language="javascript")
|
st.code(str(value), language="javascript")
|
||||||
elif mime == "text/latex":
|
elif mime == "text/latex":
|
||||||
st.latex(str(value))
|
st.latex(latex_body(value))
|
||||||
else:
|
else:
|
||||||
st.code(str(value), language="text")
|
st.code(str(value), language="text")
|
||||||
|
|
||||||
@@ -227,6 +228,17 @@ def _render_active_call(controller: ShellController) -> None:
|
|||||||
events, finished, failed = controller.poll(handle)
|
events, finished, failed = controller.poll(handle)
|
||||||
records = call_records.setdefault(handle, [])
|
records = call_records.setdefault(handle, [])
|
||||||
records.extend(event_to_record(event) for event in events)
|
records.extend(event_to_record(event) for event in events)
|
||||||
|
# Restore persisted failure from a previous poll where the sentinel
|
||||||
|
# was already drained. Without this, the next poll returns
|
||||||
|
# failed=None, finished=False and shows "Running…" forever.
|
||||||
|
if failed is None:
|
||||||
|
failures = st.session_state.get("call_failures")
|
||||||
|
if isinstance(failures, dict):
|
||||||
|
cached = failures.get(handle)
|
||||||
|
if isinstance(cached, BaseException):
|
||||||
|
failed = cached # type: ignore[assignment]
|
||||||
|
elif isinstance(cached, Exception):
|
||||||
|
failed = cached
|
||||||
|
|
||||||
for record in visible_records(call_records):
|
for record in visible_records(call_records):
|
||||||
_render_record(record)
|
_render_record(record)
|
||||||
@@ -249,7 +261,17 @@ def _render_active_call(controller: ShellController) -> None:
|
|||||||
controller.reply_to_input(handle, call_id, value)
|
controller.reply_to_input(handle, call_id, value)
|
||||||
st.rerun(scope="fragment")
|
st.rerun(scope="fragment")
|
||||||
elif failed is not None:
|
elif failed is not None:
|
||||||
|
failures = st.session_state.get("call_failures")
|
||||||
|
if not isinstance(failures, dict):
|
||||||
|
failures = {}
|
||||||
|
st.session_state.call_failures = failures
|
||||||
|
failures[handle] = failed
|
||||||
|
# Persist in call history so the error remains visible after clearing.
|
||||||
|
error_text = f"{type(failed).__name__}: {failed}"
|
||||||
|
if not records or records[-1].get("text") != error_text:
|
||||||
|
records.append({"kind": "error", "text": error_text})
|
||||||
st.exception(failed)
|
st.exception(failed)
|
||||||
|
st.session_state.active_handle = None
|
||||||
elif not finished:
|
elif not finished:
|
||||||
st.caption("Running…")
|
st.caption("Running…")
|
||||||
else:
|
else:
|
||||||
@@ -276,7 +298,9 @@ def render() -> None:
|
|||||||
value="import matplotlib.pyplot as plt\nplt.plot([1, 2, 3], [4, 5, 6])",
|
value="import matplotlib.pyplot as plt\nplt.plot([1, 2, 3], [4, 5, 6])",
|
||||||
height=180,
|
height=180,
|
||||||
)
|
)
|
||||||
if st.button("Run", type="primary"):
|
active_handle = st.session_state.get("active_handle")
|
||||||
|
is_running = isinstance(active_handle, str)
|
||||||
|
if st.button("Run", type="primary", disabled=is_running):
|
||||||
handle = controller.start_call(code, shell_name)
|
handle = controller.start_call(code, shell_name)
|
||||||
st.session_state.active_handle = handle
|
st.session_state.active_handle = handle
|
||||||
st.session_state.call_records = getattr(
|
st.session_state.call_records = getattr(
|
||||||
|
|||||||
+15
-1
@@ -13,7 +13,6 @@ from ipython_shell.jupyter.messages import (
|
|||||||
)
|
)
|
||||||
from ipython_shell.jupyter.transport import InputRequest
|
from ipython_shell.jupyter.transport import InputRequest
|
||||||
|
|
||||||
|
|
||||||
MIME_PRIORITY: Final[tuple[str, ...]] = (
|
MIME_PRIORITY: Final[tuple[str, ...]] = (
|
||||||
"text/html",
|
"text/html",
|
||||||
"image/svg+xml",
|
"image/svg+xml",
|
||||||
@@ -34,6 +33,21 @@ def preferred_mime(data: Mapping[str, object]) -> tuple[str, object] | None:
|
|||||||
return next(iter(data.items()), None)
|
return next(iter(data.items()), None)
|
||||||
|
|
||||||
|
|
||||||
|
def latex_body(value: object) -> str:
|
||||||
|
"""Adapt common Jupyter LaTeX payloads to Streamlit's ``st.latex`` API.
|
||||||
|
|
||||||
|
IPython's LaTeX formatter commonly returns ``$...$`` while Streamlit
|
||||||
|
supplies its own delimiters around the value. Remove only the outer
|
||||||
|
delimiters so they are not rendered as literal dollar signs.
|
||||||
|
"""
|
||||||
|
body = str(value).strip()
|
||||||
|
delimiters = (("$$", "$$"), ("$", "$"), (r"\[", r"\]"), (r"\(", r"\)"))
|
||||||
|
for opening, closing in delimiters:
|
||||||
|
if body.startswith(opening) and body.endswith(closing):
|
||||||
|
return body[len(opening) : -len(closing)].strip()
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
def _content_record(content: object) -> dict[str, object]:
|
def _content_record(content: object) -> dict[str, object]:
|
||||||
if isinstance(content, BaseModel):
|
if isinstance(content, BaseModel):
|
||||||
return content.model_dump(mode="json")
|
return content.model_dump(mode="json")
|
||||||
|
|||||||
+1
-1
@@ -2,10 +2,10 @@ import unittest
|
|||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from ipython_agent.ipython_wrapper import get_app_info
|
||||||
from ipython_mcp.app import app as mcp_app
|
from ipython_mcp.app import app as mcp_app
|
||||||
from ipython_shell import App, AppInfo
|
from ipython_shell import App, AppInfo
|
||||||
from ipython_webapp.app import app as web_app
|
from ipython_webapp.app import app as web_app
|
||||||
from langchain_demo.ipython_wrapper import get_app_info
|
|
||||||
|
|
||||||
|
|
||||||
class AppInfoTests(unittest.IsolatedAsyncioTestCase):
|
class AppInfoTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import re
|
|||||||
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.messages import ExecuteResultMessage
|
from ipython_shell.jupyter.messages import ExecuteResultMessage
|
||||||
|
from ipython_shell.jupyter.shell import JupyterShell
|
||||||
from ipython_shell.jupyter.transport import InputRequest
|
from ipython_shell.jupyter.transport import InputRequest
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+19
-1
@@ -8,7 +8,7 @@ from ipython_shell.jupyter.messages import (
|
|||||||
UnknownJupyterMessage,
|
UnknownJupyterMessage,
|
||||||
)
|
)
|
||||||
from st_demo.app import visible_records
|
from st_demo.app import visible_records
|
||||||
from st_demo.ui import event_to_record, preferred_mime
|
from st_demo.ui import event_to_record, latex_body, preferred_mime
|
||||||
|
|
||||||
|
|
||||||
class UIDisplayTests(unittest.TestCase):
|
class UIDisplayTests(unittest.TestCase):
|
||||||
@@ -44,6 +44,24 @@ class UIDisplayTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual((mime, value), ("text/html", "<b>rich</b>"))
|
self.assertEqual((mime, value), ("text/html", "<b>rich</b>"))
|
||||||
|
|
||||||
|
def test_preferred_mime_chooses_latex_before_plain_text(self):
|
||||||
|
latex = r"\\int_0^1 x^2\\,dx = \\frac{1}{3}"
|
||||||
|
|
||||||
|
mime, value = preferred_mime(
|
||||||
|
{
|
||||||
|
"text/plain": "integral",
|
||||||
|
"text/latex": latex,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual((mime, value), ("text/latex", latex))
|
||||||
|
|
||||||
|
def test_latex_body_unwraps_ipython_math_delimiters(self):
|
||||||
|
self.assertEqual(
|
||||||
|
latex_body(r"$\displaystyle x^{2} - x - 6$"),
|
||||||
|
r"\displaystyle x^{2} - x - 6",
|
||||||
|
)
|
||||||
|
|
||||||
def test_execute_result_record_keeps_mime_and_repr(self):
|
def test_execute_result_record_keeps_mime_and_repr(self):
|
||||||
event = ExecuteResultMessage(
|
event = ExecuteResultMessage(
|
||||||
message_id="message-1",
|
message_id="message-1",
|
||||||
|
|||||||
@@ -211,12 +211,10 @@ class AsyncTransportTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
await transport.start()
|
await transport.start()
|
||||||
try:
|
try:
|
||||||
call_id = await transport.execute(
|
call_id = await transport.execute(
|
||||||
"import matplotlib.pyplot as plt\n"
|
"import matplotlib.pyplot as plt\nplt.plot([1, 2, 3], [4, 5, 6])"
|
||||||
"plt.plot([1, 2, 3], [4, 5, 6])"
|
|
||||||
)
|
)
|
||||||
messages = [
|
messages = [
|
||||||
message
|
message async for message in transport.messages_for(call_id)
|
||||||
async for message in transport.messages_for(call_id)
|
|
||||||
]
|
]
|
||||||
finally:
|
finally:
|
||||||
await transport.shutdown()
|
await transport.shutdown()
|
||||||
|
|||||||
Reference in new issue
Block a user