From acd412a50a73f66475f56d92c88e4a00be952fc5 Mon Sep 17 00:00:00 2001 From: lda Date: Sun, 30 Aug 2026 07:08:24 +0700 Subject: [PATCH] Add JSON-safe serialization --- src/ipython_demo/__init__.py | 4 ++ src/ipython_demo/app.py | 6 +-- src/ipython_demo/models.py | 45 +++++++++++++++++++-- src/ipython_webapp/app.py | 2 +- src/langchain_demo/ipython_wrapper.py | 7 +++- tests/test_serialization.py | 57 +++++++++++++++++++++++++++ 6 files changed, 113 insertions(+), 8 deletions(-) create mode 100644 tests/test_serialization.py diff --git a/src/ipython_demo/__init__.py b/src/ipython_demo/__init__.py index 6ab1e8c..e05f8a6 100644 --- a/src/ipython_demo/__init__.py +++ b/src/ipython_demo/__init__.py @@ -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", diff --git a/src/ipython_demo/app.py b/src/ipython_demo/app.py index 284567e..9f9ab4e 100644 --- a/src/ipython_demo/app.py +++ b/src/ipython_demo/app.py @@ -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(), ) diff --git a/src/ipython_demo/models.py b/src/ipython_demo/models.py index 03082c4..c634479 100644 --- a/src/ipython_demo/models.py +++ b/src/ipython_demo/models.py @@ -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 diff --git a/src/ipython_webapp/app.py b/src/ipython_webapp/app.py index 5fc9e71..27632a2 100644 --- a/src/ipython_webapp/app.py +++ b/src/ipython_webapp/app.py @@ -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) diff --git a/src/langchain_demo/ipython_wrapper.py b/src/langchain_demo/ipython_wrapper.py index 1dbb85d..c94c420 100644 --- a/src/langchain_demo/ipython_wrapper.py +++ b/src/langchain_demo/ipython_wrapper.py @@ -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() \ No newline at end of file + return app.get_last_shell() diff --git a/tests/test_serialization.py b/tests/test_serialization.py new file mode 100644 index 0000000..4f16c22 --- /dev/null +++ b/tests/test_serialization.py @@ -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()