diff --git a/.streamlit/config.toml b/.streamlit/config.toml
new file mode 100644
index 0000000..658868f
--- /dev/null
+++ b/.streamlit/config.toml
@@ -0,0 +1,2 @@
+[browser]
+gatherUsageStats = false
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 10c1fdd..eb9f755 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,11 +8,14 @@ requires-python = ">=3.14"
dependencies = [
"fastapi>=0.141.1",
"fastmcp>=3.4.7",
+ "ipykernel>=7.3.0",
"ipython>=9.16.1",
+ "jupyter-client>=8.10.0",
"langchain>=1.3.18",
"langchain-openai>=1.6.0",
"matplotlib>=3.11.1",
"matplotlib-inline>=0.1.7",
+ "pydantic>=2.13.5",
"uvicorn>=0.52.4",
"wonderwords>=2.2.0",
]
@@ -20,8 +23,10 @@ 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"
+ipython-st-demo = "st_demo.app:main"
[build-system]
requires = ["uv_build>=0.12.6,<0.13.0"]
@@ -29,3 +34,8 @@ build-backend = "uv_build"
[tool.uv.build-backend]
module-name = "ipython_shell"
+
+[dependency-groups]
+dev = [
+ "streamlit>=1.37",
+]
diff --git a/src/ipython_shell/__init__.py b/src/ipython_shell/__init__.py
index 9137e59..b22d9ad 100644
--- a/src/ipython_shell/__init__.py
+++ b/src/ipython_shell/__init__.py
@@ -6,6 +6,10 @@ from .events import (
event_from_execution_result,
event_from_history_output,
)
+from .jupyter.app import JupyterApp
+from .jupyter.shell import JupyterShell
+from .jupyter.messages import JupyterMessage, KernelMessage, ParsedJupyterMessage
+from .jupyter.transport import InputRequest, JupyterTransport
from .models import (
AppInfo,
CallError,
@@ -19,6 +23,7 @@ from .models import (
ShellInfo,
ShellStatus,
)
+from .registry import ShellRegistry
from .shell import (
Shell,
carefully_setup_shell,
@@ -26,7 +31,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",
@@ -38,15 +43,25 @@ __all__ = [
"CallResponse",
"CallResult",
"CallResultResponse",
+ "InputRequest",
"JSONValue",
+ "JupyterApp",
+ "JupyterShell",
+ "JupyterTransport",
+ "JupyterMessage",
+ "KernelMessage",
+ "ParsedJupyterMessage",
"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/__init__.py b/src/ipython_shell/jupyter/__init__.py
new file mode 100644
index 0000000..1cf1724
--- /dev/null
+++ b/src/ipython_shell/jupyter/__init__.py
@@ -0,0 +1,38 @@
+from .app import JupyterApp
+from .messages import (
+ DisplayDataMessage,
+ ErrorMessage,
+ ExecuteReplyMessage,
+ ExecuteResultMessage,
+ InputRequestMessage,
+ JupyterMessage,
+ KernelMessage,
+ KnownJupyterMessage,
+ ParsedJupyterMessage,
+ StatusMessage,
+ StreamMessage,
+ UnknownJupyterMessage,
+ parse_jupyter_message,
+)
+from .shell import JupyterShell
+from .transport import InputRequest, JupyterTransport
+
+__all__ = [
+ "InputRequest",
+ "DisplayDataMessage",
+ "ErrorMessage",
+ "ExecuteReplyMessage",
+ "ExecuteResultMessage",
+ "InputRequestMessage",
+ "JupyterApp",
+ "JupyterMessage",
+ "KernelMessage",
+ "JupyterShell",
+ "JupyterTransport",
+ "KnownJupyterMessage",
+ "ParsedJupyterMessage",
+ "StatusMessage",
+ "StreamMessage",
+ "UnknownJupyterMessage",
+ "parse_jupyter_message",
+]
diff --git a/src/ipython_shell/jupyter/app.py b/src/ipython_shell/jupyter/app.py
new file mode 100644
index 0000000..f548c30
--- /dev/null
+++ b/src/ipython_shell/jupyter/app.py
@@ -0,0 +1,102 @@
+from collections.abc import AsyncIterator
+from datetime import UTC, datetime
+from time import monotonic
+from uuid import uuid4
+
+from ..models import AppInfo, ShellInfo
+from ..registry import ShellRegistry
+from ..utils import generate_call_id
+from .messages import ParsedJupyterMessage
+from .shell import JupyterShell
+from .transport import InputRequest
+
+
+class JupyterApp:
+ """Coordinate named persistent shells backed by Jupyter kernels."""
+
+ def __init__(self) -> 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()
+
+ @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]:
+ return self._shell_registry.select(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,
+ code: str,
+ shell_name: str = "last",
+ ) -> AsyncIterator[ParsedJupyterMessage | InputRequest]:
+ """Execute code and yield its subprocess messages."""
+ _, shell = self._select_shell(shell_name)
+ 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):
+ yield message
+ finally:
+ self._calls.pop(call_id, None)
+
+ async def reply_to_input(self, call_id: str, value: str) -> None:
+ """Reply to an input request emitted by a running call."""
+ try:
+ shell = self._calls[call_id]
+ except KeyError as error:
+ raise KeyError(f"Call {call_id} does not exist") from error
+ await shell.reply_to_input(call_id, value)
+
+ async def shutdown(self) -> None:
+ """Stop all kernel processes owned by this app."""
+ awaitables = [shell.shutdown() for shell in self.shells.values()]
+ for awaitable in awaitables:
+ await awaitable
diff --git a/src/ipython_shell/jupyter/messages.py b/src/ipython_shell/jupyter/messages.py
new file mode 100644
index 0000000..80d5c08
--- /dev/null
+++ b/src/ipython_shell/jupyter/messages.py
@@ -0,0 +1,260 @@
+from typing import Annotated, ClassVar, Final, Literal
+
+from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
+
+
+type JupyterChannel = Literal["iopub", "shell", "stdin"]
+type MessageStatus = Literal["ok", "error", "abort"]
+
+STREAM: Final[Literal["stream"]] = "stream"
+DISPLAY_DATA: Final[Literal["display_data"]] = "display_data"
+UPDATE_DISPLAY_DATA: Final[Literal["update_display_data"]] = "update_display_data"
+EXECUTE_RESULT: Final[Literal["execute_result"]] = "execute_result"
+ERROR: Final[Literal["error"]] = "error"
+STATUS: Final[Literal["status"]] = "status"
+EXECUTE_INPUT: Final[Literal["execute_input"]] = "execute_input"
+CLEAR_OUTPUT: Final[Literal["clear_output"]] = "clear_output"
+EXECUTE_REPLY: Final[Literal["execute_reply"]] = "execute_reply"
+INPUT_REQUEST: Final[Literal["input_request"]] = "input_request"
+
+
+class MimeContent(BaseModel):
+ """The shared payload for rich display messages."""
+
+ data: dict[str, object]
+ metadata: dict[str, object] = Field(default_factory=dict)
+ transient: dict[str, object] | None = None
+
+
+class ExecuteResultContent(MimeContent):
+ """Rich display data plus the kernel execution counter."""
+
+ execution_count: int
+
+
+class StreamContent(BaseModel):
+ """A stdout or stderr chunk emitted by the kernel."""
+
+ name: Literal["stdout", "stderr"]
+ text: str
+
+
+class ErrorContent(BaseModel):
+ """An exception reported on the IOPub channel."""
+
+ ename: str
+ evalue: str
+ traceback: list[str]
+
+
+class ExecuteReplyContent(BaseModel):
+ """The terminal shell-channel reply for an execution request."""
+
+ status: MessageStatus
+ execution_count: int | None = None
+ ename: str | None = None
+ evalue: str | None = None
+ traceback: list[str] | None = None
+
+
+class StatusContent(BaseModel):
+ """Kernel busy/idle state from an IOPub status message."""
+
+ execution_state: Literal["busy", "idle", "starting"]
+
+
+class InputRequestContent(BaseModel):
+ """The prompt and echo policy for a kernel input request."""
+
+ prompt: str
+ password: bool = False
+
+
+class ExecuteInputContent(BaseModel):
+ """The code and execution counter echoed by the kernel."""
+
+ code: str
+ execution_count: int
+
+
+class ClearOutputContent(BaseModel):
+ """Whether a clear-output request should wait for new output."""
+
+ wait: bool = False
+
+
+class JupyterMessage(BaseModel):
+ """Common Jupyter envelope, also capable of holding unknown messages.
+
+ The protocol's message vocabulary is extensible, so ``msg_type`` stays a
+ string on this raw-compatible model. Known messages are narrowed into the
+ typed subclasses below by :func:`parse_jupyter_message`.
+ """
+
+ model_config: ClassVar[ConfigDict] = ConfigDict(extra="allow")
+
+ channel: JupyterChannel = "iopub"
+ msg_type: str
+ message_id: str = ""
+ parent_id: str | None = None
+ metadata: dict[str, object] = Field(default_factory=dict)
+ content: dict[str, object] = Field(default_factory=dict)
+ buffers: list[bytes] = Field(default_factory=list)
+
+
+class _TypedMessage(BaseModel):
+ """Common fields for typed messages without narrowing mutable fields."""
+
+ model_config: ClassVar[ConfigDict] = ConfigDict(extra="allow")
+
+ message_id: str = ""
+ parent_id: str | None = None
+ metadata: dict[str, object] = Field(default_factory=dict)
+ buffers: list[bytes] = Field(default_factory=list)
+
+
+class StreamMessage(_TypedMessage):
+ msg_type: Literal["stream"] = STREAM
+ channel: Literal["iopub"] = "iopub"
+ content: StreamContent
+
+
+class DisplayDataMessage(_TypedMessage):
+ msg_type: Literal["display_data"] = DISPLAY_DATA
+ channel: Literal["iopub"] = "iopub"
+ content: MimeContent
+
+
+class UpdateDisplayDataMessage(_TypedMessage):
+ msg_type: Literal["update_display_data"] = UPDATE_DISPLAY_DATA
+ channel: Literal["iopub"] = "iopub"
+ content: MimeContent
+
+
+class ExecuteResultMessage(_TypedMessage):
+ msg_type: Literal["execute_result"] = EXECUTE_RESULT
+ channel: Literal["iopub"] = "iopub"
+ content: ExecuteResultContent
+
+
+class ErrorMessage(_TypedMessage):
+ msg_type: Literal["error"] = ERROR
+ channel: Literal["iopub"] = "iopub"
+ content: ErrorContent
+
+
+class StatusMessage(_TypedMessage):
+ msg_type: Literal["status"] = STATUS
+ channel: Literal["iopub"] = "iopub"
+ content: StatusContent
+
+
+class ExecuteInputMessage(_TypedMessage):
+ msg_type: Literal["execute_input"] = EXECUTE_INPUT
+ channel: Literal["iopub"] = "iopub"
+ content: ExecuteInputContent
+
+
+class ClearOutputMessage(_TypedMessage):
+ msg_type: Literal["clear_output"] = CLEAR_OUTPUT
+ channel: Literal["iopub"] = "iopub"
+ content: ClearOutputContent
+
+
+class ExecuteReplyMessage(_TypedMessage):
+ msg_type: Literal["execute_reply"] = EXECUTE_REPLY
+ channel: Literal["shell"] = "shell"
+ content: ExecuteReplyContent
+
+
+class InputRequestMessage(_TypedMessage):
+ msg_type: Literal["input_request"] = INPUT_REQUEST
+ channel: Literal["stdin"] = "stdin"
+ content: InputRequestContent
+
+
+class UnknownJupyterMessage(JupyterMessage):
+ """An extension or future message preserved without lossy parsing."""
+
+ raw: dict[str, object] = Field(default_factory=dict)
+
+
+type KnownJupyterMessage = Annotated[
+ StreamMessage
+ | DisplayDataMessage
+ | UpdateDisplayDataMessage
+ | ExecuteResultMessage
+ | ErrorMessage
+ | StatusMessage
+ | ExecuteInputMessage
+ | ClearOutputMessage
+ | ExecuteReplyMessage
+ | InputRequestMessage,
+ Field(discriminator="msg_type"),
+]
+type ParsedJupyterMessage = KnownJupyterMessage | UnknownJupyterMessage
+
+_KNOWN_MESSAGE_TYPES: Final[frozenset[str]] = frozenset(
+ {
+ STREAM,
+ DISPLAY_DATA,
+ UPDATE_DISPLAY_DATA,
+ EXECUTE_RESULT,
+ ERROR,
+ STATUS,
+ EXECUTE_INPUT,
+ CLEAR_OUTPUT,
+ EXECUTE_REPLY,
+ INPUT_REQUEST,
+ }
+)
+_KNOWN_MESSAGE_ADAPTER = TypeAdapter(KnownJupyterMessage)
+
+
+def parse_jupyter_message(
+ raw: dict[str, object],
+ *,
+ channel: JupyterChannel,
+) -> ParsedJupyterMessage:
+ """Parse a decoded Jupyter message without dropping extensions."""
+ parent_header = raw.get("parent_header")
+ parent_id = (
+ parent_header.get("msg_id")
+ if isinstance(parent_header, dict)
+ and isinstance(parent_header.get("msg_id"), str)
+ else None
+ )
+ raw_metadata = raw.get("metadata")
+ metadata = raw_metadata if isinstance(raw_metadata, dict) else {}
+ raw_content = raw.get("content")
+ content = raw_content if isinstance(raw_content, dict) else {}
+ raw_buffers = raw.get("buffers")
+ buffers = (
+ [
+ bytes(buffer)
+ for buffer in raw_buffers
+ if isinstance(buffer, (bytes, bytearray, memoryview))
+ ]
+ if isinstance(raw_buffers, list)
+ else []
+ )
+ raw_msg_type = raw.get("msg_type")
+ raw_message_id = raw.get("msg_id")
+ values: dict[str, object] = {
+ "channel": channel,
+ "msg_type": raw_msg_type if isinstance(raw_msg_type, str) else "",
+ "message_id": raw_message_id if isinstance(raw_message_id, str) else "",
+ "parent_id": parent_id,
+ "metadata": metadata,
+ "content": content,
+ "buffers": buffers,
+ }
+
+ if values["msg_type"] not in _KNOWN_MESSAGE_TYPES:
+ return UnknownJupyterMessage.model_validate({**values, "raw": raw})
+ return _KNOWN_MESSAGE_ADAPTER.validate_python(values)
+
+
+# Existing callers use this name for the transport-level message. Keep it as
+# an alias while the more expressive JupyterMessage name becomes canonical.
+KernelMessage = JupyterMessage
diff --git a/src/ipython_shell/jupyter/shell.py b/src/ipython_shell/jupyter/shell.py
new file mode 100644
index 0000000..743413b
--- /dev/null
+++ b/src/ipython_shell/jupyter/shell.py
@@ -0,0 +1,96 @@
+from collections.abc import AsyncIterator
+from datetime import UTC, datetime
+from uuid import uuid4
+
+from ..models import ShellInfo, ShellStatus
+from .messages import ExecuteReplyMessage, ParsedJupyterMessage
+from .transport import InputRequest, JupyterTransport
+
+
+class JupyterShell:
+ """One persistent, subprocess-backed IPython shell."""
+
+ 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
+
+ async def start(self) -> None:
+ """Start this shell's kernel if it is not already running."""
+ if self.transport.client is None:
+ await self.transport.start()
+
+ async def run_cell_stream(
+ self,
+ code: str,
+ *,
+ call_id: str,
+ ) -> AsyncIterator[ParsedJupyterMessage | InputRequest]:
+ """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):
+ if isinstance(message, InputRequest):
+ yield InputRequest(
+ call_id=call_id,
+ prompt=message.prompt,
+ password=message.password,
+ )
+ else:
+ # Keep the kernel's execution ID private to the transport.
+ public_message = message.model_copy(update={"parent_id": call_id})
+ if isinstance(public_message, ExecuteReplyMessage):
+ if public_message.content.execution_count is not None:
+ self._last_execution_count = (
+ public_message.content.execution_count
+ )
+ self.status = (
+ "error"
+ if public_message.content.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."""
+ if self._active_call_id != call_id:
+ raise RuntimeError(f"No active input request for call: {call_id}")
+ await self.transport.reply_to_input(value)
+
+ 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/jupyter/transport.py b/src/ipython_shell/jupyter/transport.py
new file mode 100644
index 0000000..6d4ff11
--- /dev/null
+++ b/src/ipython_shell/jupyter/transport.py
@@ -0,0 +1,236 @@
+import asyncio
+import os
+from collections.abc import AsyncIterator, Awaitable, Callable
+from dataclasses import dataclass
+from time import monotonic
+from typing import Any
+
+from jupyter_client.manager import AsyncKernelManager
+
+from .messages import (
+ ExecuteReplyMessage,
+ InputRequestMessage,
+ JupyterChannel,
+ JupyterMessage,
+ KernelMessage,
+ ParsedJupyterMessage,
+ StatusMessage,
+ parse_jupyter_message,
+)
+
+
+@dataclass
+class InputRequest:
+ """A request for user input emitted by a running kernel call."""
+
+ call_id: str
+ prompt: str
+ password: bool
+
+
+class JupyterTransport:
+ """Own one persistent ipykernel subprocess and its message channels."""
+
+ _IOPUB_SETTLE_SECONDS = 0.25
+
+ def __init__(
+ self,
+ *,
+ matplotlib_backend: str | None = "module://matplotlib_inline.backend_inline",
+ ) -> None:
+ self.manager = AsyncKernelManager()
+ self.matplotlib_backend = matplotlib_backend
+ self.client = None
+ self._active_call: str | None = None
+ self._call_lock = asyncio.Lock()
+ self._waiting_for_input: str | None = None
+ self._message_queue: (
+ asyncio.Queue[tuple[JupyterChannel, dict[str, Any] | BaseException]] | None
+ ) = None
+ self._reader_tasks: set[asyncio.Task[None]] = set()
+
+ async def start(self) -> None:
+ """Start the kernel and its persistent channel readers."""
+ if self.client is not None:
+ return
+
+ # The parent process may select a non-interactive backend (Streamlit
+ # sets ``MPLBACKEND=Agg``). That is useful for server-side rendering,
+ # but it prevents ipykernel from publishing rich display MIME. Give
+ # each Jupyter subprocess its own inline default without mutating the
+ # host process environment.
+ kernel_env = dict(os.environ)
+ if self.matplotlib_backend is not None:
+ kernel_env["MPLBACKEND"] = self.matplotlib_backend
+ await self.manager.start_kernel(env=kernel_env)
+ self.client = self.manager.client()
+ self.client.start_channels()
+ await self.client.wait_for_ready()
+
+ self._message_queue = asyncio.Queue()
+ self._reader_tasks = {
+ asyncio.create_task(
+ self._read_channel("iopub", self.client.get_iopub_msg),
+ name="jupyter-iopub-reader",
+ ),
+ asyncio.create_task(
+ self._read_channel("shell", self.client.get_shell_msg),
+ name="jupyter-shell-reader",
+ ),
+ asyncio.create_task(
+ self._read_channel("stdin", self.client.get_stdin_msg),
+ name="jupyter-stdin-reader",
+ ),
+ }
+
+ async def _read_channel(
+ self,
+ channel: JupyterChannel,
+ get_message: Callable[..., Awaitable[dict[str, Any]]],
+ ) -> None:
+ """Read one ZMQ channel continuously into the transport queue."""
+ if self._message_queue is None:
+ raise RuntimeError("JupyterTransport has not been started")
+
+ try:
+ while True:
+ message = await get_message()
+ await self._message_queue.put((channel, message))
+ except asyncio.CancelledError:
+ raise
+ except BaseException as error:
+ # Surface reader failures instead of leaving messages_for() stuck.
+ await self._message_queue.put((channel, error))
+
+ async def _stop_readers(self) -> None:
+ """Cancel channel readers once, during transport shutdown."""
+ readers = self._reader_tasks
+ self._reader_tasks = set()
+ for reader in readers:
+ reader.cancel()
+ await asyncio.gather(*readers, return_exceptions=True)
+ self._message_queue = None
+
+ async def execute(self, code: str) -> str:
+ """Submit one cell and return its Jupyter message ID."""
+ if self.client is None:
+ raise RuntimeError("JupyterTransport has not been started")
+
+ async with self._call_lock:
+ if self._active_call is not None:
+ raise RuntimeError("JupyterTransport already has an active call")
+ self._active_call = self.client.execute(code, allow_stdin=True)
+ return self._active_call
+
+ async def messages_for(
+ self, call_id: str
+ ) -> AsyncIterator[ParsedJupyterMessage | InputRequest]:
+ """Yield decoded output, input, and completion messages for one call."""
+ if self.client is None:
+ raise RuntimeError("JupyterTransport has not been started")
+ if self._active_call != call_id:
+ raise RuntimeError(f"Unknown or inactive call: {call_id}")
+ if self._message_queue is None:
+ raise RuntimeError("JupyterTransport message readers are not running")
+
+ reply: ExecuteReplyMessage | None = None
+ idle = False
+ try:
+ while True:
+ channel, queued_message = await self._message_queue.get()
+ if isinstance(queued_message, BaseException):
+ raise queued_message
+
+ message = queued_message
+ parent_id = message.get("parent_header", {}).get("msg_id")
+ if parent_id != call_id:
+ continue
+
+ decoded = parse_jupyter_message(message, channel=channel)
+
+ if isinstance(decoded, InputRequestMessage):
+ self._waiting_for_input = call_id
+ yield InputRequest(
+ call_id=call_id,
+ prompt=decoded.content.prompt,
+ password=decoded.content.password,
+ )
+ continue
+
+ if isinstance(decoded, ExecuteReplyMessage):
+ # The shell reply and IOPub messages use different
+ # channels. Buffer the reply so it remains terminal
+ # even when both channels arrive close together.
+ reply = decoded
+ else:
+ yield decoded
+ if isinstance(decoded, StatusMessage) and (
+ decoded.content.execution_state == "idle"
+ ):
+ idle = True
+
+ if reply is not None and idle:
+ # Shell and IOPub are independent ZMQ channels. The
+ # kernel can publish a final rich display after the shell
+ # reply and idle status have already reached this queue.
+ # Wait briefly for that cross-channel tail before ending
+ # the call, otherwise inline Matplotlib output can vanish.
+ settle_deadline = monotonic() + self._IOPUB_SETTLE_SECONDS
+ while True:
+ remaining = settle_deadline - monotonic()
+ if remaining <= 0:
+ break
+ try:
+ late_channel, late_message = await asyncio.wait_for(
+ self._message_queue.get(),
+ timeout=remaining,
+ )
+ except TimeoutError:
+ break
+ if isinstance(late_message, BaseException):
+ raise late_message
+ late_parent_id = late_message.get("parent_header", {}).get(
+ "msg_id"
+ )
+ if late_parent_id != call_id:
+ continue
+ late_decoded = parse_jupyter_message(
+ late_message,
+ channel=late_channel,
+ )
+ if isinstance(late_decoded, InputRequestMessage):
+ self._waiting_for_input = call_id
+ yield InputRequest(
+ call_id=call_id,
+ prompt=late_decoded.content.prompt,
+ password=late_decoded.content.password,
+ )
+ elif isinstance(late_decoded, ExecuteReplyMessage):
+ reply = late_decoded
+ else:
+ yield late_decoded
+ yield reply
+ return
+ finally:
+ if self._waiting_for_input == call_id:
+ self._waiting_for_input = None
+ if self._active_call == call_id:
+ self._active_call = None
+
+ async def reply_to_input(self, value: str) -> None:
+ """Reply to the active kernel input request without process stdin."""
+ if self.client is None:
+ raise RuntimeError("JupyterTransport has not been started")
+ if self._waiting_for_input is None:
+ raise RuntimeError("JupyterTransport is not waiting for input")
+
+ self.client.input(value)
+ self._waiting_for_input = None
+
+ async def shutdown(self) -> None:
+ """Stop readers, channels, and the kernel process."""
+ await self._stop_readers()
+ if self.client is not None:
+ self.client.stop_channels()
+ self.client = None
+ await self.manager.shutdown_kernel(now=True)
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 3ee3dbc..041538a 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,46 @@ 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"]
+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(recipe: Sequence[NamePart]) -> str:
+ """Generate a readable, collision-resistant shell name.
+
+ Example: generate_omfg_names(["adj", "n", "v", 4]) -> "happy-dog-run-1a2b"
+ """
+ words: list[str] = []
+ for category in recipe:
+ if isinstance(category, int):
+ 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_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/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/__init__.py b/src/ipython_webapp/jupyter/__init__.py
new file mode 100644
index 0000000..5aabd2c
--- /dev/null
+++ b/src/ipython_webapp/jupyter/__init__.py
@@ -0,0 +1,5 @@
+"""FastAPI adapter for the subprocess-backed Jupyter shell app."""
+
+from .app import InputReply, app, serialize_event, shell_app
+
+__all__ = ["InputReply", "app", "serialize_event", "shell_app"]
diff --git a/src/ipython_webapp/jupyter/app.py b/src/ipython_webapp/jupyter/app.py
new file mode 100644
index 0000000..6f72001
--- /dev/null
+++ b/src/ipython_webapp/jupyter/app.py
@@ -0,0 +1,96 @@
+import base64
+import json
+from collections.abc import AsyncIterator
+from typing import Annotated
+
+from fastapi import Body, FastAPI, HTTPException, Path, Response
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel
+
+from ipython_shell.jupyter import InputRequest, JupyterApp, ParsedJupyterMessage
+
+app = FastAPI(title="IPython Jupyter Web App", version="0.1.0")
+shell_app = JupyterApp()
+
+
+class InputReply(BaseModel):
+ """Value supplied by a client answering a kernel input request."""
+
+ value: str
+
+
+def serialize_event(event: ParsedJupyterMessage | InputRequest) -> dict[str, object]:
+ """Turn a shell event into JSON-safe data for a web client."""
+ if isinstance(event, InputRequest):
+ return {
+ "type": "input_request",
+ "call_id": event.call_id,
+ "prompt": event.prompt,
+ "password": event.password,
+ }
+
+ # Some Jupyter messages keep binary payloads in message buffers. Base64
+ # makes those buffers safe to carry beside JSON MIME data in the stream.
+ content = event.content
+ if isinstance(content, BaseModel):
+ content = content.model_dump(mode="json")
+
+ return {
+ "type": "kernel_message",
+ "msg_type": event.msg_type,
+ "parent_id": event.parent_id,
+ "content": content,
+ "buffers": [
+ base64.b64encode(buffer).decode("ascii") for buffer in event.buffers
+ ],
+ }
+
+
+async def _stream_events(code: str, shell_name: str) -> AsyncIterator[str]:
+ """Encode one execution's events as newline-delimited JSON."""
+ async for event in shell_app.run_code_stream(code, shell_name):
+ yield json.dumps(serialize_event(event), ensure_ascii=False) + "\n"
+
+
+@app.post("/shells/{shell_name}/run")
+async def run_code(
+ shell_name: Annotated[
+ str,
+ 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,
+ Body(..., description="The code to execute", media_type="text/plain"),
+ ],
+) -> StreamingResponse:
+ """Run code and stream each Jupyter event as one JSON line."""
+ return StreamingResponse(
+ _stream_events(code, shell_name),
+ media_type="application/x-ndjson",
+ )
+
+
+@app.post("/calls/{call_id}/input", status_code=204)
+async def reply_to_input(call_id: str, reply: InputReply) -> Response:
+ """Answer an input request belonging to a currently running call."""
+ try:
+ await shell_app.reply_to_input(call_id, reply.value)
+ except KeyError as error:
+ raise HTTPException(status_code=404, detail=str(error)) from error
+ except RuntimeError as error:
+ raise HTTPException(status_code=409, detail=str(error)) from error
+ return Response(status_code=204)
+
+
+def main() -> None:
+ """Run the Jupyter-backed HTTP API with Uvicorn."""
+ import uvicorn
+
+ uvicorn.run(app, host="::", port=8001, log_level="info")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/st_demo/__init__.py b/src/st_demo/__init__.py
new file mode 100644
index 0000000..2bcd587
--- /dev/null
+++ b/src/st_demo/__init__.py
@@ -0,0 +1 @@
+"""Streamlit demonstration client for the Jupyter-backed shell."""
diff --git a/src/st_demo/app.py b/src/st_demo/app.py
new file mode 100644
index 0000000..319c276
--- /dev/null
+++ b/src/st_demo/app.py
@@ -0,0 +1,312 @@
+import asyncio
+import base64
+import queue
+import sys
+import threading
+from collections.abc import Mapping
+from concurrent.futures import Future
+from dataclasses import dataclass
+from pathlib import Path
+from uuid import uuid4
+
+from ipython_shell.jupyter import InputRequest, JupyterApp, ParsedJupyterMessage
+
+from st_demo.ui import event_to_record, preferred_mime
+
+
+@dataclass
+class _CallFinished:
+ """Sentinel placed after a call's final Jupyter message."""
+
+
+@dataclass
+class _CallFailed:
+ """Exception sentinel placed when the background call task fails."""
+
+ error: Exception
+
+
+@dataclass
+class _BackgroundCall:
+ events: queue.Queue[ParsedJupyterMessage | InputRequest | _CallFinished | _CallFailed]
+ task: Future[None] | None = None
+
+
+class ShellController:
+ """Bridge Streamlit reruns to one persistent async Jupyter application."""
+
+ def __init__(self) -> None:
+ self._loop = asyncio.new_event_loop()
+ self._thread = threading.Thread(
+ target=self._run_loop,
+ name="st-demo-jupyter-loop",
+ daemon=True,
+ )
+ self._ready = threading.Event()
+ self._app: JupyterApp | None = None
+ self._calls: dict[str, _BackgroundCall] = {}
+ self._lock = threading.Lock()
+ self._thread.start()
+ self._ready.wait(timeout=5)
+ if self._app is None:
+ raise RuntimeError("The Jupyter application did not start")
+
+ def _run_loop(self) -> None:
+ """Own the asyncio loop so Streamlit's rerun loop stays synchronous."""
+ asyncio.set_event_loop(self._loop)
+ self._app = JupyterApp()
+ self._ready.set()
+ self._loop.run_forever()
+ self._loop.run_until_complete(self._app.shutdown())
+ self._loop.close()
+
+ def start_call(self, code: str, shell_name: str) -> str:
+ """Start a call and return a local handle immediately."""
+ handle = uuid4().hex
+ background_call = _BackgroundCall(events=queue.Queue())
+ with self._lock:
+ self._calls[handle] = background_call
+ background_call.task = asyncio.run_coroutine_threadsafe(
+ self._consume_call(handle, code, shell_name),
+ self._loop,
+ )
+ return handle
+
+ async def _consume_call(self, handle: str, code: str, shell_name: str) -> None:
+ """Consume the async app stream and publish events for Streamlit."""
+ if self._app is None:
+ raise RuntimeError("The Jupyter application did not start")
+ background_call = self._calls[handle]
+ try:
+ async for event in self._app.run_code_stream(code, shell_name):
+ background_call.events.put(event)
+ except Exception as error:
+ background_call.events.put(_CallFailed(error))
+ finally:
+ background_call.events.put(_CallFinished())
+
+ def poll(
+ self, handle: str
+ ) -> tuple[list[ParsedJupyterMessage | InputRequest], bool, Exception | None]:
+ """Drain events currently available for one call."""
+ try:
+ background_call = self._calls[handle]
+ except KeyError as error:
+ raise KeyError(f"Call {handle} does not exist") from error
+
+ events: list[ParsedJupyterMessage | InputRequest] = []
+ failed: Exception | None = None
+ finished = False
+ while True:
+ try:
+ item = background_call.events.get_nowait()
+ except queue.Empty:
+ break
+ if isinstance(item, _CallFinished):
+ finished = True
+ elif isinstance(item, _CallFailed):
+ failed = item.error
+ else:
+ events.append(item)
+ return events, finished, failed
+
+ def reply_to_input(self, handle: str, call_id: str, value: str) -> None:
+ """Answer an input request without blocking Streamlit's script."""
+ if handle not in self._calls:
+ raise KeyError(f"Call {handle} does not exist")
+ if self._app is None:
+ raise RuntimeError("The Jupyter application did not start")
+ future = asyncio.run_coroutine_threadsafe(
+ self._app.reply_to_input(call_id, value),
+ self._loop,
+ )
+ future.result(timeout=5)
+
+ def shell_names(self) -> list[str]:
+ """Return known shell names without starting any unused kernels."""
+ if self._app is None:
+ return []
+ future = asyncio.run_coroutine_threadsafe(self._shell_names(), self._loop)
+ return future.result(timeout=5)
+
+ async def _shell_names(self) -> list[str]:
+ if self._app is None:
+ return []
+ return [shell.name for shell in self._app.list_shells()]
+
+ def close(self) -> None:
+ """Stop the background application when the Streamlit session ends."""
+ if self._loop.is_running():
+ self._loop.call_soon_threadsafe(self._loop.stop)
+ self._thread.join(timeout=5)
+
+
+def _controller() -> ShellController:
+ import streamlit as st
+
+ if "shell_controller" not in st.session_state:
+ st.session_state.shell_controller = ShellController()
+ return st.session_state.shell_controller
+
+
+def _render_mime(data: dict[str, object]) -> None:
+ import streamlit as st
+
+ selected = preferred_mime(data)
+ if selected is None:
+ return
+ mime, value = selected
+ if mime == "text/html":
+ # This is explicitly a local demo: execute only code you trust.
+ st.html(str(value))
+ elif mime in {"image/png", "image/jpeg", "image/svg+xml"}:
+ if mime == "image/svg+xml":
+ st.html(str(value))
+ else:
+ st.image(base64.b64decode(str(value)))
+ elif mime == "application/pdf":
+ st.download_button(
+ "Download PDF",
+ base64.b64decode(str(value)),
+ file_name="output.pdf",
+ mime=mime,
+ )
+ elif mime == "application/javascript":
+ st.code(str(value), language="javascript")
+ elif mime == "text/latex":
+ st.latex(str(value))
+ else:
+ st.code(str(value), language="text")
+
+
+def _render_record(record: dict[str, object]) -> None:
+ import streamlit as st
+
+ kind = record["kind"]
+ if kind == "stdout":
+ st.code(str(record.get("text", "")), language="text")
+ elif kind == "stderr":
+ st.error(str(record.get("text", "")))
+ elif kind in {"display_data", "execute_result"}:
+ data = record.get("data")
+ if isinstance(data, dict):
+ _render_mime(data)
+ elif kind == "error":
+ st.error(str(record.get("text", "")))
+ data = record.get("data")
+ if isinstance(data, dict):
+ traceback = data.get("traceback")
+ if isinstance(traceback, list):
+ st.code("".join(str(line) for line in traceback), language="text")
+ elif kind == "input_request":
+ st.info(str(record.get("prompt", "Input required")))
+ elif kind not in {"status", "execute_reply", "execute_input"}:
+ with st.expander(f"Jupyter message: {kind}"):
+ st.json(record)
+
+
+def visible_records(
+ call_records: Mapping[str, list[dict[str, object]]],
+) -> list[dict[str, object]]:
+ """Flatten stored calls in insertion order for persistent history."""
+ return [record for records in call_records.values() for record in records]
+
+
+def _render_active_call(controller: ShellController) -> None:
+ import streamlit as st
+
+ handle = st.session_state.get("active_handle")
+ call_records = st.session_state.get("call_records", {})
+ if not isinstance(call_records, dict):
+ return
+
+ finished = False
+ failed: Exception | None = None
+ records: list[dict[str, object]] = []
+ if isinstance(handle, str):
+ events, finished, failed = controller.poll(handle)
+ records = call_records.setdefault(handle, [])
+ records.extend(event_to_record(event) for event in events)
+
+ for record in visible_records(call_records):
+ _render_record(record)
+
+ if not isinstance(handle, str):
+ return
+
+ input_record = next(
+ (record for record in reversed(records) if record["kind"] == "input_request"),
+ None,
+ )
+ if input_record is not None and not finished:
+ value = st.text_input(
+ str(input_record.get("prompt", "Input")),
+ type="password" if input_record.get("password") else "default",
+ key=f"input-value-{handle}",
+ )
+ if st.button("Send input", key=f"send-input-{handle}"):
+ call_id = str(input_record.get("call_id", ""))
+ controller.reply_to_input(handle, call_id, value)
+ st.rerun(scope="fragment")
+ elif failed is not None:
+ st.exception(failed)
+ elif not finished:
+ st.caption("Running…")
+ else:
+ st.success("Call complete")
+ st.session_state.active_handle = None
+
+
+def render() -> None:
+ """Render the Streamlit shell workbench."""
+ import streamlit as st
+
+ st.set_page_config(page_title="IPython Shell", layout="wide")
+ st.title("IPython Shell")
+ st.caption("A persistent Jupyter kernel with typed events and MIME output.")
+
+ controller = _controller()
+ names = controller.shell_names()
+ shell_options = ["last", "new", *names]
+ shell_name = st.sidebar.selectbox("Shell", list(dict.fromkeys(shell_options)))
+ st.sidebar.caption(f"Known shells: {len(names)}")
+
+ code = st.text_area(
+ "Code",
+ value="import matplotlib.pyplot as plt\nplt.plot([1, 2, 3], [4, 5, 6])",
+ height=180,
+ )
+ if st.button("Run", type="primary"):
+ handle = controller.start_call(code, shell_name)
+ st.session_state.active_handle = handle
+ st.session_state.call_records = getattr(
+ st.session_state,
+ "call_records",
+ {},
+ )
+ st.session_state.call_records[handle] = []
+
+ call_records = st.session_state.get("call_records", {})
+ active_call = st.session_state.get("active_handle")
+ if call_records:
+ st.divider()
+ st.subheader("Output")
+ if isinstance(active_call, str) and hasattr(st, "fragment"):
+ st.fragment(run_every="0.25s")(_render_active_call)(controller)
+ elif isinstance(active_call, str):
+ _render_active_call(controller)
+ else:
+ for record in visible_records(call_records):
+ _render_record(record)
+
+
+def main() -> None:
+ """Launch this module through Streamlit's CLI."""
+ from streamlit.web import cli as stcli
+
+ sys.argv = ["streamlit", "run", str(Path(__file__).resolve()), *sys.argv[1:]]
+ raise SystemExit(stcli.main())
+
+
+if __name__ == "__main__":
+ render()
diff --git a/src/st_demo/ui.py b/src/st_demo/ui.py
new file mode 100644
index 0000000..4627632
--- /dev/null
+++ b/src/st_demo/ui.py
@@ -0,0 +1,100 @@
+from collections.abc import Mapping
+from typing import Final
+
+from pydantic import BaseModel
+
+from ipython_shell.jupyter.messages import (
+ DisplayDataMessage,
+ ErrorMessage,
+ ExecuteResultMessage,
+ ParsedJupyterMessage,
+ StreamMessage,
+ UnknownJupyterMessage,
+)
+from ipython_shell.jupyter.transport import InputRequest
+
+
+MIME_PRIORITY: Final[tuple[str, ...]] = (
+ "text/html",
+ "image/svg+xml",
+ "image/png",
+ "image/jpeg",
+ "application/pdf",
+ "application/javascript",
+ "text/latex",
+ "text/plain",
+)
+
+
+def preferred_mime(data: Mapping[str, object]) -> tuple[str, object] | None:
+ """Choose the richest representation the demo knows how to display."""
+ for mime in MIME_PRIORITY:
+ if mime in data:
+ return mime, data[mime]
+ return next(iter(data.items()), None)
+
+
+def _content_record(content: object) -> dict[str, object]:
+ if isinstance(content, BaseModel):
+ return content.model_dump(mode="json")
+ if isinstance(content, dict):
+ return dict(content)
+ return {"value": content}
+
+
+def event_to_record(
+ event: ParsedJupyterMessage | InputRequest,
+) -> dict[str, object]:
+ """Convert one typed kernel event into a UI-friendly record."""
+ if isinstance(event, InputRequest):
+ return {
+ "kind": "input_request",
+ "call_id": event.call_id,
+ "prompt": event.prompt,
+ "password": event.password,
+ }
+
+ call_id = event.parent_id
+ if isinstance(event, StreamMessage):
+ return {
+ "kind": "stdout" if event.content.name == "stdout" else "stderr",
+ "call_id": call_id,
+ "text": event.content.text,
+ }
+
+ if isinstance(event, (DisplayDataMessage, ExecuteResultMessage)):
+ record: dict[str, object] = {
+ "kind": event.msg_type,
+ "call_id": call_id,
+ "data": dict(event.content.data),
+ "text": event.content.data.get("text/plain"),
+ "metadata": dict(event.content.metadata),
+ }
+ if event.content.transient is not None:
+ record["transient"] = dict(event.content.transient)
+ if isinstance(event, ExecuteResultMessage):
+ record["execution_count"] = event.content.execution_count
+ return record
+
+ if isinstance(event, ErrorMessage):
+ return {
+ "kind": "error",
+ "call_id": call_id,
+ "text": f"{event.content.ename}: {event.content.evalue}",
+ "data": _content_record(event.content),
+ }
+
+ if isinstance(event, UnknownJupyterMessage):
+ return {
+ "kind": event.msg_type,
+ "call_id": call_id,
+ "content": dict(event.content),
+ "metadata": dict(event.metadata),
+ "raw": dict(event.raw),
+ }
+
+ return {
+ "kind": event.msg_type,
+ "call_id": call_id,
+ "content": _content_record(event.content),
+ }
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
new file mode 100644
index 0000000..cfe66d7
--- /dev/null
+++ b/tests/test_jupyter_app.py
@@ -0,0 +1,152 @@
+import asyncio
+import re
+import unittest
+
+from ipython_shell.jupyter.app import JupyterApp
+from ipython_shell.jupyter.shell import JupyterShell
+from ipython_shell.jupyter.messages import ExecuteResultMessage
+from ipython_shell.jupyter.transport import InputRequest
+
+
+class JupyterShellTests(unittest.IsolatedAsyncioTestCase):
+ async def test_shell_keeps_namespace_between_cells(self):
+ shell = JupyterShell("persistent")
+ try:
+ first = [
+ message
+ async for message in shell.run_cell_stream(
+ "answer = 41; answer",
+ call_id="call-1",
+ )
+ ]
+ second = [
+ message
+ async for message in shell.run_cell_stream(
+ "answer + 1",
+ call_id="call-2",
+ )
+ ]
+ finally:
+ await shell.shutdown()
+
+ self.assertEqual(
+ next(
+ message.content.data["text/plain"]
+ for message in first
+ if isinstance(message, ExecuteResultMessage)
+ ),
+ "41",
+ )
+ self.assertEqual(
+ next(
+ message.content.data["text/plain"]
+ for message in second
+ if isinstance(message, ExecuteResultMessage)
+ ),
+ "42",
+ )
+
+
+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:
+ _alpha_setup = [
+ message
+ async for message in app.run_code_stream(
+ "alpha_only = 'alpha'",
+ shell_name="alpha",
+ )
+ ]
+ beta_view = [
+ message
+ async for message in app.run_code_stream(
+ "'alpha_only' in globals()",
+ shell_name="beta",
+ )
+ ]
+ alpha_view = [
+ message
+ async for message in app.run_code_stream(
+ "alpha_only",
+ shell_name="alpha",
+ )
+ ]
+ finally:
+ await app.shutdown()
+
+ self.assertEqual(
+ next(
+ message.content.data["text/plain"]
+ for message in beta_view
+ if isinstance(message, ExecuteResultMessage)
+ ),
+ "False",
+ )
+ self.assertEqual(
+ next(
+ message.content.data["text/plain"]
+ for message in alpha_view
+ if isinstance(message, ExecuteResultMessage)
+ ),
+ "'alpha'",
+ )
+
+ async def test_app_routes_input_reply_to_the_call(self):
+ app = JupyterApp()
+ stream = app.run_code_stream(
+ "answer = input('name? '); answer",
+ shell_name="new",
+ )
+
+ try:
+ while True:
+ event = await asyncio.wait_for(anext(stream), timeout=5)
+ if isinstance(event, InputRequest):
+ input_request = event
+ break
+
+ self.assertIsInstance(input_request, InputRequest)
+ assert isinstance(input_request, InputRequest)
+ 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")
+ messages = [message async for message in stream]
+ finally:
+ await app.shutdown()
+
+ self.assertEqual(
+ next(
+ message.content.data["text/plain"]
+ for message in messages
+ if isinstance(message, ExecuteResultMessage)
+ ),
+ "'Ada'",
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_jupyter_webapp.py b/tests/test_jupyter_webapp.py
new file mode 100644
index 0000000..5cf0575
--- /dev/null
+++ b/tests/test_jupyter_webapp.py
@@ -0,0 +1,79 @@
+import json
+import unittest
+
+import httpx
+
+from ipython_shell.jupyter.transport import KernelMessage
+from ipython_webapp.jupyter.app import (
+ InputReply,
+ app,
+ reply_to_input,
+ run_code,
+ serialize_event,
+ shell_app,
+)
+
+
+class JupyterWebAppTests(unittest.IsolatedAsyncioTestCase):
+ def test_serialize_event_encodes_binary_buffers(self):
+ event = KernelMessage(
+ msg_type="display_data",
+ parent_id="call-1",
+ content={"data": {"application/octet-stream": "present"}},
+ buffers=[b"\x00\xff"],
+ )
+
+ serialized = serialize_event(event)
+
+ self.assertEqual(serialized["buffers"], ["AP8="])
+
+ async def test_run_endpoint_streams_json_events_with_mime_data(self):
+ transport = httpx.ASGITransport(app=app)
+ async with httpx.AsyncClient(
+ transport=transport,
+ base_url="http://testserver",
+ ) as client:
+ try:
+ response = await client.post(
+ "/shells/new/run",
+ content="{'answer': 42}",
+ headers={"content-type": "text/plain"},
+ )
+ finally:
+ await shell_app.shutdown()
+
+ self.assertEqual(response.status_code, 200)
+ self.assertTrue(
+ response.headers["content-type"].startswith("application/x-ndjson")
+ )
+ events = [json.loads(line) for line in response.text.splitlines()]
+ result = next(
+ event for event in events if event["msg_type"] == "execute_result"
+ )
+ self.assertEqual(result["content"]["data"]["text/plain"], "{'answer': 42}")
+
+ async def test_input_request_can_be_replied_to_while_run_streams(self):
+ try:
+ response = await run_code("new", "answer = input('name? '); answer")
+ self.assertEqual(response.media_type, "application/x-ndjson")
+ events = []
+ async for line in response.body_iterator:
+ event = json.loads(line)
+ events.append(event)
+ if event["type"] == "input_request":
+ reply = await reply_to_input(
+ event["call_id"],
+ InputReply(value="Ada"),
+ )
+ self.assertEqual(reply.status_code, 204)
+ finally:
+ await shell_app.shutdown()
+
+ result = next(
+ event for event in events if event.get("msg_type") == "execute_result"
+ )
+ self.assertEqual(result["content"]["data"]["text/plain"], "'Ada'")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_st_demo.py b/tests/test_st_demo.py
new file mode 100644
index 0000000..d0d1922
--- /dev/null
+++ b/tests/test_st_demo.py
@@ -0,0 +1,82 @@
+import subprocess
+import sys
+import unittest
+from pathlib import Path
+
+from ipython_shell.jupyter.messages import (
+ ExecuteResultMessage,
+ UnknownJupyterMessage,
+)
+from st_demo.app import visible_records
+from st_demo.ui import event_to_record, preferred_mime
+
+
+class UIDisplayTests(unittest.TestCase):
+ def test_completed_call_records_remain_visible_after_active_call_clears(self):
+ records = {
+ "finished-call": [{"kind": "execute_result", "text": "42"}],
+ }
+
+ self.assertEqual(visible_records(records), records["finished-call"])
+
+ def test_app_module_loads_when_streamlit_executes_file_path(self):
+ app_path = Path(__file__).parents[1] / "src" / "st_demo" / "app.py"
+ result = subprocess.run(
+ [
+ sys.executable,
+ "-c",
+ "import runpy, sys; runpy.run_path(sys.argv[1], run_name='st_demo_script')",
+ str(app_path),
+ ],
+ capture_output=True,
+ text=True,
+ )
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+
+ def test_preferred_mime_chooses_html_before_plain_text(self):
+ mime, value = preferred_mime(
+ {
+ "text/plain": "fallback",
+ "text/html": "rich",
+ }
+ )
+
+ self.assertEqual((mime, value), ("text/html", "rich"))
+
+ def test_execute_result_record_keeps_mime_and_repr(self):
+ event = ExecuteResultMessage(
+ message_id="message-1",
+ parent_id="call-1",
+ content={
+ "execution_count": 4,
+ "data": {
+ "text/plain": "Line2D(_line0)",
+ "image/png": "ZmFrZQ==",
+ },
+ },
+ )
+
+ record = event_to_record(event)
+
+ self.assertEqual(record["kind"], "execute_result")
+ self.assertEqual(record["text"], "Line2D(_line0)")
+ self.assertEqual(record["data"]["image/png"], "ZmFrZQ==")
+
+ def test_unknown_message_record_keeps_raw_protocol_data(self):
+ event = UnknownJupyterMessage(
+ channel="iopub",
+ msg_type="vendor_extension",
+ message_id="message-2",
+ content={"value": 42},
+ raw={"msg_type": "vendor_extension", "content": {"value": 42}},
+ )
+
+ record = event_to_record(event)
+
+ self.assertEqual(record["kind"], "vendor_extension")
+ self.assertEqual(record["content"]["value"], 42)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_transport.py b/tests/test_transport.py
new file mode 100644
index 0000000..1b82af0
--- /dev/null
+++ b/tests/test_transport.py
@@ -0,0 +1,254 @@
+import asyncio
+import os
+import unittest
+from unittest.mock import patch
+
+from ipython_shell.jupyter.messages import (
+ DisplayDataMessage,
+ JupyterMessage,
+ UnknownJupyterMessage,
+ parse_jupyter_message,
+)
+from ipython_shell.jupyter.transport import InputRequest, JupyterTransport
+
+
+class TransportTests(unittest.TestCase):
+ def test_kernel_message_keeps_content_and_buffers(self):
+ message = JupyterMessage(
+ msg_type="display_data",
+ parent_id="call-1",
+ content={"data": {"text/plain": "42"}},
+ buffers=[b"binary"],
+ )
+
+ self.assertEqual(message.parent_id, "call-1")
+ self.assertEqual(message.content["data"], {"text/plain": "42"})
+ self.assertEqual(message.buffers, [b"binary"])
+
+ def test_parser_types_known_message_content(self):
+ message = parse_jupyter_message(
+ {
+ "msg_id": "message-1",
+ "msg_type": "display_data",
+ "parent_header": {"msg_id": "call-1"},
+ "metadata": {},
+ "content": {
+ "data": {"text/plain": "42"},
+ "metadata": {},
+ },
+ "buffers": [b"binary"],
+ },
+ channel="iopub",
+ )
+
+ self.assertIsInstance(message, DisplayDataMessage)
+ assert isinstance(message, DisplayDataMessage)
+ self.assertEqual(message.content.data["text/plain"], "42")
+ self.assertEqual(message.parent_id, "call-1")
+ self.assertEqual(message.buffers, [b"binary"])
+
+ def test_parser_preserves_unknown_message(self):
+ message = parse_jupyter_message(
+ {
+ "msg_id": "message-2",
+ "msg_type": "future_extension",
+ "parent_header": {"msg_id": "call-1"},
+ "metadata": {"vendor": "example"},
+ "content": {"answer": 42},
+ "buffers": [],
+ },
+ channel="iopub",
+ )
+
+ self.assertIsInstance(message, UnknownJupyterMessage)
+ self.assertEqual(message.msg_type, "future_extension")
+ self.assertEqual(message.content["answer"], 42)
+ self.assertEqual(message.metadata["vendor"], "example")
+
+
+class AsyncTransportTests(unittest.IsolatedAsyncioTestCase):
+ async def test_transport_keeps_channel_readers_alive_during_a_call(self):
+ transport = JupyterTransport()
+ await transport.start()
+ try:
+ readers = set(transport._reader_tasks)
+ call_id = await transport.execute("2 + 2")
+ messages = [message async for message in transport.messages_for(call_id)]
+
+ self.assertEqual(len(readers), 3)
+ self.assertEqual(transport._reader_tasks, readers)
+ self.assertTrue(all(not reader.done() for reader in readers))
+ finally:
+ await transport.shutdown()
+
+ self.assertTrue(
+ any(message.msg_type == "execute_result" for message in messages)
+ )
+
+ async def test_transport_executes_and_returns_execute_reply(self):
+ transport = JupyterTransport()
+ await transport.start()
+ try:
+ call_id = await transport.execute("2 + 2")
+ messages = [message async for message in transport.messages_for(call_id)]
+ finally:
+ await transport.shutdown()
+
+ self.assertTrue(
+ any(message.msg_type == "execute_result" for message in messages)
+ )
+ self.assertEqual(messages[-1].msg_type, "execute_reply")
+ self.assertEqual(messages[-1].content.status, "ok")
+
+ async def test_transport_preserves_mime_and_errors(self):
+ transport = JupyterTransport()
+ await transport.start()
+ try:
+ display_id = await transport.execute(
+ "from IPython.display import display\n"
+ "display({'text/plain': 'hello'}, raw=True)"
+ )
+ display_messages = [
+ message async for message in transport.messages_for(display_id)
+ ]
+
+ error_id = await transport.execute("raise ValueError('boom')")
+ error_messages = [
+ message async for message in transport.messages_for(error_id)
+ ]
+ finally:
+ await transport.shutdown()
+
+ display = next(
+ message
+ for message in display_messages
+ if message.msg_type == "display_data"
+ )
+ self.assertEqual(display.content.data["text/plain"], "hello")
+
+ error = next(
+ message for message in error_messages if message.msg_type == "error"
+ )
+ self.assertEqual(error.content.ename, "ValueError")
+
+ async def test_transport_waits_for_iopub_output_after_idle_and_reply(self):
+ transport = JupyterTransport()
+ transport.client = object()
+ transport._active_call = "call-1"
+ transport._message_queue = asyncio.Queue()
+ transport._message_queue.put_nowait(
+ (
+ "shell",
+ {
+ "msg_id": "reply-1",
+ "msg_type": "execute_reply",
+ "parent_header": {"msg_id": "call-1"},
+ "metadata": {},
+ "content": {"status": "ok", "execution_count": 1},
+ "buffers": [],
+ },
+ )
+ )
+ transport._message_queue.put_nowait(
+ (
+ "iopub",
+ {
+ "msg_id": "status-1",
+ "msg_type": "status",
+ "parent_header": {"msg_id": "call-1"},
+ "metadata": {},
+ "content": {"execution_state": "idle"},
+ "buffers": [],
+ },
+ )
+ )
+
+ async def enqueue_late_display() -> None:
+ await asyncio.sleep(0.15)
+ await transport._message_queue.put(
+ (
+ "iopub",
+ {
+ "msg_id": "display-1",
+ "msg_type": "display_data",
+ "parent_header": {"msg_id": "call-1"},
+ "metadata": {},
+ "content": {
+ "data": {"image/png": "ZmFrZQ=="},
+ "metadata": {},
+ },
+ "buffers": [],
+ },
+ )
+ )
+
+ asyncio.create_task(enqueue_late_display())
+ messages = [message async for message in transport.messages_for("call-1")]
+
+ self.assertTrue(any(message.msg_type == "display_data" for message in messages))
+
+ async def test_transport_preserves_matplotlib_mime_output(self):
+ transport = JupyterTransport()
+ await transport.start()
+ try:
+ call_id = await transport.execute(
+ "import matplotlib.pyplot as plt\nplt.plot([1, 2, 3], [4, 5, 6])"
+ )
+ messages = [message async for message in transport.messages_for(call_id)]
+ finally:
+ await transport.shutdown()
+
+ display = next(
+ message for message in messages if message.msg_type == "display_data"
+ )
+ self.assertIn("image/png", display.content.data)
+
+ async def test_transport_keeps_mime_output_when_parent_forces_agg(self):
+ # Streamlit sets MPLBACKEND=Agg in its own process. The kernel must
+ # still use an inline backend so rich output survives the transport.
+ with patch.dict(os.environ, {"MPLBACKEND": "Agg"}):
+ transport = JupyterTransport()
+ await transport.start()
+ try:
+ call_id = await transport.execute(
+ "import matplotlib.pyplot as plt\n"
+ "plt.plot([1, 2, 3], [4, 5, 6])"
+ )
+ messages = [
+ message
+ async for message in transport.messages_for(call_id)
+ ]
+ finally:
+ await transport.shutdown()
+
+ display = next(
+ message for message in messages if message.msg_type == "display_data"
+ )
+ self.assertIn("image/png", display.content.data)
+
+ async def test_transport_routes_input_reply_without_parent_stdin(self):
+ transport = JupyterTransport()
+ await transport.start()
+ try:
+ call_id = await transport.execute("answer = input('name? '); answer")
+ stream = transport.messages_for(call_id)
+
+ while True:
+ event = await asyncio.wait_for(anext(stream), timeout=5)
+ if isinstance(event, InputRequest):
+ input_request = event
+ break
+
+ self.assertEqual(input_request.prompt, "name? ")
+ await transport.reply_to_input("Ada")
+ messages = [message async for message in stream]
+ finally:
+ await transport.shutdown()
+
+ self.assertTrue(
+ any(message.msg_type == "execute_result" for message in messages)
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/uv.lock b/uv.lock
index 98bb1d4..9fc9042 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,6 +1,11 @@
version = 1
revision = 3
requires-python = ">=3.14"
+resolution-markers = [
+ "sys_platform == 'win32'",
+ "sys_platform == 'emscripten'",
+ "sys_platform != 'emscripten' and sys_platform != 'win32'",
+]
[[package]]
name = "aiofile"
@@ -14,6 +19,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/79/6e45e778c4c3cab39e0937b007b720c15f76c50c6453d153282d0fcc3588/aiofile-3.12.3-py3-none-any.whl", hash = "sha256:5c1bcc9e929c50834608e8cc1a4cc1d7503eb60c15a535b779fd39e2f372c017", size = 22122, upload-time = "2026-08-04T22:59:25.838Z" },
]
+[[package]]
+name = "altair"
+version = "6.2.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jinja2" },
+ { name = "jsonschema" },
+ { name = "narwhals" },
+ { name = "packaging" },
+ { name = "typing-extensions", marker = "python_full_version < '3.15'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/a1/5e6cc638a66da48cfc89a79c2f4810dfec00b63385f9b009ab1f069779bb/altair-6.2.2.tar.gz", hash = "sha256:a1ff9d9cfe81c75414641826312b9471780e19d39293ba0b012933f6b6cba0fe", size = 766606, upload-time = "2026-06-23T12:47:13.384Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e3/99/d6031f4f146298951c46b1bf1cc160c2a63f6e44b3c13a30054add100d5f/altair-6.2.2-py3-none-any.whl", hash = "sha256:94014f8ad8617c3cb163d1137359cd6db5ba134b9b46d93cfd8b609fd245a583", size = 797613, upload-time = "2026-06-23T12:47:11.451Z" },
+]
+
[[package]]
name = "annotated-doc"
version = "0.0.5"
@@ -44,6 +65,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
]
+[[package]]
+name = "appnope"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/11/f7/a82489c2b6ebe32d3e2831895ae19c77861f0eadf3bb16034484d965dbb2/appnope-1.0.0.tar.gz", hash = "sha256:685db59cb6043c3c2e528adc0b3bce3a5f8d09bcf7492c6ea650d1b7421f3c49", size = 5454, upload-time = "2026-08-20T21:36:13.748Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/46/c7/6b687cb0f83d2a51017d47953f2ee430ffad6fec2a8ebc964e0718a33eb1/appnope-1.0.0-py3-none-any.whl", hash = "sha256:6fe0c04218aab65c54c4ff81638cdbf848d89f5653b74d68638a137f200dd16e", size = 4158, upload-time = "2026-08-20T21:36:12.444Z" },
+]
+
[[package]]
name = "asttokens"
version = "3.0.2"
@@ -84,6 +114,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" },
]
+[[package]]
+name = "blinker"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
+]
+
[[package]]
name = "cachetools"
version = "7.1.7"
@@ -292,6 +331,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
+[[package]]
+name = "comm"
+version = "0.2.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" },
+]
+
[[package]]
name = "contourpy"
version = "1.3.3"
@@ -399,6 +447,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/00/d0/247938ef46dd81ab293efcac06ac2fee922db5bede9039d059912081db51/cyclopts-4.23.3-py3-none-any.whl", hash = "sha256:b3a65872942afb08f3ab5ca3d65b0b3ecfc872c9ccbea9d6a74ec11aa8a0215e", size = 237180, upload-time = "2026-08-26T18:16:34.128Z" },
]
+[[package]]
+name = "debugpy"
+version = "1.8.21"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782", size = 2477209, upload-time = "2026-06-01T19:31:04.157Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e", size = 3927115, upload-time = "2026-06-01T19:31:05.863Z" },
+ { url = "https://files.pythonhosted.org/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c", size = 5336724, upload-time = "2026-06-01T19:31:07.711Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8", size = 5373803, upload-time = "2026-06-01T19:31:09.469Z" },
+ { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" },
+]
+
[[package]]
name = "distro"
version = "1.9.0"
@@ -605,6 +666,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" },
]
+[[package]]
+name = "httptools"
+version = "0.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" },
+ { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" },
+ { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" },
+ { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" },
+ { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" },
+ { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" },
+ { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" },
+]
+
[[package]]
name = "httpx"
version = "0.28.1"
@@ -663,6 +746,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
]
+[[package]]
+name = "ipykernel"
+version = "7.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "appnope", marker = "sys_platform == 'darwin'" },
+ { name = "comm" },
+ { name = "debugpy" },
+ { name = "ipython" },
+ { name = "jupyter-client" },
+ { name = "jupyter-core" },
+ { name = "matplotlib-inline" },
+ { name = "nest-asyncio2" },
+ { name = "packaging" },
+ { name = "psutil" },
+ { name = "pyzmq" },
+ { name = "tornado" },
+ { name = "traitlets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09", size = 184899, upload-time = "2026-06-10T08:41:25.481Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3d/02/77b271f5dc58bfbc0b577c877b2365d1ffea2afe66a80c13f2312820348c/ipykernel-7.3.0-py3-none-any.whl", hash = "sha256:897eb64da762549ef610698fca5e9675195ec6ac8ec7f19d81ce1ca20c876057", size = 120583, upload-time = "2026-06-10T08:41:23.648Z" },
+]
+
[[package]]
name = "ipython"
version = "9.17.0"
@@ -691,28 +798,42 @@ source = { editable = "." }
dependencies = [
{ name = "fastapi" },
{ name = "fastmcp" },
+ { name = "ipykernel" },
{ name = "ipython" },
+ { name = "jupyter-client" },
{ name = "langchain" },
{ name = "langchain-openai" },
{ name = "matplotlib" },
{ name = "matplotlib-inline" },
+ { name = "pydantic" },
{ name = "uvicorn" },
{ name = "wonderwords" },
]
+[package.dev-dependencies]
+dev = [
+ { name = "streamlit" },
+]
+
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.141.1" },
{ name = "fastmcp", specifier = ">=3.4.7" },
+ { name = "ipykernel", specifier = ">=7.3.0" },
{ name = "ipython", specifier = ">=9.16.1" },
+ { name = "jupyter-client", specifier = ">=8.10.0" },
{ name = "langchain", specifier = ">=1.3.18" },
{ name = "langchain-openai", specifier = ">=1.6.0" },
{ name = "matplotlib", specifier = ">=3.11.1" },
{ name = "matplotlib-inline", specifier = ">=0.1.7" },
+ { name = "pydantic", specifier = ">=2.13.5" },
{ name = "uvicorn", specifier = ">=0.52.4" },
{ name = "wonderwords", specifier = ">=2.2.0" },
]
+[package.metadata.requires-dev]
+dev = [{ name = "streamlit", specifier = ">=1.37" }]
+
[[package]]
name = "ipython-pygments-lexers"
version = "1.1.1"
@@ -725,6 +846,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" },
]
+[[package]]
+name = "itsdangerous"
+version = "2.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
+]
+
[[package]]
name = "jaraco-classes"
version = "3.4.0"
@@ -779,6 +909,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
]
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
[[package]]
name = "jiter"
version = "0.16.0"
@@ -899,6 +1041,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
+[[package]]
+name = "jupyter-client"
+version = "8.10.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jupyter-core" },
+ { name = "python-dateutil" },
+ { name = "pyzmq" },
+ { name = "tornado" },
+ { name = "traitlets" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c5/2a/906772148a06e48885039e0250c340b770a55bbf37b08d6ee0449df369c3/jupyter_client-8.10.0.tar.gz", hash = "sha256:9f7116294dca55f1785be880057d44544db9b1567718d92cb33c58886afb9497", size = 360653, upload-time = "2026-08-28T12:17:10.854Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0f/88/7c548de1f6c2ade7c931a3282da73f9274fa6a1531091be682f89c85efb9/jupyter_client-8.10.0-py3-none-any.whl", hash = "sha256:5f73f24f22fa25192cfff6b23c051932a2473a797b05734aff495b392103e14e", size = 110184, upload-time = "2026-08-28T12:17:09.028Z" },
+]
+
+[[package]]
+name = "jupyter-core"
+version = "5.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "platformdirs" },
+ { name = "traitlets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" },
+]
+
[[package]]
name = "keyring"
version = "25.7.0"
@@ -1148,6 +1320,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
+ { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
+ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
+ { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
+ { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
+ { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
+ { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
+ { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
+]
+
[[package]]
name = "matplotlib"
version = "3.11.1"
@@ -1236,6 +1438,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" },
]
+[[package]]
+name = "narwhals"
+version = "2.25.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6f/7b/6248dada39781db1ab3ebf08943080df0796098515a87f6f8696d14ec744/narwhals-2.25.0.tar.gz", hash = "sha256:62c036c810662bf7820b7737077176313bc59350eeeefb808510f388c743e4b2", size = 677076, upload-time = "2026-08-20T18:10:15.454Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/eb/dc/55481808fd70ef1567cf13540ffd4702af3f74b112e35427564b03f79c2d/narwhals-2.25.0-py3-none-any.whl", hash = "sha256:1f0f403e8c7e4463cde9bfe78b12fdd809e3ae3dda6d9b2f802934fb9c7a6a8f", size = 467373, upload-time = "2026-08-20T18:10:13.834Z" },
+]
+
+[[package]]
+name = "nest-asyncio2"
+version = "1.7.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b4/73/731debf26e27e0a0323d7bda270dc2f634b398e38f040a09da1f4351d0aa/nest_asyncio2-1.7.2.tar.gz", hash = "sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8", size = 14743, upload-time = "2026-02-13T00:34:04.386Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c5/3c/3179b85b0e1c3659f0369940200cd6d0fa900e6cefcc7ea0bc6dd0e29ffb/nest_asyncio2-1.7.2-py3-none-any.whl", hash = "sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01", size = 7843, upload-time = "2026-02-13T00:34:02.691Z" },
+]
+
[[package]]
name = "numpy"
version = "2.5.2"
@@ -1388,6 +1608,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
+[[package]]
+name = "pandas"
+version = "3.0.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+ { name = "python-dateutil" },
+ { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" },
+ { url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" },
+ { url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" },
+ { url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" },
+ { url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" },
+ { url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" },
+ { url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" },
+ { url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" },
+ { url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" },
+]
+
[[package]]
name = "parso"
version = "0.8.7"
@@ -1489,6 +1738,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" },
]
+[[package]]
+name = "protobuf"
+version = "7.36.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a7/e7/0553e21d25ca4d9f573135775348a372c3ec34a93a71d5f297c3bac38341/protobuf-7.36.0.tar.gz", hash = "sha256:e8e09cb0d794c6687926fa558a8a6e72aa10edb997d5ca61da0765f12a3e00ea", size = 510034, upload-time = "2026-08-20T16:34:01.071Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8f/ae/58e3ca96cb2e118cc546b677359b3c6659f79a140935c08dec94c7998585/protobuf-7.36.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:9103532dffd80c6fab7e50c65a31007680a06eb57537d437bb1b35812c138a37", size = 453256, upload-time = "2026-08-20T16:33:53.945Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/15/5162230af4912697f0fe406f6800f80760945babcff0e2c2fe6c84ef2d5d/protobuf-7.36.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:bf94a5917c71058262de683669bc0a797a7669d3de71f0b36d058e3194f47b44", size = 341436, upload-time = "2026-08-20T16:33:55.134Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/09/1670b2bfc9a45e807e520c3e9be36524db9ccc7dc05ea17af7681cabdc61/protobuf-7.36.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:3297e60abdff301e5f74393d87f6cc59dacab5f024a89548a6e8de1d26576b16", size = 354440, upload-time = "2026-08-20T16:33:56.077Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/f8/bd5804695ba400e423c33fd4d9f58c28d86633d5ba1945c36ff3967d98cb/protobuf-7.36.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:70f5ec8eb0da81a44360c0dc0beac99a0d78071d21956a7076bae8bd2051841b", size = 340439, upload-time = "2026-08-20T16:33:56.992Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/9f/acd02338235a3e7d03168c4303478347b7624fc8189ff4e7f0d2654bbe86/protobuf-7.36.0-cp310-abi3-win32.whl", hash = "sha256:7326fd717bdc419162a735938d89d4032332bcc3408804012b24ff3a37086071", size = 440216, upload-time = "2026-08-20T16:33:57.99Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/4e/12cb93270967a2affff5b3f720694700d4d87712a67afd05c8cb3f6fa52c/protobuf-7.36.0-cp310-abi3-win_amd64.whl", hash = "sha256:1781cc1de61249b750848029bca452c0a8b7e990080316b9bbc2518b2117b488", size = 453731, upload-time = "2026-08-20T16:33:58.951Z" },
+ { url = "https://files.pythonhosted.org/packages/01/c3/629999e78d46c1115c11886d51c6bd68c17ce4a944f1ea3e153a91316a33/protobuf-7.36.0-py3-none-any.whl", hash = "sha256:53374d53fc29a67f7dbbf0ade47d7526a0f0137bf0f9c90e48d8a60790ef748c", size = 177024, upload-time = "2026-08-20T16:34:00.053Z" },
+]
+
[[package]]
name = "psutil"
version = "7.2.2"
@@ -1554,6 +1818,28 @@ memory = [
{ name = "cachetools" },
]
+[[package]]
+name = "pyarrow"
+version = "25.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3d/e3/27f57f80141379d60defe6703eb50a707325706f07fedfd1312c7a751995/pyarrow-25.0.1.tar.gz", hash = "sha256:9150a83248bfed9813ea3c3af74c3856c1984d444aa28e58bf7733b9750ddf6a", size = 1201653, upload-time = "2026-08-10T12:40:53.904Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/36/4c/b525824ad3094076919273cd97db61fb3d78252dee76fa3b8dc8f76774aa/pyarrow-25.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bf0b672390cdcb640d7288f96b826d71ff4e9abb254a86c89890baf51a29cee6", size = 35885255, upload-time = "2026-08-10T12:39:32.366Z" },
+ { url = "https://files.pythonhosted.org/packages/08/62/448bb0e940de41aec31d1a956e63ad9c54afdf122a103cc3ab20c2a3ce33/pyarrow-25.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:38a9a4b4b9613380e200641891495a56c3d5a98a092db4a870af9975e220471d", size = 37644461, upload-time = "2026-08-10T12:39:38.142Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/9a/13587e38bd4806fd218f50fd13b8903fab60588a699ff0c406372e5b4043/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b726ad7e7b669be982b0c71c07fe4b037d654354130da79a7902a669e93a66b", size = 46877146, upload-time = "2026-08-10T12:39:43.722Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/61/1c5d1229fa21da4cff5365e41e57177aaac57c563c727f35419b8513d1c1/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:9171748cdf796972d85a4b60157c279913e242992e350c90c7450182a9838b2a", size = 50131616, upload-time = "2026-08-10T12:39:49.304Z" },
+ { url = "https://files.pythonhosted.org/packages/43/20/291e1d65cc0b09aa19f03cf25cf51a2f5fa94b5db315178f2d254ed5cad4/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b7a296aac7a71fa0886c08e155ddb6c636a50013f801f6178daafa0f9e726188", size = 50008879, upload-time = "2026-08-10T12:39:56.891Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/7c/1b7c9ec28e76576337e4f97b31141c9a181b89b6d1d6221e9d8205621a58/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0fe7c8b6c03969b49c8c66182e4a18e3819ab92d07cfab5d8370c531b9369ef0", size = 53170864, upload-time = "2026-08-10T12:40:04.918Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/75/f3d789dc06011a765d14d86bda799cf72ac1d715b6a6edecaa0d73d95062/pyarrow-25.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:f729cfdbd36fd99d543b67a914d2de044c84ebe45be8b34902b299b608c15c8f", size = 28620729, upload-time = "2026-08-10T12:40:51.41Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/05/647a8ee6f7c2662feb6921315617bc04dcd6034763fb61b1199720bf6162/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:59a2de54c0cbd954da861eee4d1d330f8e909c45b53455baef696380f2c55033", size = 36130288, upload-time = "2026-08-10T12:40:11.014Z" },
+ { url = "https://files.pythonhosted.org/packages/93/f8/c9ee997554d7bea94520667dd1933f109ac1da3ee3556d2b49381e023484/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:35935cd5de130aa5cf4dea052a63e6bf2e17006c35c3a468194242b9b2bf5956", size = 37762187, upload-time = "2026-08-10T12:40:16.592Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/08/a28c01c7fe9e96e8233ce2d13df1d402f4f999f848f51d2daacd6bb4c036/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f3831aaa25c67a99f99dc8b05873cb9d64560390372e2aa197ce9dd4a3f06a44", size = 46888003, upload-time = "2026-08-10T12:40:23.242Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/b9/58612e977d28dc58c878448866838369ee8da2f1e7cc8ed2c84b952aafee/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6a1fdfc6659b6b19022f2e50627fb5cf7156a66c46bf4299379955cbe742382a", size = 50079036, upload-time = "2026-08-10T12:40:29.169Z" },
+ { url = "https://files.pythonhosted.org/packages/72/13/66e1402dcc860e1dc2760b1e0292c9a569b62b3bccab69def1b3e907d006/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:169d3429d5be7c752125890620f75a60776d38b0035eddae939651640822332e", size = 50040226, upload-time = "2026-08-10T12:40:35.186Z" },
+ { url = "https://files.pythonhosted.org/packages/78/10/3f1a5497a7ef732ab0f03ecca3e66d89d9c0f57fdc61b4794c456b781f01/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:119297a6dc197e45d9c6d4415f7814a67ffa36c180d26f68c154c58067ae782d", size = 53149035, upload-time = "2026-08-10T12:40:41.454Z" },
+ { url = "https://files.pythonhosted.org/packages/93/c0/37d4a7e8e2f7a6076283673d5298018ca26478b934c6ee369e10505ab32c/pyarrow-25.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4288f27577352d608ca08553b0865e4a9b3aa14820c5d95b53337218d609835b", size = 28753071, upload-time = "2026-08-10T12:40:46.623Z" },
+]
+
[[package]]
name = "pycparser"
version = "3.0"
@@ -1638,6 +1924,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" },
]
+[[package]]
+name = "pydeck"
+version = "0.9.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jinja2" },
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f4/c9/f71032fca47ecc09d30904d9a610234b07139f89eabc2f054b141edcc30f/pydeck-0.9.3.tar.gz", hash = "sha256:695775cbfe51f5fdffbd9735ba469987fdc5efc96bc40a0ee4808170509c78b2", size = 5900912, upload-time = "2026-07-02T23:27:08.704Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6f/34/3998411437aff304a9ed4fa37a6fe1ef3132bcd2b5eac59851b80c86123c/pydeck-0.9.3-py2.py3-none-any.whl", hash = "sha256:d8a47c11c81fb12d51b1feb42427ff4f0e13cb599e48931021b2cba98b6849a6", size = 11428091, upload-time = "2026-07-02T23:27:06.399Z" },
+]
+
[[package]]
name = "pygments"
version = "2.21.0"
@@ -1757,6 +2056,52 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
+[[package]]
+name = "pyzmq"
+version = "27.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "implementation_name == 'pypy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e7/8d/5b3d5631c2f4b4b8862f64cd0c9eb777b5710eeb5125b4be8dd0a200a4c0/pyzmq-27.2.0.tar.gz", hash = "sha256:54d4259d1bfae24ecdb5ca79f7acc2eac6c286a02d6a0ae617797cb45f0726d3", size = 292316, upload-time = "2026-08-20T19:08:21.19Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/57/8a/153532fa53db30e116118164f3af269a1f3966b3e2ba32c89b12fe864bd8/pyzmq-27.2.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:591c8de5851c5ea372194469fe97587b97c3b641e9a70f31bb3474acbfde0241", size = 1431074, upload-time = "2026-08-20T19:06:40.601Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/ef/c08b91248bb90a9efa81fa00ba81b69c157c74d0c5efbb2c319d91babb62/pyzmq-27.2.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:00e73942ef12cecbc7951c4a9104bb8ffaed742abb13af2da6833d90dd368cef", size = 973915, upload-time = "2026-08-20T19:06:42.037Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/78/a3a3a86c2b00fadb92ece1ca4f8f028d62b2ce9ac3526097239ab2d6fba9/pyzmq-27.2.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f8079d0521fe94bbb401fe9407578b28f3701627c8be2c9f7e0c5b77dcb0109", size = 697722, upload-time = "2026-08-20T19:06:43.325Z" },
+ { url = "https://files.pythonhosted.org/packages/62/2c/d5828306f795e8d34676d266823b74e2101e0ad3760d12083de3e02abbb2/pyzmq-27.2.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dea74fd65f1fc5f7fe167916a473ebe6ed6174e5e5d9de11ea6583661be6cf43", size = 872258, upload-time = "2026-08-20T19:06:44.627Z" },
+ { url = "https://files.pythonhosted.org/packages/09/52/51253b78fd8739293e283407eeecb14215c02c71b6519af21f6eed8e69cd/pyzmq-27.2.0-cp312-abi3-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcc99ca132b667a4ed750afd42db4ea73288f18425a9b2e3c0af095665c491f5", size = 739591, upload-time = "2026-08-20T19:06:46.214Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/3e/142c85b67a4c9678629b0cf6d5125b29663d75be69bfaa57a3cac344d780/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b8d5f66e4a8246cf77f7b8f7902af64f00553368fa0373c89d99b78f0ad79394", size = 1689031, upload-time = "2026-08-20T19:06:47.612Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/ee/0776fb0f98ed1eb74d77240087fef0ab045b6ad15cb09555c6c5134c98ad/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:d1526b42a2e725b84ed226f37becedc250c6347594e5ed304e4e9aff68c9aec3", size = 2059547, upload-time = "2026-08-20T19:06:49.064Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/0e/ec77f691a4aebe29ab6329f996fb0e0270c876a3016086e3ca6ef733bcae/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f707bcf2c1d007d14d70531d4dd7b41060881c73efa845580bf6faaf9ea24d42", size = 1910457, upload-time = "2026-08-20T19:06:50.783Z" },
+ { url = "https://files.pythonhosted.org/packages/30/97/1f5530ff4fc271b4597048371d5af972c2baab51be132ba15874e0327a6a/pyzmq-27.2.0-cp312-abi3-win32.whl", hash = "sha256:fdaaa4ea3242f6ad298eb5177eb042aea5c73c30e76d20caee7b15af20d24ec2", size = 563450, upload-time = "2026-08-20T19:06:52.307Z" },
+ { url = "https://files.pythonhosted.org/packages/02/8b/b83f7780dad22e0878e4c7bd9158ebd24ed12bc3d5e3a471cd0576f77ded/pyzmq-27.2.0-cp312-abi3-win_amd64.whl", hash = "sha256:2c218c6ab8bc447ba62054b581fd30209689d199c6ecb253f79615ca74a38e12", size = 628633, upload-time = "2026-08-20T19:06:53.809Z" },
+ { url = "https://files.pythonhosted.org/packages/52/aa/3918b5ac7f9987bd9c421b065074fd7409ded88f856f2c704a24341877ec/pyzmq-27.2.0-cp312-abi3-win_arm64.whl", hash = "sha256:348d6fd3e4b81ae4580622ea8c2ea60224e84b2ac1b3be4482e6edc7de06e7a3", size = 556006, upload-time = "2026-08-20T19:06:55.242Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/84/a849161ff88b2de9b991cc8ab332218824741122fdc4fdf222a5b822ac8c/pyzmq-27.2.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:3d45189c0c3c99f817b7fefff0d32eeef684cf33e1e3c0fc4281515357c54702", size = 1134452, upload-time = "2026-08-20T19:06:59.898Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/34/ff4aaff0cfba2a4d7ad1a16ffedc52c6deb89fcf673d455085446b23f215/pyzmq-27.2.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d61910b52be5b2cd8b248dbcbe3a1b0275556a7d99fb613fc43323b546e273b8", size = 1167520, upload-time = "2026-08-20T19:07:01.283Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/07/42111e9dc1041d78b4443d6eb1b82b027f1a58178dc8a38385effbc72ad5/pyzmq-27.2.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:3ab6eb88590e510ab16715c32dbba12000da9bee989fdadd9ee19a234c492eb7", size = 1466289, upload-time = "2026-08-20T19:07:02.738Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/b4/def7a478458da78665840564161772e7e938600c32a89f28e8b221b54d2d/pyzmq-27.2.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1ecbdd131b9669f62d3a45afee5527c7ae9f141e4301267f21714c90bd21725f", size = 975868, upload-time = "2026-08-20T19:07:04.155Z" },
+ { url = "https://files.pythonhosted.org/packages/38/d5/e3e85f7fea37153097aaff49db9e33093909cc2a7b22c1ac4ebe546600fc/pyzmq-27.2.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3146385b94a760236c5eceff468a66a296a716ca98a2e0f9217b1518118466b1", size = 706054, upload-time = "2026-08-20T19:07:05.623Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/ef/3b7d9449b223183222bf517245e1e53d5f1ab8c10be8b45f6a301b2f994a/pyzmq-27.2.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9846e881620dd62566ca76a53e384c3f37490faf4b9240aebc7498810dfca853", size = 878984, upload-time = "2026-08-20T19:07:07.153Z" },
+ { url = "https://files.pythonhosted.org/packages/be/a5/8b49dbd494f6dcfda69dc4cade322a4b02706ef4e3d30cc366d4e369899f/pyzmq-27.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d9527e3dbaef1edaeeb2446fa7379446814a43ade8adc7c4a5ebe69437815ddd", size = 1697489, upload-time = "2026-08-20T19:07:08.945Z" },
+ { url = "https://files.pythonhosted.org/packages/da/5a/4bb8280901130c26ea25f0cbb4a6d39d94250860c6b3dbd912f1cf48fca7/pyzmq-27.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:56b48fa9d478a3af7254f397697a62f5ad3e1bb677e200b2701f0c290d97e5af", size = 2064236, upload-time = "2026-08-20T19:07:10.384Z" },
+ { url = "https://files.pythonhosted.org/packages/de/38/f433af66922554adb2b5f79e897018c8e19a90b9eaeb49c4814f8355ebe4/pyzmq-27.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bf0b6e4ce1bb089751c504c5493d6b0557eabd02dd21b76e9086cf964234b103", size = 1917424, upload-time = "2026-08-20T19:07:11.909Z" },
+ { url = "https://files.pythonhosted.org/packages/36/81/ea1c1ae3f801d96ba2c269e056761ebcfe023476e651d3af2a7817962051/pyzmq-27.2.0-cp314-cp314t-win32.whl", hash = "sha256:fba8afcf265c6e9fbe1594cb045d4765c6c9a7d607653a8196067ef23566b843", size = 591103, upload-time = "2026-08-20T19:07:13.451Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/04/149a627707e780fa9f2c1ede3590c14fa6b18b5576d15744342622299a50/pyzmq-27.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d1bc1d380a91d954ed5fc9f12915dba014eed0978d2de05ee7ca688bdaac144a", size = 670215, upload-time = "2026-08-20T19:07:15.069Z" },
+ { url = "https://files.pythonhosted.org/packages/30/ba/f9c3c1536c41ef3dbf765ea04218990e2056e558f98184ecd883767fc501/pyzmq-27.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c7cfb75caa83f5153c687e9d2107f64b5ef0ef0d6edd260d3ff920baaaa69101", size = 582252, upload-time = "2026-08-20T19:07:16.582Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/00/78fe097a304a408275747ce43f20428789130b059c5649956277c20f30cf/pyzmq-27.2.0-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:c5129a8fe43ecc49b99eb75616603d483a3c2fcaef504988fafe8ea392aea98b", size = 1134295, upload-time = "2026-08-20T19:07:17.94Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/83/1c36270658d2ee56e23a3f9ef5fbcb94cbd2f9fe966a6641f2f38e697162/pyzmq-27.2.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:baa2ce3485145653194d6c8c5beedd1e9f0bf46a0919c9fa2fe2204fc35b74d9", size = 1167492, upload-time = "2026-08-20T19:07:19.476Z" },
+ { url = "https://files.pythonhosted.org/packages/58/b2/f0ae223438d7faa991f6feefdc823815f11cc604f898738376b59fd96515/pyzmq-27.2.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:e1ed46048d1920cabc96d952a0d5cfe4127ad8db572c335aae4e3c57b9278d7f", size = 1465992, upload-time = "2026-08-20T19:07:20.941Z" },
+ { url = "https://files.pythonhosted.org/packages/59/46/fb56f3f37a6a0937b0e1d2885e808b5eedc171320bac85573cfae78fa9bc/pyzmq-27.2.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e0fa0bc6b1a184aee59b32efcd1b7f0e6d5b8f9387799e4c16a4cb66a86747d6", size = 976118, upload-time = "2026-08-20T19:07:22.577Z" },
+ { url = "https://files.pythonhosted.org/packages/21/82/a2c9bfd7c4d34eea1278493cd041bc000d41acb4463c89ceaad29dc813b6/pyzmq-27.2.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4bd6743e8bf854c3bfce892dd6578a514aabf128e37a4b2eafcf01856f7e44", size = 705968, upload-time = "2026-08-20T19:07:24.019Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/12/b906b269116b6591dc15c0acc5d04c043957c8a531d336999731f4b1d899/pyzmq-27.2.0-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95369ed6626afcfe2ac89832fb1b917c077fbeb905fbbe5d918349ce0222b89b", size = 879011, upload-time = "2026-08-20T19:07:25.428Z" },
+ { url = "https://files.pythonhosted.org/packages/12/13/f96359534bfb77651c15f1fbfc4bfdd7ec3489d23f434706d39598dd0dcd/pyzmq-27.2.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:40124779c3a56ad5d91902df1ff89159cb414b6c1a0ee697abcc66cf5e6db62d", size = 1697496, upload-time = "2026-08-20T19:07:26.821Z" },
+ { url = "https://files.pythonhosted.org/packages/21/b4/2c007ae5f2fe5eca86cbfbc874ed86b5135f2f7812615dfd78606d3c93f6/pyzmq-27.2.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:ec8a318dfc27c7d946651b3d9e8025d5734f30c168a822195601827207bac09b", size = 2064347, upload-time = "2026-08-20T19:07:28.315Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/88/767af3a6630c15215f3a66700ec79598a375edd1fdc9d75a3ad522178c01/pyzmq-27.2.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:88c0fac061bac269076edeb3a209acefc96cd6167c239daf1c2b404ac48d7012", size = 1917360, upload-time = "2026-08-20T19:07:29.693Z" },
+ { url = "https://files.pythonhosted.org/packages/35/c1/80dd2d20d6e57bc68e1dce1e84bf3e76c9577c1bf728199985c8b4ea0fd1/pyzmq-27.2.0-cp315-cp315t-win32.whl", hash = "sha256:ac126d48cf18aa955daabef43bf0009ff76ad4deee437d09ecf15388214b5beb", size = 591073, upload-time = "2026-08-20T19:07:31.341Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/b5/33b781666f3f52ae834bc9c8e38f4f0483a826c5a91cccc993292007bf10/pyzmq-27.2.0-cp315-cp315t-win_amd64.whl", hash = "sha256:edce90a1e588ec63adbf612cc0ad582de4169cd216c7ae53c15f42a2ee902f35", size = 670701, upload-time = "2026-08-20T19:07:32.895Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/97/bc4f0edefb992df4fdebcf9f0cc40f631cd4ed277e1ed59ef2cd99a5c8c5/pyzmq-27.2.0-cp315-cp315t-win_arm64.whl", hash = "sha256:a843094b4d3d633bc3623e47a2ff50742d6af02bc1f7606aa2e67e971e21878d", size = 581985, upload-time = "2026-08-20T19:07:34.19Z" },
+]
+
[[package]]
name = "referencing"
version = "0.37.0"
@@ -1999,6 +2344,38 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
]
+[[package]]
+name = "streamlit"
+version = "1.62.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "altair" },
+ { name = "anyio" },
+ { name = "blinker" },
+ { name = "click" },
+ { name = "httptools" },
+ { name = "itsdangerous" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "pandas" },
+ { name = "pillow" },
+ { name = "protobuf" },
+ { name = "pyarrow" },
+ { name = "pydeck" },
+ { name = "python-multipart" },
+ { name = "requests" },
+ { name = "starlette" },
+ { name = "toml" },
+ { name = "typing-extensions" },
+ { name = "uvicorn" },
+ { name = "watchdog", marker = "sys_platform != 'darwin'" },
+ { name = "websockets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/50/94/928c44a8b7bcd602fc4a16025e9868bcdb88b92bcdb2e53dec188d034fc4/streamlit-1.62.0.tar.gz", hash = "sha256:9d2571da6e6799cbaf0f59548f5773926260a87a69807cf3e2f0f68f9f5e4d45", size = 9883501, upload-time = "2026-08-19T18:31:22.864Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2b/57/78864764c53885db8a378cc1c47329b4b6e095f7ebd89cd1ffcca4027c89/streamlit-1.62.0-py3-none-any.whl", hash = "sha256:294dbcfe0d6531b0d8593a095e6872dcc6ec4b731723fbb318a0f8102e69162e", size = 10490146, upload-time = "2026-08-19T18:31:19.957Z" },
+]
+
[[package]]
name = "tenacity"
version = "9.1.4"
@@ -2048,6 +2425,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/35/5e/9b01afd037bfa22a0033963fa091e0f75b6fb15cd85bffb42ff86e697323/tiktoken-0.14.0-cp315-cp315t-win_amd64.whl", hash = "sha256:561e7580f84a79859af1ef6f676968e9030fcc3fe195700b15235bca64f009c9", size = 987929, upload-time = "2026-08-17T19:49:38.947Z" },
]
+[[package]]
+name = "toml"
+version = "0.10.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" },
+]
+
+[[package]]
+name = "tornado"
+version = "6.5.8"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/10/d3/343e5bb989d6515b1646cf3d40135d73f3d5e45339bded401b56cdac24dd/tornado-6.5.8.tar.gz", hash = "sha256:9452e1b208a8bd771e2cb1f2ff564985b9b214bdebbe622793e1799e0a6bd23f", size = 520493, upload-time = "2026-08-07T02:12:42.971Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f2/d5/007086fd8df5489338e204f65adce33fd4f21a4999dbb2b9cff2f897b5f4/tornado-6.5.8-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:cc6aa787d7cfab7c3d35189dc7a56fbd2399a569624c730c6b55b3d6531d0403", size = 449487, upload-time = "2026-08-07T02:12:28.682Z" },
+ { url = "https://files.pythonhosted.org/packages/70/c8/5a24a99495903f594f6a199dd7beead1cbc0a13e2cb9102727bcaaf2a997/tornado-6.5.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9715b5eb79735b2bcd454ce216a9275b7c0470e64ea1bf5742f78b2f72b26eeb", size = 447649, upload-time = "2026-08-07T02:12:30.306Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/de/f2e733f386b85962d1b1dc82cd63d169b5b4580062b35397eac9244a41fe/tornado-6.5.8-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:547d63f450d570c14fe0e8db2cfb14c9bbd1c2503b4a6612586267955aa47b58", size = 450707, upload-time = "2026-08-07T02:12:31.95Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/94/20efeee9a01c141e9ac47c397f81679dfda24b32768fc4fff24e76d36c2c/tornado-6.5.8-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e2360a0ffbe145eca8af0b19cb7203d79b1a98dd4cccdd6b368f6f49c2e3808", size = 451677, upload-time = "2026-08-07T02:12:33.512Z" },
+ { url = "https://files.pythonhosted.org/packages/42/ec/a96ccb8ccf0de2b7bc2c5fa1608a4803735018242e90c4882365a9fd418f/tornado-6.5.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5d242290bdf7ab3151bc1065fdd75c0dcc21cbc7b49f22a4c56329c2d6566d22", size = 451510, upload-time = "2026-08-07T02:12:35.346Z" },
+ { url = "https://files.pythonhosted.org/packages/29/b5/93185859245ad3f00e62175f29607346788b696369347f0146e0421286bb/tornado-6.5.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7b94ff0e128fe0542f3bd331fb44d06260fc4ac16881545159f34ef08aad4195", size = 450917, upload-time = "2026-08-07T02:12:36.963Z" },
+ { url = "https://files.pythonhosted.org/packages/97/cf/fe33cf062834487d34d1559746a4a12521033c22645b6d74d4bca702e018/tornado-6.5.8-cp39-abi3-win32.whl", hash = "sha256:67832909c4779c64942380cb5f044a5c6163d00831472d80e25e115de9917836", size = 451952, upload-time = "2026-08-07T02:12:38.512Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/e1/468ad54333e92ccb62627e62cb88e5fc14a2171daa67ed47b1b8542d5b86/tornado-6.5.8-cp39-abi3-win_amd64.whl", hash = "sha256:11881db6b7c168494be2c2d12e65931451bdf7ee718535418ae1d8855dd5a0ee", size = 452391, upload-time = "2026-08-07T02:12:39.971Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/3e/cd5e4f06e34cde33b8ef66cf36aa2b5ad46354cc1af7d2136bbe365fee1d/tornado-6.5.8-cp39-abi3-win_arm64.whl", hash = "sha256:68a7468c7e289f8514d7d664101753903217eff1bb6822c6b5994a0b5f5bcb26", size = 451411, upload-time = "2026-08-07T02:12:41.469Z" },
+]
+
[[package]]
name = "traitlets"
version = "5.16.1"
@@ -2087,6 +2490,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
+[[package]]
+name = "tzdata"
+version = "2026.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" },
+]
+
[[package]]
name = "uncalled-for"
version = "0.4.0"
@@ -2155,6 +2567,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" },
]
+[[package]]
+name = "watchdog"
+version = "6.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
+ { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
+ { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
+ { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
+ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
+]
+
[[package]]
name = "watchfiles"
version = "1.2.0"