shared shell registry

This commit is contained in:
lda
2026-08-30 22:49:34 +07:00 Verified
parent 0b87620852
commit 6a8546cb0f
8 changed files with 262 additions and 68 deletions
+5 -1
View File
@@ -22,6 +22,7 @@ from .models import (
ShellInfo, ShellInfo,
ShellStatus, ShellStatus,
) )
from .registry import ShellRegistry
from .shell import ( from .shell import (
Shell, Shell,
carefully_setup_shell, carefully_setup_shell,
@@ -29,7 +30,7 @@ from .shell import (
run_cell_and_collect, run_cell_and_collect,
setup_shell, setup_shell,
) )
from .utils import generate_good_names from .utils import generate_call_id, generate_good_names, generate_omfg_names
__all__ = [ __all__ = [
"App", "App",
@@ -49,12 +50,15 @@ __all__ = [
"KernelMessage", "KernelMessage",
"Shell", "Shell",
"ShellInfo", "ShellInfo",
"ShellRegistry",
"ShellStatus", "ShellStatus",
"carefully_setup_shell", "carefully_setup_shell",
"error_from_execution_result", "error_from_execution_result",
"event_from_execution_result", "event_from_execution_result",
"event_from_history_output", "event_from_history_output",
"generate_call_id",
"generate_good_names", "generate_good_names",
"generate_omfg_names",
"isolate_output_history", "isolate_output_history",
"run_cell_and_collect", "run_cell_and_collect",
"setup_shell", "setup_shell",
+27 -33
View File
@@ -7,9 +7,10 @@ from time import monotonic
from typing import Literal from typing import Literal
from uuid import uuid4 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 .models import AppInfo, CallOptions, CallResponse, ShellInfo
from .registry import ShellRegistry
from .shell import Shell from .shell import Shell
@@ -21,52 +22,45 @@ class App:
self.created_at = datetime.now(UTC).isoformat() self.created_at = datetime.now(UTC).isoformat()
self._started_at = monotonic() self._started_at = monotonic()
self._total_calls = 0 self._total_calls = 0
self.shells: dict[str, Shell] = {} self._shell_registry = ShellRegistry(Shell)
self.last_shell: str | None = None self._issued_call_ids: set[str] = set()
def _new_shell(self) -> tuple[str, Shell]: @property
name = generate_good_names() def shells(self) -> dict[str, Shell]:
while name in self.shells: """Expose the managed shells for compatibility with the old API."""
name = generate_good_names() return self._shell_registry.shells
shell = Shell(name)
self.shells[name] = shell @property
self.last_shell = name def last_shell(self) -> str | None:
return name, shell """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]: def _select_shell(self, shell_name: str) -> tuple[str, Shell]:
if shell_name == "new": return self._shell_registry.select(shell_name)
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]
def list_shells(self) -> list[ShellInfo]: def list_shells(self) -> list[ShellInfo]:
"""Return frontend-safe metadata for all known shells.""" """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: def create_shell(self) -> ShellInfo:
"""Create a fresh named shell and return its metadata.""" """Create a fresh named shell and return its metadata."""
_, shell = self._new_shell() return self._shell_registry.create_shell()
return shell.describe()
def get_shell(self, shell_name: str) -> ShellInfo: def get_shell(self, shell_name: str) -> ShellInfo:
"""Return frontend-safe metadata for a single shell.""" """Return frontend-safe metadata for a single shell."""
if shell_name not in self.shells: return self._shell_registry.get_shell(shell_name)
raise KeyError(f"Shell {shell_name} does not exist")
return self.shells[shell_name].describe()
def get_last_shell(self) -> ShellInfo: def get_last_shell(self) -> ShellInfo:
"""Return metadata for the most recently selected shell.""" """Return metadata for the most recently selected shell."""
if self.last_shell is None: return self._shell_registry.get_last_shell()
raise KeyError("No shell exists yet")
return self.get_shell(self.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: def info(self) -> AppInfo:
"""Return runtime metadata without initializing or inspecting shells.""" """Return runtime metadata without initializing or inspecting shells."""
@@ -96,7 +90,7 @@ class App:
if options is None: if options is None:
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 = self._new_call_id()
self._total_calls += 1 self._total_calls += 1
execution, events = shell.run_cell(code, call_id=call_id) execution, events = shell.run_cell(code, call_id=call_id)
return CallResponse( return CallResponse(
+57 -27
View File
@@ -1,7 +1,11 @@
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from datetime import UTC, datetime
from time import monotonic
from uuid import uuid4 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 .shell import JupyterShell
from .transport import InputRequest, KernelMessage from .transport import InputRequest, KernelMessage
@@ -10,36 +14,61 @@ class JupyterApp:
"""Coordinate named persistent shells backed by Jupyter kernels.""" """Coordinate named persistent shells backed by Jupyter kernels."""
def __init__(self) -> None: def __init__(self) -> None:
self.shells: dict[str, JupyterShell] = {} self.app_id = uuid4().hex
self.last_shell: str | None = None 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._calls: dict[str, JupyterShell] = {}
self._issued_call_ids: set[str] = set()
def _new_shell(self) -> tuple[str, JupyterShell]: @property
name = generate_good_names() def shells(self) -> dict[str, JupyterShell]:
while name in self.shells: """Expose the managed shells for compatibility with the sync app."""
name = generate_good_names() return self._shell_registry.shells
shell = JupyterShell(name)
self.shells[name] = shell @property
self.last_shell = name def last_shell(self) -> str | None:
return name, shell """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]: def _select_shell(self, shell_name: str) -> tuple[str, JupyterShell]:
if shell_name == "new": return self._shell_registry.select(shell_name)
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]
def get_shell(self, shell_name: str) -> JupyterShell: def list_shells(self) -> list[ShellInfo]:
"""Return a named Jupyter shell.""" """Return metadata for every known Jupyter shell."""
if shell_name not in self.shells: return self._shell_registry.list_shells()
raise KeyError(f"Shell {shell_name} does not exist")
return self.shells[shell_name] 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( async def run_code_stream(
self, self,
@@ -48,7 +77,8 @@ class JupyterApp:
) -> AsyncIterator[KernelMessage | InputRequest]: ) -> AsyncIterator[KernelMessage | InputRequest]:
"""Execute code and yield its subprocess messages.""" """Execute code and yield its subprocess messages."""
_, shell = self._select_shell(shell_name) _, 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 self._calls[call_id] = shell
try: try:
async for message in shell.run_cell_stream(code, call_id=call_id): async for message in shell.run_cell_stream(code, call_id=call_id):
+45 -1
View File
@@ -1,6 +1,9 @@
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from dataclasses import replace from dataclasses import replace
from datetime import UTC, datetime
from uuid import uuid4
from ..models import ShellInfo, ShellStatus
from .transport import InputRequest, JupyterTransport, KernelMessage from .transport import InputRequest, JupyterTransport, KernelMessage
@@ -9,6 +12,11 @@ class JupyterShell:
def __init__(self, name: str, transport: JupyterTransport | None = None) -> None: def __init__(self, name: str, transport: JupyterTransport | None = None) -> None:
self.name = name 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.transport = transport or JupyterTransport()
self._active_call_id: str | None = None self._active_call_id: str | None = None
@@ -26,6 +34,7 @@ class JupyterShell:
"""Yield kernel messages while one cell executes.""" """Yield kernel messages while one cell executes."""
await self.start() await self.start()
self._active_call_id = call_id self._active_call_id = call_id
self.status = "running"
try: try:
kernel_call_id = await self.transport.execute(code) kernel_call_id = await self.transport.execute(code)
async for message in self.transport.messages_for(kernel_call_id): async for message in self.transport.messages_for(kernel_call_id):
@@ -33,10 +42,25 @@ class JupyterShell:
yield replace(message, call_id=call_id) yield replace(message, call_id=call_id)
else: else:
# Keep the kernel's execution ID private to the transport. # 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: finally:
if self._active_call_id == call_id: if self._active_call_id == call_id:
self._active_call_id = None 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: async def reply_to_input(self, call_id: str, value: str) -> None:
"""Send input to the cell currently waiting in this shell.""" """Send input to the cell currently waiting in this shell."""
@@ -47,3 +71,23 @@ class JupyterShell:
async def shutdown(self) -> None: async def shutdown(self) -> None:
"""Stop this shell's kernel process.""" """Stop this shell's kernel process."""
await self.transport.shutdown() 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",
),
)
+66
View File
@@ -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)
+30 -5
View File
@@ -29,17 +29,42 @@ def generate_good_names() -> str:
type WordCategory = Literal["adjectives", "nouns", "verbs"] type WordCategory = Literal["adjectives", "nouns", "verbs"]
type WordCategory1 = Literal["adjective", "noun", "verb"] 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. """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] = [] words: list[str] = []
for category in uhh: for category in recipe:
if isinstance(category, int): 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: else:
words.append(_WORD_GENERATOR.word(include_categories=[category])) words.append(
_WORD_GENERATOR.word(include_categories=[_CATEGORY_NAMES[category]])
)
return "-".join(words) 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])
+8
View File
@@ -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): def test_last_reuses_shell_but_each_call_has_new_id(self):
app = App() app = App()
+24 -1
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import re
import unittest import unittest
from ipython_shell.jupyter.app import JupyterApp from ipython_shell.jupyter.app import JupyterApp
@@ -48,6 +49,25 @@ class JupyterShellTests(unittest.IsolatedAsyncioTestCase):
class JupyterAppTests(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): async def test_named_shells_have_isolated_namespaces(self):
app = JupyterApp() app = JupyterApp()
try: try:
@@ -110,7 +130,10 @@ class JupyterAppTests(unittest.IsolatedAsyncioTestCase):
self.assertIsInstance(input_request, InputRequest) self.assertIsInstance(input_request, InputRequest)
assert isinstance(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? ") self.assertEqual(input_request.prompt, "name? ")
await app.reply_to_input(input_request.call_id, "Ada") await app.reply_to_input(input_request.call_id, "Ada")