diff --git a/pyproject.toml b/pyproject.toml index 2a96fc1..f4c3c7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,6 @@ dependencies = [ "ipython>=9.16.1", "matplotlib>=3.11.1", "matplotlib-inline>=0.1.7", - "sympy>=1.14.0", "wonderwords>=2.2.0", ] diff --git a/src/ipython_demo/__init__.py b/src/ipython_demo/__init__.py index 065bcab..66731bb 100644 --- a/src/ipython_demo/__init__.py +++ b/src/ipython_demo/__init__.py @@ -6,7 +6,15 @@ from .events import ( event_from_execution_result, event_from_history_output, ) -from .models import CallError, CallEvent, CallEventKind, CallResponse, CallResult +from .models import ( + CallError, + CallEvent, + CallEventKind, + CallResponse, + CallResult, + ShellInfo, + ShellStatus, +) from .shell import ( Shell, isolate_output_history, @@ -26,6 +34,8 @@ __all__ = [ "Shell", "event_from_history_output", "generate_good_names", + "ShellInfo", + "ShellStatus", "isolate_output_history", "run_cell_and_collect", "setup_shell", diff --git a/src/ipython_demo/app.py b/src/ipython_demo/app.py index 7eac574..cbf3f99 100644 --- a/src/ipython_demo/app.py +++ b/src/ipython_demo/app.py @@ -6,7 +6,7 @@ from uuid import uuid4 from wonderwords import RandomWord -from .models import CallResponse +from .models import CallResponse, ShellInfo from .shell import Shell @@ -50,6 +50,10 @@ class App: self.last_shell = shell_name return shell_name, self.shells[shell_name] + def list_shells(self) -> list[ShellInfo]: + """Return frontend-safe metadata for all known shells.""" + return [shell.describe() for shell in self.shells.values()] + def run_code( self, code: str, @@ -68,6 +72,7 @@ class App: collapsed=collapsed, result=result, events=events, + shell=shell.describe(), ) diff --git a/src/ipython_demo/models.py b/src/ipython_demo/models.py index 06b23a8..196034a 100644 --- a/src/ipython_demo/models.py +++ b/src/ipython_demo/models.py @@ -12,6 +12,8 @@ CallEventKind = Literal[ "error", ] +ShellStatus = Literal["ready", "running", "error"] + @dataclass class CallEvent: @@ -55,3 +57,17 @@ class CallResponse: collapsed: bool result: CallResult events: list[CallEvent] + shell: ShellInfo + + +@dataclass +class ShellInfo: + """Frontend-safe snapshot describing one persistent shell.""" + + shell_id: str + name: str + status: ShellStatus + execution_count: int + created_at: str + last_used_at: str | None + capabilities: tuple[str, ...] diff --git a/src/ipython_demo/shell.py b/src/ipython_demo/shell.py index e822e8a..0ca88d8 100644 --- a/src/ipython_demo/shell.py +++ b/src/ipython_demo/shell.py @@ -1,4 +1,6 @@ from collections import defaultdict +from datetime import datetime, timezone +from uuid import uuid4 from IPython.core.interactiveshell import InteractiveShell from IPython.core.pylabtools import ( @@ -14,7 +16,7 @@ from .events import ( event_from_execution_result, event_from_history_output, ) -from .models import CallEvent, CallResult +from .models import CallEvent, CallResult, ShellInfo, ShellStatus class Shell: @@ -22,6 +24,11 @@ class Shell: def __init__(self, name: str): self.name = name + self.shell_id = uuid4().hex + self.created_at = datetime.now(timezone.utc).isoformat() + self.last_used_at: str | None = None + self.status: ShellStatus = "ready" + self._last_execution_count = 0 self._shell: InteractiveShell | None = None @property @@ -45,7 +52,40 @@ class Shell: call_id: str, ) -> tuple[CallResult, list[CallEvent]]: """Execute one cell and return its result plus ordered output events.""" - return run_cell_and_collect(self.init_shell(), code, call_id=call_id) + self.status = "running" + try: + call_result, events = run_cell_and_collect( + self.init_shell(), + code, + call_id=call_id, + ) + except Exception: + self.status = "error" + raise + else: + self.status = "ready" + self.last_used_at = datetime.now(timezone.utc).isoformat() + self._last_execution_count = call_result.execution_count or 0 + return call_result, events + + def describe(self) -> ShellInfo: + """Return metadata without initializing an unused shell.""" + return ShellInfo( + shell_id=self.shell_id, + name=self.name, + status=self.status, + execution_count=self._last_execution_count, + created_at=self.created_at, + last_used_at=self.last_used_at, + capabilities=( + "stdout", + "stderr", + "display_data", + "execute_result", + "error", + "matplotlib-inline", + ), + ) def isolate_output_history(shell: InteractiveShell) -> None: @@ -76,7 +116,7 @@ def run_cell_and_collect( execution = shell.run_cell(code, store_history=True) records = tuple( history_manager.outputs.get(execution.execution_count, ()) - ) + ) if execution.execution_count is not None else () events = [ event_from_history_output( record, diff --git a/tests/test_app.py b/tests/test_app.py index c441b0c..131aa40 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -73,6 +73,20 @@ class AppTests(unittest.TestCase): self.assertNotEqual(first.call_id, second.call_id) self.assertEqual(second.result.result, 42) + def test_response_and_shell_list_expose_shell_info(self): + app = App() + + with contextlib.redirect_stdout(io.StringIO()): + call = app.run_code("1 + 1", shell_name="new") + + shells = app.list_shells() + + self.assertEqual(len(shells), 1) + self.assertEqual(call.shell.shell_id, shells[0].shell_id) + self.assertEqual(call.shell.name, call.shell_name) + self.assertEqual(call.shell.execution_count, call.result.execution_count) + self.assertIn("execute_result", call.shell.capabilities) + if __name__ == "__main__": unittest.main()