diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..956514b --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,29 @@ +# IPython Shell Execution + +This project exposes persistent Python environments through web and model-facing adapters. The glossary keeps the long-lived execution environment distinct from each individual code execution. + +## Execution + +**Shell**: +A persistent, isolated Python environment that retains its namespace and execution state across calls. +_Avoid_: Session, request, call + +**Call**: +One request to execute code in one shell. A call has its own identity and lifecycle, can emit ordered events, can pause for input, and eventually produces a result or error. +_Avoid_: Shell, session, task + +**Call ID**: +The application-facing identity of one call, used to associate its events, input requests, and final result. Any execution ID used internally by a transport is not part of this identity. +_Avoid_: Execution count, kernel ID + +**Call Event**: +An ordered observable part of a call, such as standard output, standard error, display data, an input request, or an execution error. +_Avoid_: Log line, transport message + +**App**: +The owner of the available shells and calls. It resolves shell selectors such as `new`, `last`, or a named shell and routes each call to its target shell. +_Avoid_: Server, session + +**Shell Name**: +A user-facing selector for a shell. `new` requests a fresh shell, `last` selects the most recently selected shell, and any other name identifies a reusable named shell. +_Avoid_: Shell ID, kernel name diff --git a/pyproject.toml b/pyproject.toml index 09cb244..e62dab2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ [project.scripts] ipython-demo = "ipython_shell.app:main" ipython-shell = "ipython_shell.app:main" -ipython-webapp = "ipython_webapp.app:main" +ipython-webapp = "ipython_webapp:main" ipython-jupyter-webapp = "ipython_webapp.jupyter.app:main" ipython-mcp = "ipython_mcp.app:main" diff --git a/src/ipython_shell/jupyter/transport.py b/src/ipython_shell/jupyter/transport.py index fa3f809..891b673 100644 --- a/src/ipython_shell/jupyter/transport.py +++ b/src/ipython_shell/jupyter/transport.py @@ -3,7 +3,7 @@ from collections.abc import AsyncIterator, Awaitable, Callable from dataclasses import dataclass from typing import Any -from jupyter_client import AsyncKernelManager +from jupyter_client.manager import AsyncKernelManager @dataclass @@ -34,9 +34,9 @@ class JupyterTransport: self._active_call: str | None = None self._call_lock = asyncio.Lock() self._waiting_for_input: str | None = None - self._message_queue: asyncio.Queue[ - tuple[str, dict[str, Any] | BaseException] - ] | None = None + self._message_queue: ( + asyncio.Queue[tuple[str, dict[str, Any] | BaseException]] | None + ) = None self._reader_tasks: set[asyncio.Task[None]] = set() async def start(self) -> None: @@ -68,7 +68,7 @@ class JupyterTransport: async def _read_channel( self, channel: str, - get_message: Callable[[], Awaitable[dict[str, Any]]], + get_message: Callable[..., Awaitable[dict[str, Any]]], ) -> None: """Read one ZMQ channel continuously into the transport queue.""" if self._message_queue is None: @@ -143,9 +143,7 @@ class JupyterTransport: msg_type=message["msg_type"], parent_id=parent_id, content=dict(message.get("content", {})), - buffers=[ - bytes(buffer) for buffer in message.get("buffers", []) - ], + buffers=[bytes(buffer) for buffer in message.get("buffers", [])], ) if decoded.msg_type == "execute_reply": # The shell reply and IOPub messages use different diff --git a/src/ipython_shell/utils.py b/src/ipython_shell/utils.py index 3ee3dbc..a54b9a8 100644 --- a/src/ipython_shell/utils.py +++ b/src/ipython_shell/utils.py @@ -1,4 +1,7 @@ import inspect +import secrets +from collections.abc import Sequence +from typing import Literal from uuid import uuid4 from wonderwords import RandomWord @@ -22,3 +25,21 @@ def generate_good_names() -> str: adjective = _WORD_GENERATOR.word(include_categories=["adjectives"]) noun = _WORD_GENERATOR.word(include_categories=["nouns"]) return f"{adjective}-{noun}-{uuid4().hex[:4]}" + + +type WordCategory = Literal["adjectives", "nouns", "verbs"] +type WordCategory1 = Literal["adjective", "noun", "verb"] + + +def generate_omfg_names(uhh: Sequence[WordCategory | WordCategory1 | int]) -> str: + """Generate a readable, collision-resistant shell name. + + Example: generate_omfg_names(["adjectives", "nouns", 4]) -> "happy-dog-1a2b" + """ + words: list[str] = [] + for category in uhh: + if isinstance(category, int): + words.append(secrets.token_hex((category + 1) // 2)[:category]) # what? + else: + words.append(_WORD_GENERATOR.word(include_categories=[category])) + return "-".join(words) diff --git a/src/ipython_webapp/__init__.py b/src/ipython_webapp/__init__.py index e69de29..1e1c283 100644 --- a/src/ipython_webapp/__init__.py +++ b/src/ipython_webapp/__init__.py @@ -0,0 +1,25 @@ +import argparse + + +def main() -> None: + from .app import app as interactive_shell_app + from .jupyter import app as jupyter_app + + parser = argparse.ArgumentParser(description="Run the Jupyter shell web app.") + + parser.add_argument( + "--jupyter", + action="store_true", + help="Run the Jupyter shell web app.", + ) + args = parser.parse_args() + import uvicorn + + if args.jupyter: + uvicorn.run(jupyter_app, host="::", port=8000, log_level="info") + else: + uvicorn.run(interactive_shell_app, host="::", port=8000, log_level="info") + + +if __name__ == "__main__": + main() diff --git a/src/ipython_webapp/jupyter/app.py b/src/ipython_webapp/jupyter/app.py index 795f343..96aad78 100644 --- a/src/ipython_webapp/jupyter/app.py +++ b/src/ipython_webapp/jupyter/app.py @@ -52,7 +52,10 @@ async def _stream_events(code: str, shell_name: str) -> AsyncIterator[str]: async def run_code( shell_name: Annotated[ str, - Path(..., description="The Jupyter shell to run code in"), + Path( + ..., + description="The Jupyter shell to run code in. Existing shell guess. 'new' creates a new shell, 'last' uses the most recent shell.", + ), ], code: Annotated[ str, diff --git a/tests/test_transport.py b/tests/test_transport.py index 586fc88..cc2a540 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -29,9 +29,7 @@ class AsyncTransportTests(unittest.IsolatedAsyncioTestCase): try: readers = set(transport._reader_tasks) call_id = await transport.execute("2 + 2") - messages = [ - message async for message in transport.messages_for(call_id) - ] + messages = [message async for message in transport.messages_for(call_id)] self.assertEqual(len(readers), 3) self.assertEqual(transport._reader_tasks, readers)