feat: preserve Jupyter MIME output in Streamlit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
[browser]
|
||||
gatherUsageStats = false
|
||||
@@ -15,6 +15,7 @@ dependencies = [
|
||||
"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",
|
||||
]
|
||||
@@ -25,6 +26,7 @@ ipython-shell = "ipython_shell.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"]
|
||||
@@ -32,3 +34,8 @@ build-backend = "uv_build"
|
||||
|
||||
[tool.uv.build-backend]
|
||||
module-name = "ipython_shell"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"streamlit>=1.37",
|
||||
]
|
||||
|
||||
@@ -8,7 +8,8 @@ from .events import (
|
||||
)
|
||||
from .jupyter.app import JupyterApp
|
||||
from .jupyter.shell import JupyterShell
|
||||
from .jupyter.transport import InputRequest, JupyterTransport, KernelMessage
|
||||
from .jupyter.messages import JupyterMessage, KernelMessage, ParsedJupyterMessage
|
||||
from .jupyter.transport import InputRequest, JupyterTransport
|
||||
from .models import (
|
||||
AppInfo,
|
||||
CallError,
|
||||
@@ -47,7 +48,9 @@ __all__ = [
|
||||
"JupyterApp",
|
||||
"JupyterShell",
|
||||
"JupyterTransport",
|
||||
"JupyterMessage",
|
||||
"KernelMessage",
|
||||
"ParsedJupyterMessage",
|
||||
"Shell",
|
||||
"ShellInfo",
|
||||
"ShellRegistry",
|
||||
|
||||
@@ -1,11 +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, KernelMessage
|
||||
from .transport import InputRequest, JupyterTransport
|
||||
|
||||
__all__ = [
|
||||
"InputRequest",
|
||||
"DisplayDataMessage",
|
||||
"ErrorMessage",
|
||||
"ExecuteReplyMessage",
|
||||
"ExecuteResultMessage",
|
||||
"InputRequestMessage",
|
||||
"JupyterApp",
|
||||
"JupyterMessage",
|
||||
"KernelMessage",
|
||||
"JupyterShell",
|
||||
"JupyterTransport",
|
||||
"KernelMessage",
|
||||
"KnownJupyterMessage",
|
||||
"ParsedJupyterMessage",
|
||||
"StatusMessage",
|
||||
"StreamMessage",
|
||||
"UnknownJupyterMessage",
|
||||
"parse_jupyter_message",
|
||||
]
|
||||
|
||||
@@ -6,8 +6,9 @@ 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, KernelMessage
|
||||
from .transport import InputRequest
|
||||
|
||||
|
||||
class JupyterApp:
|
||||
@@ -74,7 +75,7 @@ class JupyterApp:
|
||||
self,
|
||||
code: str,
|
||||
shell_name: str = "last",
|
||||
) -> AsyncIterator[KernelMessage | InputRequest]:
|
||||
) -> AsyncIterator[ParsedJupyterMessage | InputRequest]:
|
||||
"""Execute code and yield its subprocess messages."""
|
||||
_, shell = self._select_shell(shell_name)
|
||||
call_id = self._new_call_id()
|
||||
|
||||
@@ -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
|
||||
@@ -1,10 +1,10 @@
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from ..models import ShellInfo, ShellStatus
|
||||
from .transport import InputRequest, JupyterTransport, KernelMessage
|
||||
from .messages import ExecuteReplyMessage, ParsedJupyterMessage
|
||||
from .transport import InputRequest, JupyterTransport
|
||||
|
||||
|
||||
class JupyterShell:
|
||||
@@ -30,7 +30,7 @@ class JupyterShell:
|
||||
code: str,
|
||||
*,
|
||||
call_id: str,
|
||||
) -> AsyncIterator[KernelMessage | InputRequest]:
|
||||
) -> AsyncIterator[ParsedJupyterMessage | InputRequest]:
|
||||
"""Yield kernel messages while one cell executes."""
|
||||
await self.start()
|
||||
self._active_call_id = call_id
|
||||
@@ -39,19 +39,22 @@ class JupyterShell:
|
||||
kernel_call_id = await self.transport.execute(code)
|
||||
async for message in self.transport.messages_for(kernel_call_id):
|
||||
if isinstance(message, InputRequest):
|
||||
yield replace(message, call_id=call_id)
|
||||
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 = replace(message, parent_id=call_id)
|
||||
if public_message.msg_type == "execute_reply":
|
||||
self._last_execution_count = int(
|
||||
public_message.content.get(
|
||||
"execution_count", self._last_execution_count
|
||||
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.get("status") == "error"
|
||||
if public_message.content.status == "error"
|
||||
else "ready"
|
||||
)
|
||||
self.last_used_at = datetime.now(UTC).isoformat()
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
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
|
||||
|
||||
|
||||
@dataclass
|
||||
class KernelMessage:
|
||||
"""A decoded Jupyter message kept at the transport boundary."""
|
||||
|
||||
msg_type: str
|
||||
parent_id: str | None
|
||||
content: dict[str, object]
|
||||
buffers: list[bytes]
|
||||
from .messages import (
|
||||
ExecuteReplyMessage,
|
||||
InputRequestMessage,
|
||||
JupyterChannel,
|
||||
JupyterMessage,
|
||||
KernelMessage,
|
||||
ParsedJupyterMessage,
|
||||
StatusMessage,
|
||||
parse_jupyter_message,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -28,14 +31,21 @@ class InputRequest:
|
||||
class JupyterTransport:
|
||||
"""Own one persistent ipykernel subprocess and its message channels."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
_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[str, dict[str, Any] | BaseException]] | None
|
||||
asyncio.Queue[tuple[JupyterChannel, dict[str, Any] | BaseException]] | None
|
||||
) = None
|
||||
self._reader_tasks: set[asyncio.Task[None]] = set()
|
||||
|
||||
@@ -44,7 +54,15 @@ class JupyterTransport:
|
||||
if self.client is not None:
|
||||
return
|
||||
|
||||
await self.manager.start_kernel()
|
||||
# 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()
|
||||
@@ -67,7 +85,7 @@ class JupyterTransport:
|
||||
|
||||
async def _read_channel(
|
||||
self,
|
||||
channel: str,
|
||||
channel: JupyterChannel,
|
||||
get_message: Callable[..., Awaitable[dict[str, Any]]],
|
||||
) -> None:
|
||||
"""Read one ZMQ channel continuously into the transport queue."""
|
||||
@@ -106,7 +124,7 @@ class JupyterTransport:
|
||||
|
||||
async def messages_for(
|
||||
self, call_id: str
|
||||
) -> AsyncIterator[KernelMessage | InputRequest]:
|
||||
) -> 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")
|
||||
@@ -115,11 +133,11 @@ class JupyterTransport:
|
||||
if self._message_queue is None:
|
||||
raise RuntimeError("JupyterTransport message readers are not running")
|
||||
|
||||
reply: KernelMessage | None = None
|
||||
reply: ExecuteReplyMessage | None = None
|
||||
idle = False
|
||||
try:
|
||||
while True:
|
||||
_, queued_message = await self._message_queue.get()
|
||||
channel, queued_message = await self._message_queue.get()
|
||||
if isinstance(queued_message, BaseException):
|
||||
raise queued_message
|
||||
|
||||
@@ -128,37 +146,69 @@ class JupyterTransport:
|
||||
if parent_id != call_id:
|
||||
continue
|
||||
|
||||
if message["msg_type"] == "input_request":
|
||||
decoded = parse_jupyter_message(message, channel=channel)
|
||||
|
||||
if isinstance(decoded, InputRequestMessage):
|
||||
self._waiting_for_input = call_id
|
||||
yield InputRequest(
|
||||
call_id=call_id,
|
||||
prompt=str(message.get("content", {}).get("prompt", "")),
|
||||
password=bool(
|
||||
message.get("content", {}).get("password", False)
|
||||
),
|
||||
prompt=decoded.content.prompt,
|
||||
password=decoded.content.password,
|
||||
)
|
||||
continue
|
||||
|
||||
decoded = KernelMessage(
|
||||
msg_type=message["msg_type"],
|
||||
parent_id=parent_id,
|
||||
content=dict(message.get("content", {})),
|
||||
buffers=[bytes(buffer) for buffer in message.get("buffers", [])],
|
||||
)
|
||||
if decoded.msg_type == "execute_reply":
|
||||
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 (
|
||||
decoded.msg_type == "status"
|
||||
and decoded.content.get("execution_state") == "idle"
|
||||
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:
|
||||
|
||||
@@ -7,7 +7,7 @@ from fastapi import Body, FastAPI, HTTPException, Path, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ipython_shell.jupyter import InputRequest, JupyterApp, KernelMessage
|
||||
from ipython_shell.jupyter import InputRequest, JupyterApp, ParsedJupyterMessage
|
||||
|
||||
app = FastAPI(title="IPython Jupyter Web App", version="0.1.0")
|
||||
shell_app = JupyterApp()
|
||||
@@ -19,7 +19,7 @@ class InputReply(BaseModel):
|
||||
value: str
|
||||
|
||||
|
||||
def serialize_event(event: KernelMessage | InputRequest) -> dict[str, object]:
|
||||
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 {
|
||||
@@ -31,11 +31,15 @@ def serialize_event(event: KernelMessage | InputRequest) -> dict[str, object]:
|
||||
|
||||
# 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": event.content,
|
||||
"content": content,
|
||||
"buffers": [
|
||||
base64.b64encode(buffer).decode("ascii") for buffer in event.buffers
|
||||
],
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Streamlit demonstration client for the Jupyter-backed shell."""
|
||||
@@ -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()
|
||||
@@ -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),
|
||||
}
|
||||
+12
-16
@@ -4,7 +4,8 @@ import unittest
|
||||
|
||||
from ipython_shell.jupyter.app import JupyterApp
|
||||
from ipython_shell.jupyter.shell import JupyterShell
|
||||
from ipython_shell.jupyter.transport import InputRequest, KernelMessage
|
||||
from ipython_shell.jupyter.messages import ExecuteResultMessage
|
||||
from ipython_shell.jupyter.transport import InputRequest
|
||||
|
||||
|
||||
class JupyterShellTests(unittest.IsolatedAsyncioTestCase):
|
||||
@@ -30,19 +31,17 @@ class JupyterShellTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
self.assertEqual(
|
||||
next(
|
||||
message.content["data"]["text/plain"]
|
||||
message.content.data["text/plain"]
|
||||
for message in first
|
||||
if isinstance(message, KernelMessage)
|
||||
and message.msg_type == "execute_result"
|
||||
if isinstance(message, ExecuteResultMessage)
|
||||
),
|
||||
"41",
|
||||
)
|
||||
self.assertEqual(
|
||||
next(
|
||||
message.content["data"]["text/plain"]
|
||||
message.content.data["text/plain"]
|
||||
for message in second
|
||||
if isinstance(message, KernelMessage)
|
||||
and message.msg_type == "execute_result"
|
||||
if isinstance(message, ExecuteResultMessage)
|
||||
),
|
||||
"42",
|
||||
)
|
||||
@@ -97,19 +96,17 @@ class JupyterAppTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
self.assertEqual(
|
||||
next(
|
||||
message.content["data"]["text/plain"]
|
||||
message.content.data["text/plain"]
|
||||
for message in beta_view
|
||||
if isinstance(message, KernelMessage)
|
||||
and message.msg_type == "execute_result"
|
||||
if isinstance(message, ExecuteResultMessage)
|
||||
),
|
||||
"False",
|
||||
)
|
||||
self.assertEqual(
|
||||
next(
|
||||
message.content["data"]["text/plain"]
|
||||
message.content.data["text/plain"]
|
||||
for message in alpha_view
|
||||
if isinstance(message, KernelMessage)
|
||||
and message.msg_type == "execute_result"
|
||||
if isinstance(message, ExecuteResultMessage)
|
||||
),
|
||||
"'alpha'",
|
||||
)
|
||||
@@ -143,10 +140,9 @@ class JupyterAppTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
self.assertEqual(
|
||||
next(
|
||||
message.content["data"]["text/plain"]
|
||||
message.content.data["text/plain"]
|
||||
for message in messages
|
||||
if isinstance(message, KernelMessage)
|
||||
and message.msg_type == "execute_result"
|
||||
if isinstance(message, ExecuteResultMessage)
|
||||
),
|
||||
"'Ada'",
|
||||
)
|
||||
|
||||
@@ -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": "<b>rich</b>",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual((mime, value), ("text/html", "<b>rich</b>"))
|
||||
|
||||
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()
|
||||
+132
-9
@@ -1,16 +1,20 @@
|
||||
import asyncio
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from ipython_shell.jupyter.transport import (
|
||||
InputRequest,
|
||||
JupyterTransport,
|
||||
KernelMessage,
|
||||
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 = KernelMessage(
|
||||
message = JupyterMessage(
|
||||
msg_type="display_data",
|
||||
parent_id="call-1",
|
||||
content={"data": {"text/plain": "42"}},
|
||||
@@ -21,6 +25,46 @@ class TransportTests(unittest.TestCase):
|
||||
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):
|
||||
@@ -54,7 +98,7 @@ class AsyncTransportTests(unittest.IsolatedAsyncioTestCase):
|
||||
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")
|
||||
self.assertEqual(messages[-1].content.status, "ok")
|
||||
|
||||
async def test_transport_preserves_mime_and_errors(self):
|
||||
transport = JupyterTransport()
|
||||
@@ -80,12 +124,68 @@ class AsyncTransportTests(unittest.IsolatedAsyncioTestCase):
|
||||
for message in display_messages
|
||||
if message.msg_type == "display_data"
|
||||
)
|
||||
self.assertEqual(display.content["data"]["text/plain"], "hello")
|
||||
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")
|
||||
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()
|
||||
@@ -101,7 +201,30 @@ class AsyncTransportTests(unittest.IsolatedAsyncioTestCase):
|
||||
display = next(
|
||||
message for message in messages if message.msg_type == "display_data"
|
||||
)
|
||||
self.assertIn("image/png", display.content["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()
|
||||
|
||||
@@ -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"
|
||||
@@ -93,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"
|
||||
@@ -636,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"
|
||||
@@ -753,10 +805,16 @@ dependencies = [
|
||||
{ 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" },
|
||||
@@ -768,10 +826,14 @@ requires-dist = [
|
||||
{ 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"
|
||||
@@ -784,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"
|
||||
@@ -838,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"
|
||||
@@ -1237,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"
|
||||
@@ -1325,6 +1438,15 @@ 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"
|
||||
@@ -1486,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"
|
||||
@@ -1587,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"
|
||||
@@ -1652,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"
|
||||
@@ -1736,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"
|
||||
@@ -2143,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"
|
||||
@@ -2192,6 +2425,15 @@ 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"
|
||||
@@ -2248,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"
|
||||
@@ -2316,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"
|
||||
|
||||
Reference in New Issue
Block a user