Add JSON-safe serialization
This commit is contained in:
@@ -13,6 +13,8 @@ from .models import (
|
|||||||
CallOptions,
|
CallOptions,
|
||||||
CallResponse,
|
CallResponse,
|
||||||
CallResult,
|
CallResult,
|
||||||
|
CallResultResponse,
|
||||||
|
JSONValue,
|
||||||
ShellInfo,
|
ShellInfo,
|
||||||
ShellStatus,
|
ShellStatus,
|
||||||
)
|
)
|
||||||
@@ -32,6 +34,8 @@ __all__ = [
|
|||||||
"CallOptions",
|
"CallOptions",
|
||||||
"CallResponse",
|
"CallResponse",
|
||||||
"CallResult",
|
"CallResult",
|
||||||
|
"CallResultResponse",
|
||||||
|
"JSONValue",
|
||||||
"Shell",
|
"Shell",
|
||||||
"ShellInfo",
|
"ShellInfo",
|
||||||
"ShellStatus",
|
"ShellStatus",
|
||||||
|
|||||||
@@ -80,13 +80,13 @@ class App:
|
|||||||
options = CallOptions()
|
options = CallOptions()
|
||||||
selected_name, shell = self._select_shell(shell_name)
|
selected_name, shell = self._select_shell(shell_name)
|
||||||
call_id = uuid4().hex
|
call_id = uuid4().hex
|
||||||
result, events = shell.run_cell(code, call_id=call_id)
|
execution, events = shell.run_cell(code, call_id=call_id)
|
||||||
return CallResponse(
|
return CallResponse(
|
||||||
call_id=call_id,
|
call_id=call_id,
|
||||||
shell_name=selected_name,
|
shell_name=selected_name,
|
||||||
code=code,
|
code=code,
|
||||||
options=options,
|
options=options,
|
||||||
result=result,
|
result=execution.to_response(),
|
||||||
events=events,
|
events=events,
|
||||||
shell=shell.describe(),
|
shell=shell.describe(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import TypeAdapter, ValidationError
|
||||||
|
|
||||||
|
type JSONValue = (
|
||||||
|
None | bool | int | float | str | list["JSONValue"] | dict[str, "JSONValue"]
|
||||||
|
)
|
||||||
|
|
||||||
CallEventKind = Literal[
|
CallEventKind = Literal[
|
||||||
"stdout",
|
"stdout",
|
||||||
"stderr",
|
"stderr",
|
||||||
@@ -13,6 +19,8 @@ CallEventKind = Literal[
|
|||||||
|
|
||||||
ShellStatus = Literal["ready", "running", "error"]
|
ShellStatus = Literal["ready", "running", "error"]
|
||||||
|
|
||||||
|
_JSON_ADAPTER = TypeAdapter(JSONValue)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CallOptions:
|
class CallOptions:
|
||||||
@@ -45,23 +53,54 @@ class CallError:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CallResult:
|
class CallResult:
|
||||||
"""The application-level result of one cell execution."""
|
"""The raw application-level result of one cell execution."""
|
||||||
|
|
||||||
call_id: str
|
call_id: str
|
||||||
execution_count: int | None
|
execution_count: int | None
|
||||||
result: object | None
|
result: object | None
|
||||||
error: CallError | None = None
|
error: CallError | None = None
|
||||||
|
|
||||||
|
def to_response(self) -> CallResultResponse:
|
||||||
|
"""Return a JSON-safe DTO without changing the raw execution result."""
|
||||||
|
try:
|
||||||
|
result = _JSON_ADAPTER.validate_python(self.result)
|
||||||
|
except ValidationError:
|
||||||
|
return CallResultResponse(
|
||||||
|
call_id=self.call_id,
|
||||||
|
execution_count=self.execution_count,
|
||||||
|
result=None,
|
||||||
|
result_repr=repr(self.result),
|
||||||
|
error=self.error,
|
||||||
|
)
|
||||||
|
|
||||||
|
return CallResultResponse(
|
||||||
|
call_id=self.call_id,
|
||||||
|
execution_count=self.execution_count,
|
||||||
|
result=result,
|
||||||
|
error=self.error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CallResultResponse:
|
||||||
|
"""JSON-safe representation of a :class:`CallResult`."""
|
||||||
|
|
||||||
|
call_id: str
|
||||||
|
execution_count: int | None
|
||||||
|
result: JSONValue = None
|
||||||
|
result_repr: str | None = None
|
||||||
|
error: CallError | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CallResponse:
|
class CallResponse:
|
||||||
"""Complete response envelope intended for a UI or graph node."""
|
"""JSON-safe response envelope intended for a UI or graph node."""
|
||||||
|
|
||||||
call_id: str
|
call_id: str
|
||||||
shell_name: str
|
shell_name: str
|
||||||
code: str
|
code: str
|
||||||
options: CallOptions
|
options: CallOptions
|
||||||
result: CallResult
|
result: CallResultResponse
|
||||||
events: list[CallEvent]
|
events: list[CallEvent]
|
||||||
shell: ShellInfo
|
shell: ShellInfo
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ def run_code_2(
|
|||||||
options: Annotated[CallOptions, Depends()],
|
options: Annotated[CallOptions, Depends()],
|
||||||
shell_name: Annotated[
|
shell_name: Annotated[
|
||||||
Literal["last", "new"] | str,
|
Literal["last", "new"] | str,
|
||||||
Query("last", description="The shell to run code in"),
|
Query(description="The shell to run code in"),
|
||||||
] = "last",
|
] = "last",
|
||||||
):
|
):
|
||||||
return ShellApp.run_code(code, shell_name, options=options)
|
return ShellApp.run_code(code, shell_name, options=options)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from ipython_demo.models import CallOptions, CallResponse, ShellInfo
|
|||||||
|
|
||||||
app = App() # global, again
|
app = App() # global, again
|
||||||
|
|
||||||
|
|
||||||
# should be similar to mcp/webapp.
|
# should be similar to mcp/webapp.
|
||||||
@tool
|
@tool
|
||||||
def run_code(
|
def run_code(
|
||||||
@@ -20,21 +21,25 @@ def run_code(
|
|||||||
"""
|
"""
|
||||||
return app.run_code(code, shell_name, options=options)
|
return app.run_code(code, shell_name, options=options)
|
||||||
|
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
def list_shells() -> list[ShellInfo]:
|
def list_shells() -> list[ShellInfo]:
|
||||||
"""Return frontend-safe metadata for all known shells."""
|
"""Return frontend-safe metadata for all known shells."""
|
||||||
return app.list_shells()
|
return app.list_shells()
|
||||||
|
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
def get_shell(shell_name: str | Literal["last"]) -> ShellInfo:
|
def get_shell(shell_name: str | Literal["last"]) -> ShellInfo:
|
||||||
"""Return frontend-safe metadata for a single shell."""
|
"""Return frontend-safe metadata for a single shell."""
|
||||||
return app.get_last_shell() if shell_name == "last" else app.get_shell(shell_name)
|
return app.get_last_shell() if shell_name == "last" else app.get_shell(shell_name)
|
||||||
|
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
def create_shell() -> ShellInfo:
|
def create_shell() -> ShellInfo:
|
||||||
"""Create a fresh named shell and return its metadata."""
|
"""Create a fresh named shell and return its metadata."""
|
||||||
return app.create_shell()
|
return app.create_shell()
|
||||||
|
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
def get_last_shell() -> ShellInfo:
|
def get_last_shell() -> ShellInfo:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from ipython_demo import App, Shell
|
||||||
|
from ipython_webapp.app import app as web_app
|
||||||
|
|
||||||
|
|
||||||
|
class SerializationTests(unittest.TestCase):
|
||||||
|
def test_call_result_stays_raw_until_to_response(self):
|
||||||
|
shell = Shell("raw-result")
|
||||||
|
|
||||||
|
with contextlib.redirect_stdout(io.StringIO()):
|
||||||
|
result, _events = shell.run_cell(
|
||||||
|
"plt.plot([1, 2, 3], [4, 5, 6])",
|
||||||
|
call_id="raw-call",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("Line2D", repr(result.result))
|
||||||
|
response = result.to_response()
|
||||||
|
self.assertIsNone(response.result)
|
||||||
|
self.assertIn("Line2D", response.result_repr)
|
||||||
|
|
||||||
|
def test_matplotlib_result_is_safe_for_web_serialization(self):
|
||||||
|
app = App()
|
||||||
|
|
||||||
|
with contextlib.redirect_stdout(io.StringIO()):
|
||||||
|
response = app.run_code(
|
||||||
|
"plt.plot([1, 2, 3], [4, 5, 6])",
|
||||||
|
shell_name="new",
|
||||||
|
)
|
||||||
|
|
||||||
|
encoded = jsonable_encoder(response)
|
||||||
|
|
||||||
|
self.assertIsNone(response.result.result)
|
||||||
|
self.assertIn("Line2D", response.result.result_repr)
|
||||||
|
self.assertIsNone(encoded["result"]["result"])
|
||||||
|
self.assertIn("Line2D", encoded["result"]["result_repr"])
|
||||||
|
|
||||||
|
def test_plot_route_returns_dto_instead_of_recursion_error(self):
|
||||||
|
response = TestClient(web_app).post(
|
||||||
|
"/shells/http-plot/run",
|
||||||
|
content="plt.plot([1, 2, 3], [4, 5, 6])",
|
||||||
|
headers={"content-type": "text/plain"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertIsNone(payload["result"]["result"])
|
||||||
|
self.assertIn("Line2D", payload["result"]["result_repr"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user