Add JSON-safe serialization
This commit is contained in:
@@ -13,6 +13,8 @@ from .models import (
|
||||
CallOptions,
|
||||
CallResponse,
|
||||
CallResult,
|
||||
CallResultResponse,
|
||||
JSONValue,
|
||||
ShellInfo,
|
||||
ShellStatus,
|
||||
)
|
||||
@@ -32,6 +34,8 @@ __all__ = [
|
||||
"CallOptions",
|
||||
"CallResponse",
|
||||
"CallResult",
|
||||
"CallResultResponse",
|
||||
"JSONValue",
|
||||
"Shell",
|
||||
"ShellInfo",
|
||||
"ShellStatus",
|
||||
|
||||
@@ -67,7 +67,7 @@ class App:
|
||||
if shell_name == "last":
|
||||
return self.get_last_shell()
|
||||
return self.get_shell(shell_name)
|
||||
|
||||
|
||||
def run_code(
|
||||
self,
|
||||
code: str,
|
||||
@@ -80,13 +80,13 @@ class App:
|
||||
options = CallOptions()
|
||||
selected_name, shell = self._select_shell(shell_name)
|
||||
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(
|
||||
call_id=call_id,
|
||||
shell_name=selected_name,
|
||||
code=code,
|
||||
options=options,
|
||||
result=result,
|
||||
result=execution.to_response(),
|
||||
events=events,
|
||||
shell=shell.describe(),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
type JSONValue = (
|
||||
None | bool | int | float | str | list["JSONValue"] | dict[str, "JSONValue"]
|
||||
)
|
||||
|
||||
CallEventKind = Literal[
|
||||
"stdout",
|
||||
"stderr",
|
||||
@@ -13,6 +19,8 @@ CallEventKind = Literal[
|
||||
|
||||
ShellStatus = Literal["ready", "running", "error"]
|
||||
|
||||
_JSON_ADAPTER = TypeAdapter(JSONValue)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CallOptions:
|
||||
@@ -45,23 +53,54 @@ class CallError:
|
||||
|
||||
@dataclass
|
||||
class CallResult:
|
||||
"""The application-level result of one cell execution."""
|
||||
"""The raw application-level result of one cell execution."""
|
||||
|
||||
call_id: str
|
||||
execution_count: int | None
|
||||
result: object | 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
|
||||
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
|
||||
shell_name: str
|
||||
code: str
|
||||
options: CallOptions
|
||||
result: CallResult
|
||||
result: CallResultResponse
|
||||
events: list[CallEvent]
|
||||
shell: ShellInfo
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ def run_code_2(
|
||||
options: Annotated[CallOptions, Depends()],
|
||||
shell_name: Annotated[
|
||||
Literal["last", "new"] | str,
|
||||
Query("last", description="The shell to run code in"),
|
||||
Query(description="The shell to run code in"),
|
||||
] = "last",
|
||||
):
|
||||
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
|
||||
|
||||
|
||||
# should be similar to mcp/webapp.
|
||||
@tool
|
||||
def run_code(
|
||||
@@ -20,21 +21,25 @@ def run_code(
|
||||
"""
|
||||
return app.run_code(code, shell_name, options=options)
|
||||
|
||||
|
||||
@tool
|
||||
def list_shells() -> list[ShellInfo]:
|
||||
"""Return frontend-safe metadata for all known shells."""
|
||||
return app.list_shells()
|
||||
|
||||
|
||||
@tool
|
||||
def get_shell(shell_name: str | Literal["last"]) -> ShellInfo:
|
||||
"""Return frontend-safe metadata for a single shell."""
|
||||
return app.get_last_shell() if shell_name == "last" else app.get_shell(shell_name)
|
||||
|
||||
|
||||
@tool
|
||||
def create_shell() -> ShellInfo:
|
||||
"""Create a fresh named shell and return its metadata."""
|
||||
return app.create_shell()
|
||||
|
||||
|
||||
@tool
|
||||
def get_last_shell() -> ShellInfo:
|
||||
"""
|
||||
@@ -42,4 +47,4 @@ def get_last_shell() -> ShellInfo:
|
||||
|
||||
get_shell('last') is equivalent, but this is a more explicit name.
|
||||
"""
|
||||
return app.get_last_shell()
|
||||
return app.get_last_shell()
|
||||
|
||||
@@ -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