Add JSON-safe serialization

This commit is contained in:
lda
2026-08-30 07:08:24 +07:00 Verified
parent acec75cf7e
commit acd412a50a
6 changed files with 113 additions and 8 deletions
+4
View File
@@ -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",
+2 -2
View File
@@ -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(),
) )
+42 -3
View File
@@ -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
+1 -1
View File
@@ -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)
+5
View File
@@ -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:
""" """
+57
View File
@@ -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()