From 6a8546cb0f9438398cba223bb060f02d71bf8778 Mon Sep 17 00:00:00 2001 From: lda Date: Sun, 30 Aug 2026 22:49:34 +0700 Subject: [PATCH] shared shell registry --- src/ipython_shell/__init__.py | 6 ++- src/ipython_shell/app.py | 60 ++++++++++----------- src/ipython_shell/jupyter/app.py | 84 ++++++++++++++++++++---------- src/ipython_shell/jupyter/shell.py | 46 +++++++++++++++- src/ipython_shell/registry.py | 66 +++++++++++++++++++++++ src/ipython_shell/utils.py | 35 +++++++++++-- tests/test_app.py | 8 +++ tests/test_jupyter_app.py | 25 ++++++++- 8 files changed, 262 insertions(+), 68 deletions(-) create mode 100644 src/ipython_shell/registry.py diff --git a/src/ipython_shell/__init__.py b/src/ipython_shell/__init__.py index 26bdcb2..8d666b4 100644 --- a/src/ipython_shell/__init__.py +++ b/src/ipython_shell/__init__.py @@ -22,6 +22,7 @@ from .models import ( ShellInfo, ShellStatus, ) +from .registry import ShellRegistry from .shell import ( Shell, carefully_setup_shell, @@ -29,7 +30,7 @@ from .shell import ( run_cell_and_collect, setup_shell, ) -from .utils import generate_good_names +from .utils import generate_call_id, generate_good_names, generate_omfg_names __all__ = [ "App", @@ -49,12 +50,15 @@ __all__ = [ "KernelMessage", "Shell", "ShellInfo", + "ShellRegistry", "ShellStatus", "carefully_setup_shell", "error_from_execution_result", "event_from_execution_result", "event_from_history_output", + "generate_call_id", "generate_good_names", + "generate_omfg_names", "isolate_output_history", "run_cell_and_collect", "setup_shell", diff --git a/src/ipython_shell/app.py b/src/ipython_shell/app.py index 26a383c..3801fc0 100644 --- a/src/ipython_shell/app.py +++ b/src/ipython_shell/app.py @@ -7,9 +7,10 @@ from time import monotonic from typing import Literal from uuid import uuid4 -from ipython_shell.utils import generate_good_names +from ipython_shell.utils import generate_call_id from .models import AppInfo, CallOptions, CallResponse, ShellInfo +from .registry import ShellRegistry from .shell import Shell @@ -21,52 +22,45 @@ class App: self.created_at = datetime.now(UTC).isoformat() self._started_at = monotonic() self._total_calls = 0 - self.shells: dict[str, Shell] = {} - self.last_shell: str | None = None + self._shell_registry = ShellRegistry(Shell) + self._issued_call_ids: set[str] = set() - def _new_shell(self) -> tuple[str, Shell]: - name = generate_good_names() - while name in self.shells: - name = generate_good_names() - shell = Shell(name) - self.shells[name] = shell - self.last_shell = name - return name, shell + @property + def shells(self) -> dict[str, Shell]: + """Expose the managed shells for compatibility with the old API.""" + return self._shell_registry.shells + + @property + def last_shell(self) -> str | None: + """Return the name of the most recently selected shell.""" + return self._shell_registry.last_shell def _select_shell(self, shell_name: str) -> tuple[str, Shell]: - if shell_name == "new": - return self._new_shell() - - if shell_name == "last": - if self.last_shell is None: - return self._new_shell() - return self.last_shell, self.shells[self.last_shell] - - if shell_name not in self.shells: - self.shells[shell_name] = Shell(shell_name) - self.last_shell = shell_name - return shell_name, self.shells[shell_name] + return self._shell_registry.select(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()] + return self._shell_registry.list_shells() def create_shell(self) -> ShellInfo: """Create a fresh named shell and return its metadata.""" - _, shell = self._new_shell() - return shell.describe() + return self._shell_registry.create_shell() def get_shell(self, shell_name: str) -> ShellInfo: """Return frontend-safe metadata for a single shell.""" - if shell_name not in self.shells: - raise KeyError(f"Shell {shell_name} does not exist") - return self.shells[shell_name].describe() + return self._shell_registry.get_shell(shell_name) def get_last_shell(self) -> ShellInfo: """Return metadata for the most recently selected shell.""" - if self.last_shell is None: - raise KeyError("No shell exists yet") - return self.get_shell(self.last_shell) + return self._shell_registry.get_last_shell() + + def _new_call_id(self) -> str: + """Generate a readable call ID that is unique for this app.""" + call_id = generate_call_id() + while call_id in self._issued_call_ids: + call_id = generate_call_id() + self._issued_call_ids.add(call_id) + return call_id def info(self) -> AppInfo: """Return runtime metadata without initializing or inspecting shells.""" @@ -96,7 +90,7 @@ class App: if options is None: options = CallOptions() selected_name, shell = self._select_shell(shell_name) - call_id = uuid4().hex + call_id = self._new_call_id() self._total_calls += 1 execution, events = shell.run_cell(code, call_id=call_id) return CallResponse( diff --git a/src/ipython_shell/jupyter/app.py b/src/ipython_shell/jupyter/app.py index a0f20d8..ad888f4 100644 --- a/src/ipython_shell/jupyter/app.py +++ b/src/ipython_shell/jupyter/app.py @@ -1,7 +1,11 @@ from collections.abc import AsyncIterator +from datetime import UTC, datetime +from time import monotonic from uuid import uuid4 -from ..utils import generate_good_names +from ..models import AppInfo, ShellInfo +from ..registry import ShellRegistry +from ..utils import generate_call_id from .shell import JupyterShell from .transport import InputRequest, KernelMessage @@ -10,36 +14,61 @@ class JupyterApp: """Coordinate named persistent shells backed by Jupyter kernels.""" def __init__(self) -> None: - self.shells: dict[str, JupyterShell] = {} - self.last_shell: str | None = None + self.app_id = uuid4().hex + self.created_at = datetime.now(UTC).isoformat() + self._started_at = monotonic() + self._total_calls = 0 + self._shell_registry = ShellRegistry(JupyterShell) self._calls: dict[str, JupyterShell] = {} + self._issued_call_ids: set[str] = set() - def _new_shell(self) -> tuple[str, JupyterShell]: - name = generate_good_names() - while name in self.shells: - name = generate_good_names() - shell = JupyterShell(name) - self.shells[name] = shell - self.last_shell = name - return name, shell + @property + def shells(self) -> dict[str, JupyterShell]: + """Expose the managed shells for compatibility with the sync app.""" + return self._shell_registry.shells + + @property + def last_shell(self) -> str | None: + """Return the name of the most recently selected shell.""" + return self._shell_registry.last_shell def _select_shell(self, shell_name: str) -> tuple[str, JupyterShell]: - if shell_name == "new": - return self._new_shell() - if shell_name == "last": - if self.last_shell is None: - return self._new_shell() - return self.last_shell, self.shells[self.last_shell] - if shell_name not in self.shells: - self.shells[shell_name] = JupyterShell(shell_name) - self.last_shell = shell_name - return shell_name, self.shells[shell_name] + return self._shell_registry.select(shell_name) - def get_shell(self, shell_name: str) -> JupyterShell: - """Return a named Jupyter shell.""" - if shell_name not in self.shells: - raise KeyError(f"Shell {shell_name} does not exist") - return self.shells[shell_name] + def list_shells(self) -> list[ShellInfo]: + """Return metadata for every known Jupyter shell.""" + return self._shell_registry.list_shells() + + def create_shell(self) -> ShellInfo: + """Create a fresh Jupyter shell and return its metadata.""" + return self._shell_registry.create_shell() + + def get_shell(self, shell_name: str) -> ShellInfo: + """Return frontend-safe metadata for one Jupyter shell.""" + return self._shell_registry.get_shell(shell_name) + + def get_last_shell(self) -> ShellInfo: + """Return metadata for the most recently selected Jupyter shell.""" + return self._shell_registry.get_last_shell() + + def info(self) -> AppInfo: + """Return runtime metadata without starting any kernels.""" + return AppInfo( + app_id=self.app_id, + created_at=self.created_at, + uptime_seconds=monotonic() - self._started_at, + shell_count=len(self.shells), + last_shell=self.last_shell, + total_calls=self._total_calls, + ) + + def _new_call_id(self) -> str: + """Generate a readable call ID that is unique for this app.""" + call_id = generate_call_id() + while call_id in self._issued_call_ids: + call_id = generate_call_id() + self._issued_call_ids.add(call_id) + return call_id async def run_code_stream( self, @@ -48,7 +77,8 @@ class JupyterApp: ) -> AsyncIterator[KernelMessage | InputRequest]: """Execute code and yield its subprocess messages.""" _, shell = self._select_shell(shell_name) - call_id = uuid4().hex + call_id = self._new_call_id() + self._total_calls += 1 self._calls[call_id] = shell try: async for message in shell.run_cell_stream(code, call_id=call_id): diff --git a/src/ipython_shell/jupyter/shell.py b/src/ipython_shell/jupyter/shell.py index ac2a100..20cc4f9 100644 --- a/src/ipython_shell/jupyter/shell.py +++ b/src/ipython_shell/jupyter/shell.py @@ -1,6 +1,9 @@ from collections.abc import AsyncIterator from dataclasses import replace +from datetime import UTC, datetime +from uuid import uuid4 +from ..models import ShellInfo, ShellStatus from .transport import InputRequest, JupyterTransport, KernelMessage @@ -9,6 +12,11 @@ class JupyterShell: def __init__(self, name: str, transport: JupyterTransport | None = None) -> None: self.name = name + self.shell_id = uuid4().hex + self.created_at = datetime.now(UTC).isoformat() + self.last_used_at: str | None = None + self.status: ShellStatus = "ready" + self._last_execution_count = 0 self.transport = transport or JupyterTransport() self._active_call_id: str | None = None @@ -26,6 +34,7 @@ class JupyterShell: """Yield kernel messages while one cell executes.""" await self.start() self._active_call_id = call_id + self.status = "running" try: kernel_call_id = await self.transport.execute(code) async for message in self.transport.messages_for(kernel_call_id): @@ -33,10 +42,25 @@ class JupyterShell: yield replace(message, call_id=call_id) else: # Keep the kernel's execution ID private to the transport. - yield replace(message, parent_id=call_id) + public_message = replace(message, parent_id=call_id) + if public_message.msg_type == "execute_reply": + self._last_execution_count = int( + public_message.content.get( + "execution_count", self._last_execution_count + ) + ) + self.status = ( + "error" + if public_message.content.get("status") == "error" + else "ready" + ) + self.last_used_at = datetime.now(UTC).isoformat() + yield public_message finally: if self._active_call_id == call_id: self._active_call_id = None + if self.status == "running" and self._active_call_id is None: + self.status = "ready" async def reply_to_input(self, call_id: str, value: str) -> None: """Send input to the cell currently waiting in this shell.""" @@ -47,3 +71,23 @@ class JupyterShell: async def shutdown(self) -> None: """Stop this shell's kernel process.""" await self.transport.shutdown() + + def describe(self) -> ShellInfo: + """Return metadata without starting an unused kernel.""" + 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", + "input", + ), + ) diff --git a/src/ipython_shell/registry.py b/src/ipython_shell/registry.py new file mode 100644 index 0000000..8d2607b --- /dev/null +++ b/src/ipython_shell/registry.py @@ -0,0 +1,66 @@ +from collections.abc import Callable +from typing import Protocol + +from .models import ShellInfo +from .utils import generate_good_names + + +class DescribableShell(Protocol): + """The small shell interface needed by the shared registry.""" + + def describe(self) -> ShellInfo: ... + + +class ShellRegistry[ShellT: DescribableShell]: + """Own named shells and resolve ``new``, ``last``, and explicit names.""" + + def __init__(self, factory: Callable[[str], ShellT]) -> None: + self._factory = factory + self.shells: dict[str, ShellT] = {} + self.last_shell: str | None = None + + def new_shell(self) -> tuple[str, ShellT]: + """Create a fresh generated shell and select it.""" + name = generate_good_names() + while name in self.shells: + name = generate_good_names() + shell = self._factory(name) + self.shells[name] = shell + self.last_shell = name + return name, shell + + def select(self, shell_name: str) -> tuple[str, ShellT]: + """Resolve a selector, creating explicit names when necessary.""" + if shell_name == "new": + return self.new_shell() + if shell_name == "last": + if self.last_shell is None: + return self.new_shell() + return self.last_shell, self.shells[self.last_shell] + if shell_name not in self.shells: + self.shells[shell_name] = self._factory(shell_name) + self.last_shell = shell_name + return shell_name, self.shells[shell_name] + + def list_shells(self) -> list[ShellInfo]: + """Return metadata for every known shell.""" + return [shell.describe() for shell in self.shells.values()] + + def create_shell(self) -> ShellInfo: + """Create a fresh generated shell and return its metadata.""" + _, shell = self.new_shell() + return shell.describe() + + def get_shell(self, shell_name: str) -> ShellInfo: + """Return metadata for one existing shell.""" + try: + shell = self.shells[shell_name] + except KeyError as error: + raise KeyError(f"Shell {shell_name} does not exist") from error + return shell.describe() + + def get_last_shell(self) -> ShellInfo: + """Return metadata for the most recently selected shell.""" + if self.last_shell is None: + raise KeyError("No shell exists yet") + return self.get_shell(self.last_shell) diff --git a/src/ipython_shell/utils.py b/src/ipython_shell/utils.py index a54b9a8..041538a 100644 --- a/src/ipython_shell/utils.py +++ b/src/ipython_shell/utils.py @@ -29,17 +29,42 @@ def generate_good_names() -> str: type WordCategory = Literal["adjectives", "nouns", "verbs"] type WordCategory1 = Literal["adjective", "noun", "verb"] +type WordCategoryShort = Literal["adj", "n", "v"] +type NamePart = WordCategory | WordCategory1 | WordCategoryShort | int + +_CATEGORY_NAMES: dict[ + WordCategory | WordCategory1 | WordCategoryShort, WordCategory +] = { + "adjectives": "adjectives", + "adjective": "adjectives", + "adj": "adjectives", + "nouns": "nouns", + "noun": "nouns", + "n": "nouns", + "verbs": "verbs", + "verb": "verbs", + "v": "verbs", +} -def generate_omfg_names(uhh: Sequence[WordCategory | WordCategory1 | int]) -> str: +def generate_omfg_names(recipe: Sequence[NamePart]) -> str: """Generate a readable, collision-resistant shell name. - Example: generate_omfg_names(["adjectives", "nouns", 4]) -> "happy-dog-1a2b" + Example: generate_omfg_names(["adj", "n", "v", 4]) -> "happy-dog-run-1a2b" """ words: list[str] = [] - for category in uhh: + for category in recipe: if isinstance(category, int): - words.append(secrets.token_hex((category + 1) // 2)[:category]) # what? + if category < 1: + raise ValueError("Hex suffix length must be positive") + words.append(secrets.token_hex((category + 1) // 2)[:category]) else: - words.append(_WORD_GENERATOR.word(include_categories=[category])) + words.append( + _WORD_GENERATOR.word(include_categories=[_CATEGORY_NAMES[category]]) + ) return "-".join(words) + + +def generate_call_id() -> str: + """Generate a readable application-level identifier for one call.""" + return generate_omfg_names(["adj", "n", "v", 4]) diff --git a/tests/test_app.py b/tests/test_app.py index 90379f2..b6a514a 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -49,6 +49,14 @@ class AppTests(unittest.TestCase): ], ) + def test_call_ids_are_readable(self): + app = App() + + with contextlib.redirect_stdout(io.StringIO()): + call = app.run_code("1 + 1", shell_name="new") + + self.assertRegex(call.call_id, r"^[a-z]+-[a-z]+-[a-z]+-[0-9a-f]{4}$") + def test_last_reuses_shell_but_each_call_has_new_id(self): app = App() diff --git a/tests/test_jupyter_app.py b/tests/test_jupyter_app.py index 5379d3c..1be3b2a 100644 --- a/tests/test_jupyter_app.py +++ b/tests/test_jupyter_app.py @@ -1,4 +1,5 @@ import asyncio +import re import unittest from ipython_shell.jupyter.app import JupyterApp @@ -48,6 +49,25 @@ class JupyterShellTests(unittest.IsolatedAsyncioTestCase): class JupyterAppTests(unittest.IsolatedAsyncioTestCase): + async def test_app_exposes_shared_shell_metadata_api(self): + app = JupyterApp() + try: + created = app.create_shell() + listed = app.list_shells() + last = app.get_last_shell() + selected = app.get_shell(created.name) + info = app.info() + finally: + await app.shutdown() + + self.assertEqual(len(listed), 1) + self.assertEqual(created.shell_id, listed[0].shell_id) + self.assertEqual(created.shell_id, last.shell_id) + self.assertEqual(created.shell_id, selected.shell_id) + self.assertEqual(created.execution_count, 0) + self.assertEqual(info.shell_count, 1) + self.assertEqual(info.last_shell, created.name) + async def test_named_shells_have_isolated_namespaces(self): app = JupyterApp() try: @@ -110,7 +130,10 @@ class JupyterAppTests(unittest.IsolatedAsyncioTestCase): self.assertIsInstance(input_request, InputRequest) assert isinstance(input_request, InputRequest) - self.assertTrue(input_request.call_id) + self.assertRegex( + input_request.call_id, + re.compile(r"^[a-z]+-[a-z]+-[a-z]+-[0-9a-f]{4}$"), + ) self.assertEqual(input_request.prompt, "name? ") await app.reply_to_input(input_request.call_id, "Ada")