merge: preserve Jupyter MIME output in Streamlit

This commit is contained in:
lda
2026-08-31 00:54:20 +07:00 Verified
24 changed files with 2473 additions and 35 deletions
+2
View File
@@ -0,0 +1,2 @@
[browser]
gatherUsageStats = false
+29
View File
@@ -0,0 +1,29 @@
# IPython Shell Execution
This project exposes persistent Python environments through web and model-facing adapters. The glossary keeps the long-lived execution environment distinct from each individual code execution.
## Execution
**Shell**:
A persistent, isolated Python environment that retains its namespace and execution state across calls.
_Avoid_: Session, request, call
**Call**:
One request to execute code in one shell. A call has its own identity and lifecycle, can emit ordered events, can pause for input, and eventually produces a result or error.
_Avoid_: Shell, session, task
**Call ID**:
The application-facing identity of one call, used to associate its events, input requests, and final result. Any execution ID used internally by a transport is not part of this identity.
_Avoid_: Execution count, kernel ID
**Call Event**:
An ordered observable part of a call, such as standard output, standard error, display data, an input request, or an execution error.
_Avoid_: Log line, transport message
**App**:
The owner of the available shells and calls. It resolves shell selectors such as `new`, `last`, or a named shell and routes each call to its target shell.
_Avoid_: Server, session
**Shell Name**:
A user-facing selector for a shell. `new` requests a fresh shell, `last` selects the most recently selected shell, and any other name identifies a reusable named shell.
_Avoid_: Shell ID, kernel name
+11 -1
View File
@@ -8,11 +8,14 @@ requires-python = ">=3.14"
dependencies = [
"fastapi>=0.141.1",
"fastmcp>=3.4.7",
"ipykernel>=7.3.0",
"ipython>=9.16.1",
"jupyter-client>=8.10.0",
"langchain>=1.3.18",
"langchain-openai>=1.6.0",
"matplotlib>=3.11.1",
"matplotlib-inline>=0.1.7",
"pydantic>=2.13.5",
"uvicorn>=0.52.4",
"wonderwords>=2.2.0",
]
@@ -20,8 +23,10 @@ dependencies = [
[project.scripts]
ipython-demo = "ipython_shell.app:main"
ipython-shell = "ipython_shell.app:main"
ipython-webapp = "ipython_webapp.app:main"
ipython-webapp = "ipython_webapp:main"
ipython-jupyter-webapp = "ipython_webapp.jupyter.app:main"
ipython-mcp = "ipython_mcp.app:main"
ipython-st-demo = "st_demo.app:main"
[build-system]
requires = ["uv_build>=0.12.6,<0.13.0"]
@@ -29,3 +34,8 @@ build-backend = "uv_build"
[tool.uv.build-backend]
module-name = "ipython_shell"
[dependency-groups]
dev = [
"streamlit>=1.37",
]
+16 -1
View File
@@ -6,6 +6,10 @@ from .events import (
event_from_execution_result,
event_from_history_output,
)
from .jupyter.app import JupyterApp
from .jupyter.shell import JupyterShell
from .jupyter.messages import JupyterMessage, KernelMessage, ParsedJupyterMessage
from .jupyter.transport import InputRequest, JupyterTransport
from .models import (
AppInfo,
CallError,
@@ -19,6 +23,7 @@ from .models import (
ShellInfo,
ShellStatus,
)
from .registry import ShellRegistry
from .shell import (
Shell,
carefully_setup_shell,
@@ -26,7 +31,7 @@ from .shell import (
run_cell_and_collect,
setup_shell,
)
from .utils import generate_good_names
from .utils import generate_call_id, generate_good_names, generate_omfg_names
__all__ = [
"App",
@@ -38,15 +43,25 @@ __all__ = [
"CallResponse",
"CallResult",
"CallResultResponse",
"InputRequest",
"JSONValue",
"JupyterApp",
"JupyterShell",
"JupyterTransport",
"JupyterMessage",
"KernelMessage",
"ParsedJupyterMessage",
"Shell",
"ShellInfo",
"ShellRegistry",
"ShellStatus",
"carefully_setup_shell",
"error_from_execution_result",
"event_from_execution_result",
"event_from_history_output",
"generate_call_id",
"generate_good_names",
"generate_omfg_names",
"isolate_output_history",
"run_cell_and_collect",
"setup_shell",
+27 -33
View File
@@ -7,9 +7,10 @@ from time import monotonic
from typing import Literal
from uuid import uuid4
from ipython_shell.utils import generate_good_names
from ipython_shell.utils import generate_call_id
from .models import AppInfo, CallOptions, CallResponse, ShellInfo
from .registry import ShellRegistry
from .shell import Shell
@@ -21,52 +22,45 @@ class App:
self.created_at = datetime.now(UTC).isoformat()
self._started_at = monotonic()
self._total_calls = 0
self.shells: dict[str, Shell] = {}
self.last_shell: str | None = None
self._shell_registry = ShellRegistry(Shell)
self._issued_call_ids: set[str] = set()
def _new_shell(self) -> tuple[str, Shell]:
name = generate_good_names()
while name in self.shells:
name = generate_good_names()
shell = Shell(name)
self.shells[name] = shell
self.last_shell = name
return name, shell
@property
def shells(self) -> dict[str, Shell]:
"""Expose the managed shells for compatibility with the old API."""
return self._shell_registry.shells
@property
def last_shell(self) -> str | None:
"""Return the name of the most recently selected shell."""
return self._shell_registry.last_shell
def _select_shell(self, shell_name: str) -> tuple[str, Shell]:
if shell_name == "new":
return self._new_shell()
if shell_name == "last":
if self.last_shell is None:
return self._new_shell()
return self.last_shell, self.shells[self.last_shell]
if shell_name not in self.shells:
self.shells[shell_name] = Shell(shell_name)
self.last_shell = shell_name
return shell_name, self.shells[shell_name]
return self._shell_registry.select(shell_name)
def list_shells(self) -> list[ShellInfo]:
"""Return frontend-safe metadata for all known shells."""
return [shell.describe() for shell in self.shells.values()]
return self._shell_registry.list_shells()
def create_shell(self) -> ShellInfo:
"""Create a fresh named shell and return its metadata."""
_, shell = self._new_shell()
return shell.describe()
return self._shell_registry.create_shell()
def get_shell(self, shell_name: str) -> ShellInfo:
"""Return frontend-safe metadata for a single shell."""
if shell_name not in self.shells:
raise KeyError(f"Shell {shell_name} does not exist")
return self.shells[shell_name].describe()
return self._shell_registry.get_shell(shell_name)
def get_last_shell(self) -> ShellInfo:
"""Return metadata for the most recently selected shell."""
if self.last_shell is None:
raise KeyError("No shell exists yet")
return self.get_shell(self.last_shell)
return self._shell_registry.get_last_shell()
def _new_call_id(self) -> str:
"""Generate a readable call ID that is unique for this app."""
call_id = generate_call_id()
while call_id in self._issued_call_ids:
call_id = generate_call_id()
self._issued_call_ids.add(call_id)
return call_id
def info(self) -> AppInfo:
"""Return runtime metadata without initializing or inspecting shells."""
@@ -96,7 +90,7 @@ class App:
if options is None:
options = CallOptions()
selected_name, shell = self._select_shell(shell_name)
call_id = uuid4().hex
call_id = self._new_call_id()
self._total_calls += 1
execution, events = shell.run_cell(code, call_id=call_id)
return CallResponse(
+38
View File
@@ -0,0 +1,38 @@
from .app import JupyterApp
from .messages import (
DisplayDataMessage,
ErrorMessage,
ExecuteReplyMessage,
ExecuteResultMessage,
InputRequestMessage,
JupyterMessage,
KernelMessage,
KnownJupyterMessage,
ParsedJupyterMessage,
StatusMessage,
StreamMessage,
UnknownJupyterMessage,
parse_jupyter_message,
)
from .shell import JupyterShell
from .transport import InputRequest, JupyterTransport
__all__ = [
"InputRequest",
"DisplayDataMessage",
"ErrorMessage",
"ExecuteReplyMessage",
"ExecuteResultMessage",
"InputRequestMessage",
"JupyterApp",
"JupyterMessage",
"KernelMessage",
"JupyterShell",
"JupyterTransport",
"KnownJupyterMessage",
"ParsedJupyterMessage",
"StatusMessage",
"StreamMessage",
"UnknownJupyterMessage",
"parse_jupyter_message",
]
+102
View File
@@ -0,0 +1,102 @@
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from time import monotonic
from uuid import uuid4
from ..models import AppInfo, ShellInfo
from ..registry import ShellRegistry
from ..utils import generate_call_id
from .messages import ParsedJupyterMessage
from .shell import JupyterShell
from .transport import InputRequest
class JupyterApp:
"""Coordinate named persistent shells backed by Jupyter kernels."""
def __init__(self) -> None:
self.app_id = uuid4().hex
self.created_at = datetime.now(UTC).isoformat()
self._started_at = monotonic()
self._total_calls = 0
self._shell_registry = ShellRegistry(JupyterShell)
self._calls: dict[str, JupyterShell] = {}
self._issued_call_ids: set[str] = set()
@property
def shells(self) -> dict[str, JupyterShell]:
"""Expose the managed shells for compatibility with the sync app."""
return self._shell_registry.shells
@property
def last_shell(self) -> str | None:
"""Return the name of the most recently selected shell."""
return self._shell_registry.last_shell
def _select_shell(self, shell_name: str) -> tuple[str, JupyterShell]:
return self._shell_registry.select(shell_name)
def list_shells(self) -> list[ShellInfo]:
"""Return metadata for every known Jupyter shell."""
return self._shell_registry.list_shells()
def create_shell(self) -> ShellInfo:
"""Create a fresh Jupyter shell and return its metadata."""
return self._shell_registry.create_shell()
def get_shell(self, shell_name: str) -> ShellInfo:
"""Return frontend-safe metadata for one Jupyter shell."""
return self._shell_registry.get_shell(shell_name)
def get_last_shell(self) -> ShellInfo:
"""Return metadata for the most recently selected Jupyter shell."""
return self._shell_registry.get_last_shell()
def info(self) -> AppInfo:
"""Return runtime metadata without starting any kernels."""
return AppInfo(
app_id=self.app_id,
created_at=self.created_at,
uptime_seconds=monotonic() - self._started_at,
shell_count=len(self.shells),
last_shell=self.last_shell,
total_calls=self._total_calls,
)
def _new_call_id(self) -> str:
"""Generate a readable call ID that is unique for this app."""
call_id = generate_call_id()
while call_id in self._issued_call_ids:
call_id = generate_call_id()
self._issued_call_ids.add(call_id)
return call_id
async def run_code_stream(
self,
code: str,
shell_name: str = "last",
) -> AsyncIterator[ParsedJupyterMessage | InputRequest]:
"""Execute code and yield its subprocess messages."""
_, shell = self._select_shell(shell_name)
call_id = self._new_call_id()
self._total_calls += 1
self._calls[call_id] = shell
try:
async for message in shell.run_cell_stream(code, call_id=call_id):
yield message
finally:
self._calls.pop(call_id, None)
async def reply_to_input(self, call_id: str, value: str) -> None:
"""Reply to an input request emitted by a running call."""
try:
shell = self._calls[call_id]
except KeyError as error:
raise KeyError(f"Call {call_id} does not exist") from error
await shell.reply_to_input(call_id, value)
async def shutdown(self) -> None:
"""Stop all kernel processes owned by this app."""
awaitables = [shell.shutdown() for shell in self.shells.values()]
for awaitable in awaitables:
await awaitable
+260
View File
@@ -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
+96
View File
@@ -0,0 +1,96 @@
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from uuid import uuid4
from ..models import ShellInfo, ShellStatus
from .messages import ExecuteReplyMessage, ParsedJupyterMessage
from .transport import InputRequest, JupyterTransport
class JupyterShell:
"""One persistent, subprocess-backed IPython shell."""
def __init__(self, name: str, transport: JupyterTransport | None = None) -> None:
self.name = name
self.shell_id = uuid4().hex
self.created_at = datetime.now(UTC).isoformat()
self.last_used_at: str | None = None
self.status: ShellStatus = "ready"
self._last_execution_count = 0
self.transport = transport or JupyterTransport()
self._active_call_id: str | None = None
async def start(self) -> None:
"""Start this shell's kernel if it is not already running."""
if self.transport.client is None:
await self.transport.start()
async def run_cell_stream(
self,
code: str,
*,
call_id: str,
) -> AsyncIterator[ParsedJupyterMessage | InputRequest]:
"""Yield kernel messages while one cell executes."""
await self.start()
self._active_call_id = call_id
self.status = "running"
try:
kernel_call_id = await self.transport.execute(code)
async for message in self.transport.messages_for(kernel_call_id):
if isinstance(message, InputRequest):
yield InputRequest(
call_id=call_id,
prompt=message.prompt,
password=message.password,
)
else:
# Keep the kernel's execution ID private to the transport.
public_message = message.model_copy(update={"parent_id": call_id})
if isinstance(public_message, ExecuteReplyMessage):
if public_message.content.execution_count is not None:
self._last_execution_count = (
public_message.content.execution_count
)
self.status = (
"error"
if public_message.content.status == "error"
else "ready"
)
self.last_used_at = datetime.now(UTC).isoformat()
yield public_message
finally:
if self._active_call_id == call_id:
self._active_call_id = None
if self.status == "running" and self._active_call_id is None:
self.status = "ready"
async def reply_to_input(self, call_id: str, value: str) -> None:
"""Send input to the cell currently waiting in this shell."""
if self._active_call_id != call_id:
raise RuntimeError(f"No active input request for call: {call_id}")
await self.transport.reply_to_input(value)
async def shutdown(self) -> None:
"""Stop this shell's kernel process."""
await self.transport.shutdown()
def describe(self) -> ShellInfo:
"""Return metadata without starting an unused kernel."""
return ShellInfo(
shell_id=self.shell_id,
name=self.name,
status=self.status,
execution_count=self._last_execution_count,
created_at=self.created_at,
last_used_at=self.last_used_at,
capabilities=(
"stdout",
"stderr",
"display_data",
"execute_result",
"error",
"matplotlib-inline",
"input",
),
)
+236
View File
@@ -0,0 +1,236 @@
import asyncio
import os
from collections.abc import AsyncIterator, Awaitable, Callable
from dataclasses import dataclass
from time import monotonic
from typing import Any
from jupyter_client.manager import AsyncKernelManager
from .messages import (
ExecuteReplyMessage,
InputRequestMessage,
JupyterChannel,
JupyterMessage,
KernelMessage,
ParsedJupyterMessage,
StatusMessage,
parse_jupyter_message,
)
@dataclass
class InputRequest:
"""A request for user input emitted by a running kernel call."""
call_id: str
prompt: str
password: bool
class JupyterTransport:
"""Own one persistent ipykernel subprocess and its message channels."""
_IOPUB_SETTLE_SECONDS = 0.25
def __init__(
self,
*,
matplotlib_backend: str | None = "module://matplotlib_inline.backend_inline",
) -> None:
self.manager = AsyncKernelManager()
self.matplotlib_backend = matplotlib_backend
self.client = None
self._active_call: str | None = None
self._call_lock = asyncio.Lock()
self._waiting_for_input: str | None = None
self._message_queue: (
asyncio.Queue[tuple[JupyterChannel, dict[str, Any] | BaseException]] | None
) = None
self._reader_tasks: set[asyncio.Task[None]] = set()
async def start(self) -> None:
"""Start the kernel and its persistent channel readers."""
if self.client is not None:
return
# The parent process may select a non-interactive backend (Streamlit
# sets ``MPLBACKEND=Agg``). That is useful for server-side rendering,
# but it prevents ipykernel from publishing rich display MIME. Give
# each Jupyter subprocess its own inline default without mutating the
# host process environment.
kernel_env = dict(os.environ)
if self.matplotlib_backend is not None:
kernel_env["MPLBACKEND"] = self.matplotlib_backend
await self.manager.start_kernel(env=kernel_env)
self.client = self.manager.client()
self.client.start_channels()
await self.client.wait_for_ready()
self._message_queue = asyncio.Queue()
self._reader_tasks = {
asyncio.create_task(
self._read_channel("iopub", self.client.get_iopub_msg),
name="jupyter-iopub-reader",
),
asyncio.create_task(
self._read_channel("shell", self.client.get_shell_msg),
name="jupyter-shell-reader",
),
asyncio.create_task(
self._read_channel("stdin", self.client.get_stdin_msg),
name="jupyter-stdin-reader",
),
}
async def _read_channel(
self,
channel: JupyterChannel,
get_message: Callable[..., Awaitable[dict[str, Any]]],
) -> None:
"""Read one ZMQ channel continuously into the transport queue."""
if self._message_queue is None:
raise RuntimeError("JupyterTransport has not been started")
try:
while True:
message = await get_message()
await self._message_queue.put((channel, message))
except asyncio.CancelledError:
raise
except BaseException as error:
# Surface reader failures instead of leaving messages_for() stuck.
await self._message_queue.put((channel, error))
async def _stop_readers(self) -> None:
"""Cancel channel readers once, during transport shutdown."""
readers = self._reader_tasks
self._reader_tasks = set()
for reader in readers:
reader.cancel()
await asyncio.gather(*readers, return_exceptions=True)
self._message_queue = None
async def execute(self, code: str) -> str:
"""Submit one cell and return its Jupyter message ID."""
if self.client is None:
raise RuntimeError("JupyterTransport has not been started")
async with self._call_lock:
if self._active_call is not None:
raise RuntimeError("JupyterTransport already has an active call")
self._active_call = self.client.execute(code, allow_stdin=True)
return self._active_call
async def messages_for(
self, call_id: str
) -> AsyncIterator[ParsedJupyterMessage | InputRequest]:
"""Yield decoded output, input, and completion messages for one call."""
if self.client is None:
raise RuntimeError("JupyterTransport has not been started")
if self._active_call != call_id:
raise RuntimeError(f"Unknown or inactive call: {call_id}")
if self._message_queue is None:
raise RuntimeError("JupyterTransport message readers are not running")
reply: ExecuteReplyMessage | None = None
idle = False
try:
while True:
channel, queued_message = await self._message_queue.get()
if isinstance(queued_message, BaseException):
raise queued_message
message = queued_message
parent_id = message.get("parent_header", {}).get("msg_id")
if parent_id != call_id:
continue
decoded = parse_jupyter_message(message, channel=channel)
if isinstance(decoded, InputRequestMessage):
self._waiting_for_input = call_id
yield InputRequest(
call_id=call_id,
prompt=decoded.content.prompt,
password=decoded.content.password,
)
continue
if isinstance(decoded, ExecuteReplyMessage):
# The shell reply and IOPub messages use different
# channels. Buffer the reply so it remains terminal
# even when both channels arrive close together.
reply = decoded
else:
yield decoded
if isinstance(decoded, StatusMessage) and (
decoded.content.execution_state == "idle"
):
idle = True
if reply is not None and idle:
# Shell and IOPub are independent ZMQ channels. The
# kernel can publish a final rich display after the shell
# reply and idle status have already reached this queue.
# Wait briefly for that cross-channel tail before ending
# the call, otherwise inline Matplotlib output can vanish.
settle_deadline = monotonic() + self._IOPUB_SETTLE_SECONDS
while True:
remaining = settle_deadline - monotonic()
if remaining <= 0:
break
try:
late_channel, late_message = await asyncio.wait_for(
self._message_queue.get(),
timeout=remaining,
)
except TimeoutError:
break
if isinstance(late_message, BaseException):
raise late_message
late_parent_id = late_message.get("parent_header", {}).get(
"msg_id"
)
if late_parent_id != call_id:
continue
late_decoded = parse_jupyter_message(
late_message,
channel=late_channel,
)
if isinstance(late_decoded, InputRequestMessage):
self._waiting_for_input = call_id
yield InputRequest(
call_id=call_id,
prompt=late_decoded.content.prompt,
password=late_decoded.content.password,
)
elif isinstance(late_decoded, ExecuteReplyMessage):
reply = late_decoded
else:
yield late_decoded
yield reply
return
finally:
if self._waiting_for_input == call_id:
self._waiting_for_input = None
if self._active_call == call_id:
self._active_call = None
async def reply_to_input(self, value: str) -> None:
"""Reply to the active kernel input request without process stdin."""
if self.client is None:
raise RuntimeError("JupyterTransport has not been started")
if self._waiting_for_input is None:
raise RuntimeError("JupyterTransport is not waiting for input")
self.client.input(value)
self._waiting_for_input = None
async def shutdown(self) -> None:
"""Stop readers, channels, and the kernel process."""
await self._stop_readers()
if self.client is not None:
self.client.stop_channels()
self.client = None
await self.manager.shutdown_kernel(now=True)
+66
View File
@@ -0,0 +1,66 @@
from collections.abc import Callable
from typing import Protocol
from .models import ShellInfo
from .utils import generate_good_names
class DescribableShell(Protocol):
"""The small shell interface needed by the shared registry."""
def describe(self) -> ShellInfo: ...
class ShellRegistry[ShellT: DescribableShell]:
"""Own named shells and resolve ``new``, ``last``, and explicit names."""
def __init__(self, factory: Callable[[str], ShellT]) -> None:
self._factory = factory
self.shells: dict[str, ShellT] = {}
self.last_shell: str | None = None
def new_shell(self) -> tuple[str, ShellT]:
"""Create a fresh generated shell and select it."""
name = generate_good_names()
while name in self.shells:
name = generate_good_names()
shell = self._factory(name)
self.shells[name] = shell
self.last_shell = name
return name, shell
def select(self, shell_name: str) -> tuple[str, ShellT]:
"""Resolve a selector, creating explicit names when necessary."""
if shell_name == "new":
return self.new_shell()
if shell_name == "last":
if self.last_shell is None:
return self.new_shell()
return self.last_shell, self.shells[self.last_shell]
if shell_name not in self.shells:
self.shells[shell_name] = self._factory(shell_name)
self.last_shell = shell_name
return shell_name, self.shells[shell_name]
def list_shells(self) -> list[ShellInfo]:
"""Return metadata for every known shell."""
return [shell.describe() for shell in self.shells.values()]
def create_shell(self) -> ShellInfo:
"""Create a fresh generated shell and return its metadata."""
_, shell = self.new_shell()
return shell.describe()
def get_shell(self, shell_name: str) -> ShellInfo:
"""Return metadata for one existing shell."""
try:
shell = self.shells[shell_name]
except KeyError as error:
raise KeyError(f"Shell {shell_name} does not exist") from error
return shell.describe()
def get_last_shell(self) -> ShellInfo:
"""Return metadata for the most recently selected shell."""
if self.last_shell is None:
raise KeyError("No shell exists yet")
return self.get_shell(self.last_shell)
+46
View File
@@ -1,4 +1,7 @@
import inspect
import secrets
from collections.abc import Sequence
from typing import Literal
from uuid import uuid4
from wonderwords import RandomWord
@@ -22,3 +25,46 @@ def generate_good_names() -> str:
adjective = _WORD_GENERATOR.word(include_categories=["adjectives"])
noun = _WORD_GENERATOR.word(include_categories=["nouns"])
return f"{adjective}-{noun}-{uuid4().hex[:4]}"
type WordCategory = Literal["adjectives", "nouns", "verbs"]
type WordCategory1 = Literal["adjective", "noun", "verb"]
type WordCategoryShort = Literal["adj", "n", "v"]
type NamePart = WordCategory | WordCategory1 | WordCategoryShort | int
_CATEGORY_NAMES: dict[
WordCategory | WordCategory1 | WordCategoryShort, WordCategory
] = {
"adjectives": "adjectives",
"adjective": "adjectives",
"adj": "adjectives",
"nouns": "nouns",
"noun": "nouns",
"n": "nouns",
"verbs": "verbs",
"verb": "verbs",
"v": "verbs",
}
def generate_omfg_names(recipe: Sequence[NamePart]) -> str:
"""Generate a readable, collision-resistant shell name.
Example: generate_omfg_names(["adj", "n", "v", 4]) -> "happy-dog-run-1a2b"
"""
words: list[str] = []
for category in recipe:
if isinstance(category, int):
if category < 1:
raise ValueError("Hex suffix length must be positive")
words.append(secrets.token_hex((category + 1) // 2)[:category])
else:
words.append(
_WORD_GENERATOR.word(include_categories=[_CATEGORY_NAMES[category]])
)
return "-".join(words)
def generate_call_id() -> str:
"""Generate a readable application-level identifier for one call."""
return generate_omfg_names(["adj", "n", "v", 4])
+25
View File
@@ -0,0 +1,25 @@
import argparse
def main() -> None:
from .app import app as interactive_shell_app
from .jupyter import app as jupyter_app
parser = argparse.ArgumentParser(description="Run the Jupyter shell web app.")
parser.add_argument(
"--jupyter",
action="store_true",
help="Run the Jupyter shell web app.",
)
args = parser.parse_args()
import uvicorn
if args.jupyter:
uvicorn.run(jupyter_app, host="::", port=8000, log_level="info")
else:
uvicorn.run(interactive_shell_app, host="::", port=8000, log_level="info")
if __name__ == "__main__":
main()
+5
View File
@@ -0,0 +1,5 @@
"""FastAPI adapter for the subprocess-backed Jupyter shell app."""
from .app import InputReply, app, serialize_event, shell_app
__all__ = ["InputReply", "app", "serialize_event", "shell_app"]
+96
View File
@@ -0,0 +1,96 @@
import base64
import json
from collections.abc import AsyncIterator
from typing import Annotated
from fastapi import Body, FastAPI, HTTPException, Path, Response
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from ipython_shell.jupyter import InputRequest, JupyterApp, ParsedJupyterMessage
app = FastAPI(title="IPython Jupyter Web App", version="0.1.0")
shell_app = JupyterApp()
class InputReply(BaseModel):
"""Value supplied by a client answering a kernel input request."""
value: str
def serialize_event(event: ParsedJupyterMessage | InputRequest) -> dict[str, object]:
"""Turn a shell event into JSON-safe data for a web client."""
if isinstance(event, InputRequest):
return {
"type": "input_request",
"call_id": event.call_id,
"prompt": event.prompt,
"password": event.password,
}
# Some Jupyter messages keep binary payloads in message buffers. Base64
# makes those buffers safe to carry beside JSON MIME data in the stream.
content = event.content
if isinstance(content, BaseModel):
content = content.model_dump(mode="json")
return {
"type": "kernel_message",
"msg_type": event.msg_type,
"parent_id": event.parent_id,
"content": content,
"buffers": [
base64.b64encode(buffer).decode("ascii") for buffer in event.buffers
],
}
async def _stream_events(code: str, shell_name: str) -> AsyncIterator[str]:
"""Encode one execution's events as newline-delimited JSON."""
async for event in shell_app.run_code_stream(code, shell_name):
yield json.dumps(serialize_event(event), ensure_ascii=False) + "\n"
@app.post("/shells/{shell_name}/run")
async def run_code(
shell_name: Annotated[
str,
Path(
...,
description="The Jupyter shell to run code in. Existing shell guess. 'new' creates a new shell, 'last' uses the most recent shell.",
),
],
code: Annotated[
str,
Body(..., description="The code to execute", media_type="text/plain"),
],
) -> StreamingResponse:
"""Run code and stream each Jupyter event as one JSON line."""
return StreamingResponse(
_stream_events(code, shell_name),
media_type="application/x-ndjson",
)
@app.post("/calls/{call_id}/input", status_code=204)
async def reply_to_input(call_id: str, reply: InputReply) -> Response:
"""Answer an input request belonging to a currently running call."""
try:
await shell_app.reply_to_input(call_id, reply.value)
except KeyError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
except RuntimeError as error:
raise HTTPException(status_code=409, detail=str(error)) from error
return Response(status_code=204)
def main() -> None:
"""Run the Jupyter-backed HTTP API with Uvicorn."""
import uvicorn
uvicorn.run(app, host="::", port=8001, log_level="info")
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
"""Streamlit demonstration client for the Jupyter-backed shell."""
+312
View File
@@ -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()
+100
View File
@@ -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),
}
+8
View File
@@ -49,6 +49,14 @@ class AppTests(unittest.TestCase):
],
)
def test_call_ids_are_readable(self):
app = App()
with contextlib.redirect_stdout(io.StringIO()):
call = app.run_code("1 + 1", shell_name="new")
self.assertRegex(call.call_id, r"^[a-z]+-[a-z]+-[a-z]+-[0-9a-f]{4}$")
def test_last_reuses_shell_but_each_call_has_new_id(self):
app = App()
+152
View File
@@ -0,0 +1,152 @@
import asyncio
import re
import unittest
from ipython_shell.jupyter.app import JupyterApp
from ipython_shell.jupyter.shell import JupyterShell
from ipython_shell.jupyter.messages import ExecuteResultMessage
from ipython_shell.jupyter.transport import InputRequest
class JupyterShellTests(unittest.IsolatedAsyncioTestCase):
async def test_shell_keeps_namespace_between_cells(self):
shell = JupyterShell("persistent")
try:
first = [
message
async for message in shell.run_cell_stream(
"answer = 41; answer",
call_id="call-1",
)
]
second = [
message
async for message in shell.run_cell_stream(
"answer + 1",
call_id="call-2",
)
]
finally:
await shell.shutdown()
self.assertEqual(
next(
message.content.data["text/plain"]
for message in first
if isinstance(message, ExecuteResultMessage)
),
"41",
)
self.assertEqual(
next(
message.content.data["text/plain"]
for message in second
if isinstance(message, ExecuteResultMessage)
),
"42",
)
class JupyterAppTests(unittest.IsolatedAsyncioTestCase):
async def test_app_exposes_shared_shell_metadata_api(self):
app = JupyterApp()
try:
created = app.create_shell()
listed = app.list_shells()
last = app.get_last_shell()
selected = app.get_shell(created.name)
info = app.info()
finally:
await app.shutdown()
self.assertEqual(len(listed), 1)
self.assertEqual(created.shell_id, listed[0].shell_id)
self.assertEqual(created.shell_id, last.shell_id)
self.assertEqual(created.shell_id, selected.shell_id)
self.assertEqual(created.execution_count, 0)
self.assertEqual(info.shell_count, 1)
self.assertEqual(info.last_shell, created.name)
async def test_named_shells_have_isolated_namespaces(self):
app = JupyterApp()
try:
_alpha_setup = [
message
async for message in app.run_code_stream(
"alpha_only = 'alpha'",
shell_name="alpha",
)
]
beta_view = [
message
async for message in app.run_code_stream(
"'alpha_only' in globals()",
shell_name="beta",
)
]
alpha_view = [
message
async for message in app.run_code_stream(
"alpha_only",
shell_name="alpha",
)
]
finally:
await app.shutdown()
self.assertEqual(
next(
message.content.data["text/plain"]
for message in beta_view
if isinstance(message, ExecuteResultMessage)
),
"False",
)
self.assertEqual(
next(
message.content.data["text/plain"]
for message in alpha_view
if isinstance(message, ExecuteResultMessage)
),
"'alpha'",
)
async def test_app_routes_input_reply_to_the_call(self):
app = JupyterApp()
stream = app.run_code_stream(
"answer = input('name? '); answer",
shell_name="new",
)
try:
while True:
event = await asyncio.wait_for(anext(stream), timeout=5)
if isinstance(event, InputRequest):
input_request = event
break
self.assertIsInstance(input_request, InputRequest)
assert isinstance(input_request, InputRequest)
self.assertRegex(
input_request.call_id,
re.compile(r"^[a-z]+-[a-z]+-[a-z]+-[0-9a-f]{4}$"),
)
self.assertEqual(input_request.prompt, "name? ")
await app.reply_to_input(input_request.call_id, "Ada")
messages = [message async for message in stream]
finally:
await app.shutdown()
self.assertEqual(
next(
message.content.data["text/plain"]
for message in messages
if isinstance(message, ExecuteResultMessage)
),
"'Ada'",
)
if __name__ == "__main__":
unittest.main()
+79
View File
@@ -0,0 +1,79 @@
import json
import unittest
import httpx
from ipython_shell.jupyter.transport import KernelMessage
from ipython_webapp.jupyter.app import (
InputReply,
app,
reply_to_input,
run_code,
serialize_event,
shell_app,
)
class JupyterWebAppTests(unittest.IsolatedAsyncioTestCase):
def test_serialize_event_encodes_binary_buffers(self):
event = KernelMessage(
msg_type="display_data",
parent_id="call-1",
content={"data": {"application/octet-stream": "present"}},
buffers=[b"\x00\xff"],
)
serialized = serialize_event(event)
self.assertEqual(serialized["buffers"], ["AP8="])
async def test_run_endpoint_streams_json_events_with_mime_data(self):
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
try:
response = await client.post(
"/shells/new/run",
content="{'answer': 42}",
headers={"content-type": "text/plain"},
)
finally:
await shell_app.shutdown()
self.assertEqual(response.status_code, 200)
self.assertTrue(
response.headers["content-type"].startswith("application/x-ndjson")
)
events = [json.loads(line) for line in response.text.splitlines()]
result = next(
event for event in events if event["msg_type"] == "execute_result"
)
self.assertEqual(result["content"]["data"]["text/plain"], "{'answer': 42}")
async def test_input_request_can_be_replied_to_while_run_streams(self):
try:
response = await run_code("new", "answer = input('name? '); answer")
self.assertEqual(response.media_type, "application/x-ndjson")
events = []
async for line in response.body_iterator:
event = json.loads(line)
events.append(event)
if event["type"] == "input_request":
reply = await reply_to_input(
event["call_id"],
InputReply(value="Ada"),
)
self.assertEqual(reply.status_code, 204)
finally:
await shell_app.shutdown()
result = next(
event for event in events if event.get("msg_type") == "execute_result"
)
self.assertEqual(result["content"]["data"]["text/plain"], "'Ada'")
if __name__ == "__main__":
unittest.main()
+82
View File
@@ -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()
+254
View File
@@ -0,0 +1,254 @@
import asyncio
import os
import unittest
from unittest.mock import patch
from ipython_shell.jupyter.messages import (
DisplayDataMessage,
JupyterMessage,
UnknownJupyterMessage,
parse_jupyter_message,
)
from ipython_shell.jupyter.transport import InputRequest, JupyterTransport
class TransportTests(unittest.TestCase):
def test_kernel_message_keeps_content_and_buffers(self):
message = JupyterMessage(
msg_type="display_data",
parent_id="call-1",
content={"data": {"text/plain": "42"}},
buffers=[b"binary"],
)
self.assertEqual(message.parent_id, "call-1")
self.assertEqual(message.content["data"], {"text/plain": "42"})
self.assertEqual(message.buffers, [b"binary"])
def test_parser_types_known_message_content(self):
message = parse_jupyter_message(
{
"msg_id": "message-1",
"msg_type": "display_data",
"parent_header": {"msg_id": "call-1"},
"metadata": {},
"content": {
"data": {"text/plain": "42"},
"metadata": {},
},
"buffers": [b"binary"],
},
channel="iopub",
)
self.assertIsInstance(message, DisplayDataMessage)
assert isinstance(message, DisplayDataMessage)
self.assertEqual(message.content.data["text/plain"], "42")
self.assertEqual(message.parent_id, "call-1")
self.assertEqual(message.buffers, [b"binary"])
def test_parser_preserves_unknown_message(self):
message = parse_jupyter_message(
{
"msg_id": "message-2",
"msg_type": "future_extension",
"parent_header": {"msg_id": "call-1"},
"metadata": {"vendor": "example"},
"content": {"answer": 42},
"buffers": [],
},
channel="iopub",
)
self.assertIsInstance(message, UnknownJupyterMessage)
self.assertEqual(message.msg_type, "future_extension")
self.assertEqual(message.content["answer"], 42)
self.assertEqual(message.metadata["vendor"], "example")
class AsyncTransportTests(unittest.IsolatedAsyncioTestCase):
async def test_transport_keeps_channel_readers_alive_during_a_call(self):
transport = JupyterTransport()
await transport.start()
try:
readers = set(transport._reader_tasks)
call_id = await transport.execute("2 + 2")
messages = [message async for message in transport.messages_for(call_id)]
self.assertEqual(len(readers), 3)
self.assertEqual(transport._reader_tasks, readers)
self.assertTrue(all(not reader.done() for reader in readers))
finally:
await transport.shutdown()
self.assertTrue(
any(message.msg_type == "execute_result" for message in messages)
)
async def test_transport_executes_and_returns_execute_reply(self):
transport = JupyterTransport()
await transport.start()
try:
call_id = await transport.execute("2 + 2")
messages = [message async for message in transport.messages_for(call_id)]
finally:
await transport.shutdown()
self.assertTrue(
any(message.msg_type == "execute_result" for message in messages)
)
self.assertEqual(messages[-1].msg_type, "execute_reply")
self.assertEqual(messages[-1].content.status, "ok")
async def test_transport_preserves_mime_and_errors(self):
transport = JupyterTransport()
await transport.start()
try:
display_id = await transport.execute(
"from IPython.display import display\n"
"display({'text/plain': 'hello'}, raw=True)"
)
display_messages = [
message async for message in transport.messages_for(display_id)
]
error_id = await transport.execute("raise ValueError('boom')")
error_messages = [
message async for message in transport.messages_for(error_id)
]
finally:
await transport.shutdown()
display = next(
message
for message in display_messages
if message.msg_type == "display_data"
)
self.assertEqual(display.content.data["text/plain"], "hello")
error = next(
message for message in error_messages if message.msg_type == "error"
)
self.assertEqual(error.content.ename, "ValueError")
async def test_transport_waits_for_iopub_output_after_idle_and_reply(self):
transport = JupyterTransport()
transport.client = object()
transport._active_call = "call-1"
transport._message_queue = asyncio.Queue()
transport._message_queue.put_nowait(
(
"shell",
{
"msg_id": "reply-1",
"msg_type": "execute_reply",
"parent_header": {"msg_id": "call-1"},
"metadata": {},
"content": {"status": "ok", "execution_count": 1},
"buffers": [],
},
)
)
transport._message_queue.put_nowait(
(
"iopub",
{
"msg_id": "status-1",
"msg_type": "status",
"parent_header": {"msg_id": "call-1"},
"metadata": {},
"content": {"execution_state": "idle"},
"buffers": [],
},
)
)
async def enqueue_late_display() -> None:
await asyncio.sleep(0.15)
await transport._message_queue.put(
(
"iopub",
{
"msg_id": "display-1",
"msg_type": "display_data",
"parent_header": {"msg_id": "call-1"},
"metadata": {},
"content": {
"data": {"image/png": "ZmFrZQ=="},
"metadata": {},
},
"buffers": [],
},
)
)
asyncio.create_task(enqueue_late_display())
messages = [message async for message in transport.messages_for("call-1")]
self.assertTrue(any(message.msg_type == "display_data" for message in messages))
async def test_transport_preserves_matplotlib_mime_output(self):
transport = JupyterTransport()
await transport.start()
try:
call_id = await transport.execute(
"import matplotlib.pyplot as plt\nplt.plot([1, 2, 3], [4, 5, 6])"
)
messages = [message async for message in transport.messages_for(call_id)]
finally:
await transport.shutdown()
display = next(
message for message in messages if message.msg_type == "display_data"
)
self.assertIn("image/png", display.content.data)
async def test_transport_keeps_mime_output_when_parent_forces_agg(self):
# Streamlit sets MPLBACKEND=Agg in its own process. The kernel must
# still use an inline backend so rich output survives the transport.
with patch.dict(os.environ, {"MPLBACKEND": "Agg"}):
transport = JupyterTransport()
await transport.start()
try:
call_id = await transport.execute(
"import matplotlib.pyplot as plt\n"
"plt.plot([1, 2, 3], [4, 5, 6])"
)
messages = [
message
async for message in transport.messages_for(call_id)
]
finally:
await transport.shutdown()
display = next(
message for message in messages if message.msg_type == "display_data"
)
self.assertIn("image/png", display.content.data)
async def test_transport_routes_input_reply_without_parent_stdin(self):
transport = JupyterTransport()
await transport.start()
try:
call_id = await transport.execute("answer = input('name? '); answer")
stream = transport.messages_for(call_id)
while True:
event = await asyncio.wait_for(anext(stream), timeout=5)
if isinstance(event, InputRequest):
input_request = event
break
self.assertEqual(input_request.prompt, "name? ")
await transport.reply_to_input("Ada")
messages = [message async for message in stream]
finally:
await transport.shutdown()
self.assertTrue(
any(message.msg_type == "execute_result" for message in messages)
)
if __name__ == "__main__":
unittest.main()
Generated
+430
View File
@@ -1,6 +1,11 @@
version = 1
revision = 3
requires-python = ">=3.14"
resolution-markers = [
"sys_platform == 'win32'",
"sys_platform == 'emscripten'",
"sys_platform != 'emscripten' and sys_platform != 'win32'",
]
[[package]]
name = "aiofile"
@@ -14,6 +19,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/79/6e45e778c4c3cab39e0937b007b720c15f76c50c6453d153282d0fcc3588/aiofile-3.12.3-py3-none-any.whl", hash = "sha256:5c1bcc9e929c50834608e8cc1a4cc1d7503eb60c15a535b779fd39e2f372c017", size = 22122, upload-time = "2026-08-04T22:59:25.838Z" },
]
[[package]]
name = "altair"
version = "6.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jinja2" },
{ name = "jsonschema" },
{ name = "narwhals" },
{ name = "packaging" },
{ name = "typing-extensions", marker = "python_full_version < '3.15'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/a1/5e6cc638a66da48cfc89a79c2f4810dfec00b63385f9b009ab1f069779bb/altair-6.2.2.tar.gz", hash = "sha256:a1ff9d9cfe81c75414641826312b9471780e19d39293ba0b012933f6b6cba0fe", size = 766606, upload-time = "2026-06-23T12:47:13.384Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/99/d6031f4f146298951c46b1bf1cc160c2a63f6e44b3c13a30054add100d5f/altair-6.2.2-py3-none-any.whl", hash = "sha256:94014f8ad8617c3cb163d1137359cd6db5ba134b9b46d93cfd8b609fd245a583", size = 797613, upload-time = "2026-06-23T12:47:11.451Z" },
]
[[package]]
name = "annotated-doc"
version = "0.0.5"
@@ -44,6 +65,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
]
[[package]]
name = "appnope"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/11/f7/a82489c2b6ebe32d3e2831895ae19c77861f0eadf3bb16034484d965dbb2/appnope-1.0.0.tar.gz", hash = "sha256:685db59cb6043c3c2e528adc0b3bce3a5f8d09bcf7492c6ea650d1b7421f3c49", size = 5454, upload-time = "2026-08-20T21:36:13.748Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/c7/6b687cb0f83d2a51017d47953f2ee430ffad6fec2a8ebc964e0718a33eb1/appnope-1.0.0-py3-none-any.whl", hash = "sha256:6fe0c04218aab65c54c4ff81638cdbf848d89f5653b74d68638a137f200dd16e", size = 4158, upload-time = "2026-08-20T21:36:12.444Z" },
]
[[package]]
name = "asttokens"
version = "3.0.2"
@@ -84,6 +114,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" },
]
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "cachetools"
version = "7.1.7"
@@ -292,6 +331,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "comm"
version = "0.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" },
]
[[package]]
name = "contourpy"
version = "1.3.3"
@@ -399,6 +447,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/00/d0/247938ef46dd81ab293efcac06ac2fee922db5bede9039d059912081db51/cyclopts-4.23.3-py3-none-any.whl", hash = "sha256:b3a65872942afb08f3ab5ca3d65b0b3ecfc872c9ccbea9d6a74ec11aa8a0215e", size = 237180, upload-time = "2026-08-26T18:16:34.128Z" },
]
[[package]]
name = "debugpy"
version = "1.8.21"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782", size = 2477209, upload-time = "2026-06-01T19:31:04.157Z" },
{ url = "https://files.pythonhosted.org/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e", size = 3927115, upload-time = "2026-06-01T19:31:05.863Z" },
{ url = "https://files.pythonhosted.org/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c", size = 5336724, upload-time = "2026-06-01T19:31:07.711Z" },
{ url = "https://files.pythonhosted.org/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8", size = 5373803, upload-time = "2026-06-01T19:31:09.469Z" },
{ url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" },
]
[[package]]
name = "distro"
version = "1.9.0"
@@ -605,6 +666,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" },
]
[[package]]
name = "httptools"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" },
{ url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" },
{ url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" },
{ url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" },
{ url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" },
{ url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" },
{ url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" },
{ url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" },
{ url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" },
{ url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" },
{ url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" },
{ url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" },
{ url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" },
{ url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
@@ -663,6 +746,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
]
[[package]]
name = "ipykernel"
version = "7.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "appnope", marker = "sys_platform == 'darwin'" },
{ name = "comm" },
{ name = "debugpy" },
{ name = "ipython" },
{ name = "jupyter-client" },
{ name = "jupyter-core" },
{ name = "matplotlib-inline" },
{ name = "nest-asyncio2" },
{ name = "packaging" },
{ name = "psutil" },
{ name = "pyzmq" },
{ name = "tornado" },
{ name = "traitlets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09", size = 184899, upload-time = "2026-06-10T08:41:25.481Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/02/77b271f5dc58bfbc0b577c877b2365d1ffea2afe66a80c13f2312820348c/ipykernel-7.3.0-py3-none-any.whl", hash = "sha256:897eb64da762549ef610698fca5e9675195ec6ac8ec7f19d81ce1ca20c876057", size = 120583, upload-time = "2026-06-10T08:41:23.648Z" },
]
[[package]]
name = "ipython"
version = "9.17.0"
@@ -691,28 +798,42 @@ source = { editable = "." }
dependencies = [
{ name = "fastapi" },
{ name = "fastmcp" },
{ name = "ipykernel" },
{ name = "ipython" },
{ name = "jupyter-client" },
{ name = "langchain" },
{ name = "langchain-openai" },
{ name = "matplotlib" },
{ name = "matplotlib-inline" },
{ name = "pydantic" },
{ name = "uvicorn" },
{ name = "wonderwords" },
]
[package.dev-dependencies]
dev = [
{ name = "streamlit" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.141.1" },
{ name = "fastmcp", specifier = ">=3.4.7" },
{ name = "ipykernel", specifier = ">=7.3.0" },
{ name = "ipython", specifier = ">=9.16.1" },
{ name = "jupyter-client", specifier = ">=8.10.0" },
{ name = "langchain", specifier = ">=1.3.18" },
{ name = "langchain-openai", specifier = ">=1.6.0" },
{ name = "matplotlib", specifier = ">=3.11.1" },
{ name = "matplotlib-inline", specifier = ">=0.1.7" },
{ name = "pydantic", specifier = ">=2.13.5" },
{ name = "uvicorn", specifier = ">=0.52.4" },
{ name = "wonderwords", specifier = ">=2.2.0" },
]
[package.metadata.requires-dev]
dev = [{ name = "streamlit", specifier = ">=1.37" }]
[[package]]
name = "ipython-pygments-lexers"
version = "1.1.1"
@@ -725,6 +846,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]]
name = "jaraco-classes"
version = "3.4.0"
@@ -779,6 +909,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "jiter"
version = "0.16.0"
@@ -899,6 +1041,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
[[package]]
name = "jupyter-client"
version = "8.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jupyter-core" },
{ name = "python-dateutil" },
{ name = "pyzmq" },
{ name = "tornado" },
{ name = "traitlets" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c5/2a/906772148a06e48885039e0250c340b770a55bbf37b08d6ee0449df369c3/jupyter_client-8.10.0.tar.gz", hash = "sha256:9f7116294dca55f1785be880057d44544db9b1567718d92cb33c58886afb9497", size = 360653, upload-time = "2026-08-28T12:17:10.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/88/7c548de1f6c2ade7c931a3282da73f9274fa6a1531091be682f89c85efb9/jupyter_client-8.10.0-py3-none-any.whl", hash = "sha256:5f73f24f22fa25192cfff6b23c051932a2473a797b05734aff495b392103e14e", size = 110184, upload-time = "2026-08-28T12:17:09.028Z" },
]
[[package]]
name = "jupyter-core"
version = "5.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "platformdirs" },
{ name = "traitlets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" },
]
[[package]]
name = "keyring"
version = "25.7.0"
@@ -1148,6 +1320,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "matplotlib"
version = "3.11.1"
@@ -1236,6 +1438,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" },
]
[[package]]
name = "narwhals"
version = "2.25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/7b/6248dada39781db1ab3ebf08943080df0796098515a87f6f8696d14ec744/narwhals-2.25.0.tar.gz", hash = "sha256:62c036c810662bf7820b7737077176313bc59350eeeefb808510f388c743e4b2", size = 677076, upload-time = "2026-08-20T18:10:15.454Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/dc/55481808fd70ef1567cf13540ffd4702af3f74b112e35427564b03f79c2d/narwhals-2.25.0-py3-none-any.whl", hash = "sha256:1f0f403e8c7e4463cde9bfe78b12fdd809e3ae3dda6d9b2f802934fb9c7a6a8f", size = 467373, upload-time = "2026-08-20T18:10:13.834Z" },
]
[[package]]
name = "nest-asyncio2"
version = "1.7.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b4/73/731debf26e27e0a0323d7bda270dc2f634b398e38f040a09da1f4351d0aa/nest_asyncio2-1.7.2.tar.gz", hash = "sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8", size = 14743, upload-time = "2026-02-13T00:34:04.386Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/3c/3179b85b0e1c3659f0369940200cd6d0fa900e6cefcc7ea0bc6dd0e29ffb/nest_asyncio2-1.7.2-py3-none-any.whl", hash = "sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01", size = 7843, upload-time = "2026-02-13T00:34:02.691Z" },
]
[[package]]
name = "numpy"
version = "2.5.2"
@@ -1388,6 +1608,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pandas"
version = "3.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "python-dateutil" },
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" },
{ url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" },
{ url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" },
{ url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" },
{ url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" },
{ url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" },
{ url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" },
{ url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" },
{ url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" },
{ url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" },
{ url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" },
{ url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" },
{ url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" },
{ url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" },
{ url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" },
{ url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" },
]
[[package]]
name = "parso"
version = "0.8.7"
@@ -1489,6 +1738,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" },
]
[[package]]
name = "protobuf"
version = "7.36.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a7/e7/0553e21d25ca4d9f573135775348a372c3ec34a93a71d5f297c3bac38341/protobuf-7.36.0.tar.gz", hash = "sha256:e8e09cb0d794c6687926fa558a8a6e72aa10edb997d5ca61da0765f12a3e00ea", size = 510034, upload-time = "2026-08-20T16:34:01.071Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/ae/58e3ca96cb2e118cc546b677359b3c6659f79a140935c08dec94c7998585/protobuf-7.36.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:9103532dffd80c6fab7e50c65a31007680a06eb57537d437bb1b35812c138a37", size = 453256, upload-time = "2026-08-20T16:33:53.945Z" },
{ url = "https://files.pythonhosted.org/packages/f0/15/5162230af4912697f0fe406f6800f80760945babcff0e2c2fe6c84ef2d5d/protobuf-7.36.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:bf94a5917c71058262de683669bc0a797a7669d3de71f0b36d058e3194f47b44", size = 341436, upload-time = "2026-08-20T16:33:55.134Z" },
{ url = "https://files.pythonhosted.org/packages/d7/09/1670b2bfc9a45e807e520c3e9be36524db9ccc7dc05ea17af7681cabdc61/protobuf-7.36.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:3297e60abdff301e5f74393d87f6cc59dacab5f024a89548a6e8de1d26576b16", size = 354440, upload-time = "2026-08-20T16:33:56.077Z" },
{ url = "https://files.pythonhosted.org/packages/c7/f8/bd5804695ba400e423c33fd4d9f58c28d86633d5ba1945c36ff3967d98cb/protobuf-7.36.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:70f5ec8eb0da81a44360c0dc0beac99a0d78071d21956a7076bae8bd2051841b", size = 340439, upload-time = "2026-08-20T16:33:56.992Z" },
{ url = "https://files.pythonhosted.org/packages/ef/9f/acd02338235a3e7d03168c4303478347b7624fc8189ff4e7f0d2654bbe86/protobuf-7.36.0-cp310-abi3-win32.whl", hash = "sha256:7326fd717bdc419162a735938d89d4032332bcc3408804012b24ff3a37086071", size = 440216, upload-time = "2026-08-20T16:33:57.99Z" },
{ url = "https://files.pythonhosted.org/packages/0e/4e/12cb93270967a2affff5b3f720694700d4d87712a67afd05c8cb3f6fa52c/protobuf-7.36.0-cp310-abi3-win_amd64.whl", hash = "sha256:1781cc1de61249b750848029bca452c0a8b7e990080316b9bbc2518b2117b488", size = 453731, upload-time = "2026-08-20T16:33:58.951Z" },
{ url = "https://files.pythonhosted.org/packages/01/c3/629999e78d46c1115c11886d51c6bd68c17ce4a944f1ea3e153a91316a33/protobuf-7.36.0-py3-none-any.whl", hash = "sha256:53374d53fc29a67f7dbbf0ade47d7526a0f0137bf0f9c90e48d8a60790ef748c", size = 177024, upload-time = "2026-08-20T16:34:00.053Z" },
]
[[package]]
name = "psutil"
version = "7.2.2"
@@ -1554,6 +1818,28 @@ memory = [
{ name = "cachetools" },
]
[[package]]
name = "pyarrow"
version = "25.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3d/e3/27f57f80141379d60defe6703eb50a707325706f07fedfd1312c7a751995/pyarrow-25.0.1.tar.gz", hash = "sha256:9150a83248bfed9813ea3c3af74c3856c1984d444aa28e58bf7733b9750ddf6a", size = 1201653, upload-time = "2026-08-10T12:40:53.904Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/36/4c/b525824ad3094076919273cd97db61fb3d78252dee76fa3b8dc8f76774aa/pyarrow-25.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bf0b672390cdcb640d7288f96b826d71ff4e9abb254a86c89890baf51a29cee6", size = 35885255, upload-time = "2026-08-10T12:39:32.366Z" },
{ url = "https://files.pythonhosted.org/packages/08/62/448bb0e940de41aec31d1a956e63ad9c54afdf122a103cc3ab20c2a3ce33/pyarrow-25.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:38a9a4b4b9613380e200641891495a56c3d5a98a092db4a870af9975e220471d", size = 37644461, upload-time = "2026-08-10T12:39:38.142Z" },
{ url = "https://files.pythonhosted.org/packages/6e/9a/13587e38bd4806fd218f50fd13b8903fab60588a699ff0c406372e5b4043/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b726ad7e7b669be982b0c71c07fe4b037d654354130da79a7902a669e93a66b", size = 46877146, upload-time = "2026-08-10T12:39:43.722Z" },
{ url = "https://files.pythonhosted.org/packages/8d/61/1c5d1229fa21da4cff5365e41e57177aaac57c563c727f35419b8513d1c1/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:9171748cdf796972d85a4b60157c279913e242992e350c90c7450182a9838b2a", size = 50131616, upload-time = "2026-08-10T12:39:49.304Z" },
{ url = "https://files.pythonhosted.org/packages/43/20/291e1d65cc0b09aa19f03cf25cf51a2f5fa94b5db315178f2d254ed5cad4/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b7a296aac7a71fa0886c08e155ddb6c636a50013f801f6178daafa0f9e726188", size = 50008879, upload-time = "2026-08-10T12:39:56.891Z" },
{ url = "https://files.pythonhosted.org/packages/8b/7c/1b7c9ec28e76576337e4f97b31141c9a181b89b6d1d6221e9d8205621a58/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0fe7c8b6c03969b49c8c66182e4a18e3819ab92d07cfab5d8370c531b9369ef0", size = 53170864, upload-time = "2026-08-10T12:40:04.918Z" },
{ url = "https://files.pythonhosted.org/packages/b7/75/f3d789dc06011a765d14d86bda799cf72ac1d715b6a6edecaa0d73d95062/pyarrow-25.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:f729cfdbd36fd99d543b67a914d2de044c84ebe45be8b34902b299b608c15c8f", size = 28620729, upload-time = "2026-08-10T12:40:51.41Z" },
{ url = "https://files.pythonhosted.org/packages/fc/05/647a8ee6f7c2662feb6921315617bc04dcd6034763fb61b1199720bf6162/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:59a2de54c0cbd954da861eee4d1d330f8e909c45b53455baef696380f2c55033", size = 36130288, upload-time = "2026-08-10T12:40:11.014Z" },
{ url = "https://files.pythonhosted.org/packages/93/f8/c9ee997554d7bea94520667dd1933f109ac1da3ee3556d2b49381e023484/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:35935cd5de130aa5cf4dea052a63e6bf2e17006c35c3a468194242b9b2bf5956", size = 37762187, upload-time = "2026-08-10T12:40:16.592Z" },
{ url = "https://files.pythonhosted.org/packages/a2/08/a28c01c7fe9e96e8233ce2d13df1d402f4f999f848f51d2daacd6bb4c036/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f3831aaa25c67a99f99dc8b05873cb9d64560390372e2aa197ce9dd4a3f06a44", size = 46888003, upload-time = "2026-08-10T12:40:23.242Z" },
{ url = "https://files.pythonhosted.org/packages/1b/b9/58612e977d28dc58c878448866838369ee8da2f1e7cc8ed2c84b952aafee/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6a1fdfc6659b6b19022f2e50627fb5cf7156a66c46bf4299379955cbe742382a", size = 50079036, upload-time = "2026-08-10T12:40:29.169Z" },
{ url = "https://files.pythonhosted.org/packages/72/13/66e1402dcc860e1dc2760b1e0292c9a569b62b3bccab69def1b3e907d006/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:169d3429d5be7c752125890620f75a60776d38b0035eddae939651640822332e", size = 50040226, upload-time = "2026-08-10T12:40:35.186Z" },
{ url = "https://files.pythonhosted.org/packages/78/10/3f1a5497a7ef732ab0f03ecca3e66d89d9c0f57fdc61b4794c456b781f01/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:119297a6dc197e45d9c6d4415f7814a67ffa36c180d26f68c154c58067ae782d", size = 53149035, upload-time = "2026-08-10T12:40:41.454Z" },
{ url = "https://files.pythonhosted.org/packages/93/c0/37d4a7e8e2f7a6076283673d5298018ca26478b934c6ee369e10505ab32c/pyarrow-25.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4288f27577352d608ca08553b0865e4a9b3aa14820c5d95b53337218d609835b", size = 28753071, upload-time = "2026-08-10T12:40:46.623Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
@@ -1638,6 +1924,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" },
]
[[package]]
name = "pydeck"
version = "0.9.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jinja2" },
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f4/c9/f71032fca47ecc09d30904d9a610234b07139f89eabc2f054b141edcc30f/pydeck-0.9.3.tar.gz", hash = "sha256:695775cbfe51f5fdffbd9735ba469987fdc5efc96bc40a0ee4808170509c78b2", size = 5900912, upload-time = "2026-07-02T23:27:08.704Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/34/3998411437aff304a9ed4fa37a6fe1ef3132bcd2b5eac59851b80c86123c/pydeck-0.9.3-py2.py3-none-any.whl", hash = "sha256:d8a47c11c81fb12d51b1feb42427ff4f0e13cb599e48931021b2cba98b6849a6", size = 11428091, upload-time = "2026-07-02T23:27:06.399Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
@@ -1757,6 +2056,52 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "pyzmq"
version = "27.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "implementation_name == 'pypy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e7/8d/5b3d5631c2f4b4b8862f64cd0c9eb777b5710eeb5125b4be8dd0a200a4c0/pyzmq-27.2.0.tar.gz", hash = "sha256:54d4259d1bfae24ecdb5ca79f7acc2eac6c286a02d6a0ae617797cb45f0726d3", size = 292316, upload-time = "2026-08-20T19:08:21.19Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/57/8a/153532fa53db30e116118164f3af269a1f3966b3e2ba32c89b12fe864bd8/pyzmq-27.2.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:591c8de5851c5ea372194469fe97587b97c3b641e9a70f31bb3474acbfde0241", size = 1431074, upload-time = "2026-08-20T19:06:40.601Z" },
{ url = "https://files.pythonhosted.org/packages/c8/ef/c08b91248bb90a9efa81fa00ba81b69c157c74d0c5efbb2c319d91babb62/pyzmq-27.2.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:00e73942ef12cecbc7951c4a9104bb8ffaed742abb13af2da6833d90dd368cef", size = 973915, upload-time = "2026-08-20T19:06:42.037Z" },
{ url = "https://files.pythonhosted.org/packages/b4/78/a3a3a86c2b00fadb92ece1ca4f8f028d62b2ce9ac3526097239ab2d6fba9/pyzmq-27.2.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f8079d0521fe94bbb401fe9407578b28f3701627c8be2c9f7e0c5b77dcb0109", size = 697722, upload-time = "2026-08-20T19:06:43.325Z" },
{ url = "https://files.pythonhosted.org/packages/62/2c/d5828306f795e8d34676d266823b74e2101e0ad3760d12083de3e02abbb2/pyzmq-27.2.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dea74fd65f1fc5f7fe167916a473ebe6ed6174e5e5d9de11ea6583661be6cf43", size = 872258, upload-time = "2026-08-20T19:06:44.627Z" },
{ url = "https://files.pythonhosted.org/packages/09/52/51253b78fd8739293e283407eeecb14215c02c71b6519af21f6eed8e69cd/pyzmq-27.2.0-cp312-abi3-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcc99ca132b667a4ed750afd42db4ea73288f18425a9b2e3c0af095665c491f5", size = 739591, upload-time = "2026-08-20T19:06:46.214Z" },
{ url = "https://files.pythonhosted.org/packages/e6/3e/142c85b67a4c9678629b0cf6d5125b29663d75be69bfaa57a3cac344d780/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b8d5f66e4a8246cf77f7b8f7902af64f00553368fa0373c89d99b78f0ad79394", size = 1689031, upload-time = "2026-08-20T19:06:47.612Z" },
{ url = "https://files.pythonhosted.org/packages/0e/ee/0776fb0f98ed1eb74d77240087fef0ab045b6ad15cb09555c6c5134c98ad/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:d1526b42a2e725b84ed226f37becedc250c6347594e5ed304e4e9aff68c9aec3", size = 2059547, upload-time = "2026-08-20T19:06:49.064Z" },
{ url = "https://files.pythonhosted.org/packages/aa/0e/ec77f691a4aebe29ab6329f996fb0e0270c876a3016086e3ca6ef733bcae/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f707bcf2c1d007d14d70531d4dd7b41060881c73efa845580bf6faaf9ea24d42", size = 1910457, upload-time = "2026-08-20T19:06:50.783Z" },
{ url = "https://files.pythonhosted.org/packages/30/97/1f5530ff4fc271b4597048371d5af972c2baab51be132ba15874e0327a6a/pyzmq-27.2.0-cp312-abi3-win32.whl", hash = "sha256:fdaaa4ea3242f6ad298eb5177eb042aea5c73c30e76d20caee7b15af20d24ec2", size = 563450, upload-time = "2026-08-20T19:06:52.307Z" },
{ url = "https://files.pythonhosted.org/packages/02/8b/b83f7780dad22e0878e4c7bd9158ebd24ed12bc3d5e3a471cd0576f77ded/pyzmq-27.2.0-cp312-abi3-win_amd64.whl", hash = "sha256:2c218c6ab8bc447ba62054b581fd30209689d199c6ecb253f79615ca74a38e12", size = 628633, upload-time = "2026-08-20T19:06:53.809Z" },
{ url = "https://files.pythonhosted.org/packages/52/aa/3918b5ac7f9987bd9c421b065074fd7409ded88f856f2c704a24341877ec/pyzmq-27.2.0-cp312-abi3-win_arm64.whl", hash = "sha256:348d6fd3e4b81ae4580622ea8c2ea60224e84b2ac1b3be4482e6edc7de06e7a3", size = 556006, upload-time = "2026-08-20T19:06:55.242Z" },
{ url = "https://files.pythonhosted.org/packages/f9/84/a849161ff88b2de9b991cc8ab332218824741122fdc4fdf222a5b822ac8c/pyzmq-27.2.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:3d45189c0c3c99f817b7fefff0d32eeef684cf33e1e3c0fc4281515357c54702", size = 1134452, upload-time = "2026-08-20T19:06:59.898Z" },
{ url = "https://files.pythonhosted.org/packages/3c/34/ff4aaff0cfba2a4d7ad1a16ffedc52c6deb89fcf673d455085446b23f215/pyzmq-27.2.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d61910b52be5b2cd8b248dbcbe3a1b0275556a7d99fb613fc43323b546e273b8", size = 1167520, upload-time = "2026-08-20T19:07:01.283Z" },
{ url = "https://files.pythonhosted.org/packages/b6/07/42111e9dc1041d78b4443d6eb1b82b027f1a58178dc8a38385effbc72ad5/pyzmq-27.2.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:3ab6eb88590e510ab16715c32dbba12000da9bee989fdadd9ee19a234c492eb7", size = 1466289, upload-time = "2026-08-20T19:07:02.738Z" },
{ url = "https://files.pythonhosted.org/packages/4b/b4/def7a478458da78665840564161772e7e938600c32a89f28e8b221b54d2d/pyzmq-27.2.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1ecbdd131b9669f62d3a45afee5527c7ae9f141e4301267f21714c90bd21725f", size = 975868, upload-time = "2026-08-20T19:07:04.155Z" },
{ url = "https://files.pythonhosted.org/packages/38/d5/e3e85f7fea37153097aaff49db9e33093909cc2a7b22c1ac4ebe546600fc/pyzmq-27.2.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3146385b94a760236c5eceff468a66a296a716ca98a2e0f9217b1518118466b1", size = 706054, upload-time = "2026-08-20T19:07:05.623Z" },
{ url = "https://files.pythonhosted.org/packages/1c/ef/3b7d9449b223183222bf517245e1e53d5f1ab8c10be8b45f6a301b2f994a/pyzmq-27.2.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9846e881620dd62566ca76a53e384c3f37490faf4b9240aebc7498810dfca853", size = 878984, upload-time = "2026-08-20T19:07:07.153Z" },
{ url = "https://files.pythonhosted.org/packages/be/a5/8b49dbd494f6dcfda69dc4cade322a4b02706ef4e3d30cc366d4e369899f/pyzmq-27.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d9527e3dbaef1edaeeb2446fa7379446814a43ade8adc7c4a5ebe69437815ddd", size = 1697489, upload-time = "2026-08-20T19:07:08.945Z" },
{ url = "https://files.pythonhosted.org/packages/da/5a/4bb8280901130c26ea25f0cbb4a6d39d94250860c6b3dbd912f1cf48fca7/pyzmq-27.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:56b48fa9d478a3af7254f397697a62f5ad3e1bb677e200b2701f0c290d97e5af", size = 2064236, upload-time = "2026-08-20T19:07:10.384Z" },
{ url = "https://files.pythonhosted.org/packages/de/38/f433af66922554adb2b5f79e897018c8e19a90b9eaeb49c4814f8355ebe4/pyzmq-27.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bf0b6e4ce1bb089751c504c5493d6b0557eabd02dd21b76e9086cf964234b103", size = 1917424, upload-time = "2026-08-20T19:07:11.909Z" },
{ url = "https://files.pythonhosted.org/packages/36/81/ea1c1ae3f801d96ba2c269e056761ebcfe023476e651d3af2a7817962051/pyzmq-27.2.0-cp314-cp314t-win32.whl", hash = "sha256:fba8afcf265c6e9fbe1594cb045d4765c6c9a7d607653a8196067ef23566b843", size = 591103, upload-time = "2026-08-20T19:07:13.451Z" },
{ url = "https://files.pythonhosted.org/packages/8a/04/149a627707e780fa9f2c1ede3590c14fa6b18b5576d15744342622299a50/pyzmq-27.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d1bc1d380a91d954ed5fc9f12915dba014eed0978d2de05ee7ca688bdaac144a", size = 670215, upload-time = "2026-08-20T19:07:15.069Z" },
{ url = "https://files.pythonhosted.org/packages/30/ba/f9c3c1536c41ef3dbf765ea04218990e2056e558f98184ecd883767fc501/pyzmq-27.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c7cfb75caa83f5153c687e9d2107f64b5ef0ef0d6edd260d3ff920baaaa69101", size = 582252, upload-time = "2026-08-20T19:07:16.582Z" },
{ url = "https://files.pythonhosted.org/packages/fa/00/78fe097a304a408275747ce43f20428789130b059c5649956277c20f30cf/pyzmq-27.2.0-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:c5129a8fe43ecc49b99eb75616603d483a3c2fcaef504988fafe8ea392aea98b", size = 1134295, upload-time = "2026-08-20T19:07:17.94Z" },
{ url = "https://files.pythonhosted.org/packages/f2/83/1c36270658d2ee56e23a3f9ef5fbcb94cbd2f9fe966a6641f2f38e697162/pyzmq-27.2.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:baa2ce3485145653194d6c8c5beedd1e9f0bf46a0919c9fa2fe2204fc35b74d9", size = 1167492, upload-time = "2026-08-20T19:07:19.476Z" },
{ url = "https://files.pythonhosted.org/packages/58/b2/f0ae223438d7faa991f6feefdc823815f11cc604f898738376b59fd96515/pyzmq-27.2.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:e1ed46048d1920cabc96d952a0d5cfe4127ad8db572c335aae4e3c57b9278d7f", size = 1465992, upload-time = "2026-08-20T19:07:20.941Z" },
{ url = "https://files.pythonhosted.org/packages/59/46/fb56f3f37a6a0937b0e1d2885e808b5eedc171320bac85573cfae78fa9bc/pyzmq-27.2.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e0fa0bc6b1a184aee59b32efcd1b7f0e6d5b8f9387799e4c16a4cb66a86747d6", size = 976118, upload-time = "2026-08-20T19:07:22.577Z" },
{ url = "https://files.pythonhosted.org/packages/21/82/a2c9bfd7c4d34eea1278493cd041bc000d41acb4463c89ceaad29dc813b6/pyzmq-27.2.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4bd6743e8bf854c3bfce892dd6578a514aabf128e37a4b2eafcf01856f7e44", size = 705968, upload-time = "2026-08-20T19:07:24.019Z" },
{ url = "https://files.pythonhosted.org/packages/d6/12/b906b269116b6591dc15c0acc5d04c043957c8a531d336999731f4b1d899/pyzmq-27.2.0-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95369ed6626afcfe2ac89832fb1b917c077fbeb905fbbe5d918349ce0222b89b", size = 879011, upload-time = "2026-08-20T19:07:25.428Z" },
{ url = "https://files.pythonhosted.org/packages/12/13/f96359534bfb77651c15f1fbfc4bfdd7ec3489d23f434706d39598dd0dcd/pyzmq-27.2.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:40124779c3a56ad5d91902df1ff89159cb414b6c1a0ee697abcc66cf5e6db62d", size = 1697496, upload-time = "2026-08-20T19:07:26.821Z" },
{ url = "https://files.pythonhosted.org/packages/21/b4/2c007ae5f2fe5eca86cbfbc874ed86b5135f2f7812615dfd78606d3c93f6/pyzmq-27.2.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:ec8a318dfc27c7d946651b3d9e8025d5734f30c168a822195601827207bac09b", size = 2064347, upload-time = "2026-08-20T19:07:28.315Z" },
{ url = "https://files.pythonhosted.org/packages/9b/88/767af3a6630c15215f3a66700ec79598a375edd1fdc9d75a3ad522178c01/pyzmq-27.2.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:88c0fac061bac269076edeb3a209acefc96cd6167c239daf1c2b404ac48d7012", size = 1917360, upload-time = "2026-08-20T19:07:29.693Z" },
{ url = "https://files.pythonhosted.org/packages/35/c1/80dd2d20d6e57bc68e1dce1e84bf3e76c9577c1bf728199985c8b4ea0fd1/pyzmq-27.2.0-cp315-cp315t-win32.whl", hash = "sha256:ac126d48cf18aa955daabef43bf0009ff76ad4deee437d09ecf15388214b5beb", size = 591073, upload-time = "2026-08-20T19:07:31.341Z" },
{ url = "https://files.pythonhosted.org/packages/f8/b5/33b781666f3f52ae834bc9c8e38f4f0483a826c5a91cccc993292007bf10/pyzmq-27.2.0-cp315-cp315t-win_amd64.whl", hash = "sha256:edce90a1e588ec63adbf612cc0ad582de4169cd216c7ae53c15f42a2ee902f35", size = 670701, upload-time = "2026-08-20T19:07:32.895Z" },
{ url = "https://files.pythonhosted.org/packages/6e/97/bc4f0edefb992df4fdebcf9f0cc40f631cd4ed277e1ed59ef2cd99a5c8c5/pyzmq-27.2.0-cp315-cp315t-win_arm64.whl", hash = "sha256:a843094b4d3d633bc3623e47a2ff50742d6af02bc1f7606aa2e67e971e21878d", size = 581985, upload-time = "2026-08-20T19:07:34.19Z" },
]
[[package]]
name = "referencing"
version = "0.37.0"
@@ -1999,6 +2344,38 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
]
[[package]]
name = "streamlit"
version = "1.62.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "altair" },
{ name = "anyio" },
{ name = "blinker" },
{ name = "click" },
{ name = "httptools" },
{ name = "itsdangerous" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "pandas" },
{ name = "pillow" },
{ name = "protobuf" },
{ name = "pyarrow" },
{ name = "pydeck" },
{ name = "python-multipart" },
{ name = "requests" },
{ name = "starlette" },
{ name = "toml" },
{ name = "typing-extensions" },
{ name = "uvicorn" },
{ name = "watchdog", marker = "sys_platform != 'darwin'" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/94/928c44a8b7bcd602fc4a16025e9868bcdb88b92bcdb2e53dec188d034fc4/streamlit-1.62.0.tar.gz", hash = "sha256:9d2571da6e6799cbaf0f59548f5773926260a87a69807cf3e2f0f68f9f5e4d45", size = 9883501, upload-time = "2026-08-19T18:31:22.864Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/57/78864764c53885db8a378cc1c47329b4b6e095f7ebd89cd1ffcca4027c89/streamlit-1.62.0-py3-none-any.whl", hash = "sha256:294dbcfe0d6531b0d8593a095e6872dcc6ec4b731723fbb318a0f8102e69162e", size = 10490146, upload-time = "2026-08-19T18:31:19.957Z" },
]
[[package]]
name = "tenacity"
version = "9.1.4"
@@ -2048,6 +2425,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/35/5e/9b01afd037bfa22a0033963fa091e0f75b6fb15cd85bffb42ff86e697323/tiktoken-0.14.0-cp315-cp315t-win_amd64.whl", hash = "sha256:561e7580f84a79859af1ef6f676968e9030fcc3fe195700b15235bca64f009c9", size = 987929, upload-time = "2026-08-17T19:49:38.947Z" },
]
[[package]]
name = "toml"
version = "0.10.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" },
]
[[package]]
name = "tornado"
version = "6.5.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/10/d3/343e5bb989d6515b1646cf3d40135d73f3d5e45339bded401b56cdac24dd/tornado-6.5.8.tar.gz", hash = "sha256:9452e1b208a8bd771e2cb1f2ff564985b9b214bdebbe622793e1799e0a6bd23f", size = 520493, upload-time = "2026-08-07T02:12:42.971Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/d5/007086fd8df5489338e204f65adce33fd4f21a4999dbb2b9cff2f897b5f4/tornado-6.5.8-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:cc6aa787d7cfab7c3d35189dc7a56fbd2399a569624c730c6b55b3d6531d0403", size = 449487, upload-time = "2026-08-07T02:12:28.682Z" },
{ url = "https://files.pythonhosted.org/packages/70/c8/5a24a99495903f594f6a199dd7beead1cbc0a13e2cb9102727bcaaf2a997/tornado-6.5.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9715b5eb79735b2bcd454ce216a9275b7c0470e64ea1bf5742f78b2f72b26eeb", size = 447649, upload-time = "2026-08-07T02:12:30.306Z" },
{ url = "https://files.pythonhosted.org/packages/6e/de/f2e733f386b85962d1b1dc82cd63d169b5b4580062b35397eac9244a41fe/tornado-6.5.8-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:547d63f450d570c14fe0e8db2cfb14c9bbd1c2503b4a6612586267955aa47b58", size = 450707, upload-time = "2026-08-07T02:12:31.95Z" },
{ url = "https://files.pythonhosted.org/packages/0b/94/20efeee9a01c141e9ac47c397f81679dfda24b32768fc4fff24e76d36c2c/tornado-6.5.8-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e2360a0ffbe145eca8af0b19cb7203d79b1a98dd4cccdd6b368f6f49c2e3808", size = 451677, upload-time = "2026-08-07T02:12:33.512Z" },
{ url = "https://files.pythonhosted.org/packages/42/ec/a96ccb8ccf0de2b7bc2c5fa1608a4803735018242e90c4882365a9fd418f/tornado-6.5.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5d242290bdf7ab3151bc1065fdd75c0dcc21cbc7b49f22a4c56329c2d6566d22", size = 451510, upload-time = "2026-08-07T02:12:35.346Z" },
{ url = "https://files.pythonhosted.org/packages/29/b5/93185859245ad3f00e62175f29607346788b696369347f0146e0421286bb/tornado-6.5.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7b94ff0e128fe0542f3bd331fb44d06260fc4ac16881545159f34ef08aad4195", size = 450917, upload-time = "2026-08-07T02:12:36.963Z" },
{ url = "https://files.pythonhosted.org/packages/97/cf/fe33cf062834487d34d1559746a4a12521033c22645b6d74d4bca702e018/tornado-6.5.8-cp39-abi3-win32.whl", hash = "sha256:67832909c4779c64942380cb5f044a5c6163d00831472d80e25e115de9917836", size = 451952, upload-time = "2026-08-07T02:12:38.512Z" },
{ url = "https://files.pythonhosted.org/packages/cb/e1/468ad54333e92ccb62627e62cb88e5fc14a2171daa67ed47b1b8542d5b86/tornado-6.5.8-cp39-abi3-win_amd64.whl", hash = "sha256:11881db6b7c168494be2c2d12e65931451bdf7ee718535418ae1d8855dd5a0ee", size = 452391, upload-time = "2026-08-07T02:12:39.971Z" },
{ url = "https://files.pythonhosted.org/packages/ad/3e/cd5e4f06e34cde33b8ef66cf36aa2b5ad46354cc1af7d2136bbe365fee1d/tornado-6.5.8-cp39-abi3-win_arm64.whl", hash = "sha256:68a7468c7e289f8514d7d664101753903217eff1bb6822c6b5994a0b5f5bcb26", size = 451411, upload-time = "2026-08-07T02:12:41.469Z" },
]
[[package]]
name = "traitlets"
version = "5.16.1"
@@ -2087,6 +2490,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
name = "tzdata"
version = "2026.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" },
]
[[package]]
name = "uncalled-for"
version = "0.4.0"
@@ -2155,6 +2567,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" },
]
[[package]]
name = "watchdog"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
{ url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
{ url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
{ url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
{ url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
{ url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
{ url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
{ url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
{ url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
]
[[package]]
name = "watchfiles"
version = "1.2.0"