diff --git a/.streamlit/config.toml b/.streamlit/config.toml new file mode 100644 index 0000000..658868f --- /dev/null +++ b/.streamlit/config.toml @@ -0,0 +1,2 @@ +[browser] +gatherUsageStats = false diff --git a/pyproject.toml b/pyproject.toml index e62dab2..eb9f755 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "langchain-openai>=1.6.0", "matplotlib>=3.11.1", "matplotlib-inline>=0.1.7", + "pydantic>=2.13.5", "uvicorn>=0.52.4", "wonderwords>=2.2.0", ] @@ -25,6 +26,7 @@ ipython-shell = "ipython_shell.app:main" ipython-webapp = "ipython_webapp:main" ipython-jupyter-webapp = "ipython_webapp.jupyter.app:main" ipython-mcp = "ipython_mcp.app:main" +ipython-st-demo = "st_demo.app:main" [build-system] requires = ["uv_build>=0.12.6,<0.13.0"] @@ -32,3 +34,8 @@ build-backend = "uv_build" [tool.uv.build-backend] module-name = "ipython_shell" + +[dependency-groups] +dev = [ + "streamlit>=1.37", +] diff --git a/src/ipython_shell/__init__.py b/src/ipython_shell/__init__.py index 8d666b4..b22d9ad 100644 --- a/src/ipython_shell/__init__.py +++ b/src/ipython_shell/__init__.py @@ -8,7 +8,8 @@ from .events import ( ) from .jupyter.app import JupyterApp from .jupyter.shell import JupyterShell -from .jupyter.transport import InputRequest, JupyterTransport, KernelMessage +from .jupyter.messages import JupyterMessage, KernelMessage, ParsedJupyterMessage +from .jupyter.transport import InputRequest, JupyterTransport from .models import ( AppInfo, CallError, @@ -47,7 +48,9 @@ __all__ = [ "JupyterApp", "JupyterShell", "JupyterTransport", + "JupyterMessage", "KernelMessage", + "ParsedJupyterMessage", "Shell", "ShellInfo", "ShellRegistry", diff --git a/src/ipython_shell/jupyter/__init__.py b/src/ipython_shell/jupyter/__init__.py index 3fe577c..1cf1724 100644 --- a/src/ipython_shell/jupyter/__init__.py +++ b/src/ipython_shell/jupyter/__init__.py @@ -1,11 +1,38 @@ from .app import JupyterApp +from .messages import ( + DisplayDataMessage, + ErrorMessage, + ExecuteReplyMessage, + ExecuteResultMessage, + InputRequestMessage, + JupyterMessage, + KernelMessage, + KnownJupyterMessage, + ParsedJupyterMessage, + StatusMessage, + StreamMessage, + UnknownJupyterMessage, + parse_jupyter_message, +) from .shell import JupyterShell -from .transport import InputRequest, JupyterTransport, KernelMessage +from .transport import InputRequest, JupyterTransport __all__ = [ "InputRequest", + "DisplayDataMessage", + "ErrorMessage", + "ExecuteReplyMessage", + "ExecuteResultMessage", + "InputRequestMessage", "JupyterApp", + "JupyterMessage", + "KernelMessage", "JupyterShell", "JupyterTransport", - "KernelMessage", + "KnownJupyterMessage", + "ParsedJupyterMessage", + "StatusMessage", + "StreamMessage", + "UnknownJupyterMessage", + "parse_jupyter_message", ] diff --git a/src/ipython_shell/jupyter/app.py b/src/ipython_shell/jupyter/app.py index ad888f4..f548c30 100644 --- a/src/ipython_shell/jupyter/app.py +++ b/src/ipython_shell/jupyter/app.py @@ -6,8 +6,9 @@ from uuid import uuid4 from ..models import AppInfo, ShellInfo from ..registry import ShellRegistry from ..utils import generate_call_id +from .messages import ParsedJupyterMessage from .shell import JupyterShell -from .transport import InputRequest, KernelMessage +from .transport import InputRequest class JupyterApp: @@ -74,7 +75,7 @@ class JupyterApp: self, code: str, shell_name: str = "last", - ) -> AsyncIterator[KernelMessage | InputRequest]: + ) -> AsyncIterator[ParsedJupyterMessage | InputRequest]: """Execute code and yield its subprocess messages.""" _, shell = self._select_shell(shell_name) call_id = self._new_call_id() diff --git a/src/ipython_shell/jupyter/messages.py b/src/ipython_shell/jupyter/messages.py new file mode 100644 index 0000000..80d5c08 --- /dev/null +++ b/src/ipython_shell/jupyter/messages.py @@ -0,0 +1,260 @@ +from typing import Annotated, ClassVar, Final, Literal + +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter + + +type JupyterChannel = Literal["iopub", "shell", "stdin"] +type MessageStatus = Literal["ok", "error", "abort"] + +STREAM: Final[Literal["stream"]] = "stream" +DISPLAY_DATA: Final[Literal["display_data"]] = "display_data" +UPDATE_DISPLAY_DATA: Final[Literal["update_display_data"]] = "update_display_data" +EXECUTE_RESULT: Final[Literal["execute_result"]] = "execute_result" +ERROR: Final[Literal["error"]] = "error" +STATUS: Final[Literal["status"]] = "status" +EXECUTE_INPUT: Final[Literal["execute_input"]] = "execute_input" +CLEAR_OUTPUT: Final[Literal["clear_output"]] = "clear_output" +EXECUTE_REPLY: Final[Literal["execute_reply"]] = "execute_reply" +INPUT_REQUEST: Final[Literal["input_request"]] = "input_request" + + +class MimeContent(BaseModel): + """The shared payload for rich display messages.""" + + data: dict[str, object] + metadata: dict[str, object] = Field(default_factory=dict) + transient: dict[str, object] | None = None + + +class ExecuteResultContent(MimeContent): + """Rich display data plus the kernel execution counter.""" + + execution_count: int + + +class StreamContent(BaseModel): + """A stdout or stderr chunk emitted by the kernel.""" + + name: Literal["stdout", "stderr"] + text: str + + +class ErrorContent(BaseModel): + """An exception reported on the IOPub channel.""" + + ename: str + evalue: str + traceback: list[str] + + +class ExecuteReplyContent(BaseModel): + """The terminal shell-channel reply for an execution request.""" + + status: MessageStatus + execution_count: int | None = None + ename: str | None = None + evalue: str | None = None + traceback: list[str] | None = None + + +class StatusContent(BaseModel): + """Kernel busy/idle state from an IOPub status message.""" + + execution_state: Literal["busy", "idle", "starting"] + + +class InputRequestContent(BaseModel): + """The prompt and echo policy for a kernel input request.""" + + prompt: str + password: bool = False + + +class ExecuteInputContent(BaseModel): + """The code and execution counter echoed by the kernel.""" + + code: str + execution_count: int + + +class ClearOutputContent(BaseModel): + """Whether a clear-output request should wait for new output.""" + + wait: bool = False + + +class JupyterMessage(BaseModel): + """Common Jupyter envelope, also capable of holding unknown messages. + + The protocol's message vocabulary is extensible, so ``msg_type`` stays a + string on this raw-compatible model. Known messages are narrowed into the + typed subclasses below by :func:`parse_jupyter_message`. + """ + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="allow") + + channel: JupyterChannel = "iopub" + msg_type: str + message_id: str = "" + parent_id: str | None = None + metadata: dict[str, object] = Field(default_factory=dict) + content: dict[str, object] = Field(default_factory=dict) + buffers: list[bytes] = Field(default_factory=list) + + +class _TypedMessage(BaseModel): + """Common fields for typed messages without narrowing mutable fields.""" + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="allow") + + message_id: str = "" + parent_id: str | None = None + metadata: dict[str, object] = Field(default_factory=dict) + buffers: list[bytes] = Field(default_factory=list) + + +class StreamMessage(_TypedMessage): + msg_type: Literal["stream"] = STREAM + channel: Literal["iopub"] = "iopub" + content: StreamContent + + +class DisplayDataMessage(_TypedMessage): + msg_type: Literal["display_data"] = DISPLAY_DATA + channel: Literal["iopub"] = "iopub" + content: MimeContent + + +class UpdateDisplayDataMessage(_TypedMessage): + msg_type: Literal["update_display_data"] = UPDATE_DISPLAY_DATA + channel: Literal["iopub"] = "iopub" + content: MimeContent + + +class ExecuteResultMessage(_TypedMessage): + msg_type: Literal["execute_result"] = EXECUTE_RESULT + channel: Literal["iopub"] = "iopub" + content: ExecuteResultContent + + +class ErrorMessage(_TypedMessage): + msg_type: Literal["error"] = ERROR + channel: Literal["iopub"] = "iopub" + content: ErrorContent + + +class StatusMessage(_TypedMessage): + msg_type: Literal["status"] = STATUS + channel: Literal["iopub"] = "iopub" + content: StatusContent + + +class ExecuteInputMessage(_TypedMessage): + msg_type: Literal["execute_input"] = EXECUTE_INPUT + channel: Literal["iopub"] = "iopub" + content: ExecuteInputContent + + +class ClearOutputMessage(_TypedMessage): + msg_type: Literal["clear_output"] = CLEAR_OUTPUT + channel: Literal["iopub"] = "iopub" + content: ClearOutputContent + + +class ExecuteReplyMessage(_TypedMessage): + msg_type: Literal["execute_reply"] = EXECUTE_REPLY + channel: Literal["shell"] = "shell" + content: ExecuteReplyContent + + +class InputRequestMessage(_TypedMessage): + msg_type: Literal["input_request"] = INPUT_REQUEST + channel: Literal["stdin"] = "stdin" + content: InputRequestContent + + +class UnknownJupyterMessage(JupyterMessage): + """An extension or future message preserved without lossy parsing.""" + + raw: dict[str, object] = Field(default_factory=dict) + + +type KnownJupyterMessage = Annotated[ + StreamMessage + | DisplayDataMessage + | UpdateDisplayDataMessage + | ExecuteResultMessage + | ErrorMessage + | StatusMessage + | ExecuteInputMessage + | ClearOutputMessage + | ExecuteReplyMessage + | InputRequestMessage, + Field(discriminator="msg_type"), +] +type ParsedJupyterMessage = KnownJupyterMessage | UnknownJupyterMessage + +_KNOWN_MESSAGE_TYPES: Final[frozenset[str]] = frozenset( + { + STREAM, + DISPLAY_DATA, + UPDATE_DISPLAY_DATA, + EXECUTE_RESULT, + ERROR, + STATUS, + EXECUTE_INPUT, + CLEAR_OUTPUT, + EXECUTE_REPLY, + INPUT_REQUEST, + } +) +_KNOWN_MESSAGE_ADAPTER = TypeAdapter(KnownJupyterMessage) + + +def parse_jupyter_message( + raw: dict[str, object], + *, + channel: JupyterChannel, +) -> ParsedJupyterMessage: + """Parse a decoded Jupyter message without dropping extensions.""" + parent_header = raw.get("parent_header") + parent_id = ( + parent_header.get("msg_id") + if isinstance(parent_header, dict) + and isinstance(parent_header.get("msg_id"), str) + else None + ) + raw_metadata = raw.get("metadata") + metadata = raw_metadata if isinstance(raw_metadata, dict) else {} + raw_content = raw.get("content") + content = raw_content if isinstance(raw_content, dict) else {} + raw_buffers = raw.get("buffers") + buffers = ( + [ + bytes(buffer) + for buffer in raw_buffers + if isinstance(buffer, (bytes, bytearray, memoryview)) + ] + if isinstance(raw_buffers, list) + else [] + ) + raw_msg_type = raw.get("msg_type") + raw_message_id = raw.get("msg_id") + values: dict[str, object] = { + "channel": channel, + "msg_type": raw_msg_type if isinstance(raw_msg_type, str) else "", + "message_id": raw_message_id if isinstance(raw_message_id, str) else "", + "parent_id": parent_id, + "metadata": metadata, + "content": content, + "buffers": buffers, + } + + if values["msg_type"] not in _KNOWN_MESSAGE_TYPES: + return UnknownJupyterMessage.model_validate({**values, "raw": raw}) + return _KNOWN_MESSAGE_ADAPTER.validate_python(values) + + +# Existing callers use this name for the transport-level message. Keep it as +# an alias while the more expressive JupyterMessage name becomes canonical. +KernelMessage = JupyterMessage diff --git a/src/ipython_shell/jupyter/shell.py b/src/ipython_shell/jupyter/shell.py index 20cc4f9..743413b 100644 --- a/src/ipython_shell/jupyter/shell.py +++ b/src/ipython_shell/jupyter/shell.py @@ -1,10 +1,10 @@ from collections.abc import AsyncIterator -from dataclasses import replace from datetime import UTC, datetime from uuid import uuid4 from ..models import ShellInfo, ShellStatus -from .transport import InputRequest, JupyterTransport, KernelMessage +from .messages import ExecuteReplyMessage, ParsedJupyterMessage +from .transport import InputRequest, JupyterTransport class JupyterShell: @@ -30,7 +30,7 @@ class JupyterShell: code: str, *, call_id: str, - ) -> AsyncIterator[KernelMessage | InputRequest]: + ) -> AsyncIterator[ParsedJupyterMessage | InputRequest]: """Yield kernel messages while one cell executes.""" await self.start() self._active_call_id = call_id @@ -39,19 +39,22 @@ class JupyterShell: kernel_call_id = await self.transport.execute(code) async for message in self.transport.messages_for(kernel_call_id): if isinstance(message, InputRequest): - yield replace(message, call_id=call_id) + yield InputRequest( + call_id=call_id, + prompt=message.prompt, + password=message.password, + ) else: # Keep the kernel's execution ID private to the transport. - public_message = replace(message, parent_id=call_id) - if public_message.msg_type == "execute_reply": - self._last_execution_count = int( - public_message.content.get( - "execution_count", self._last_execution_count + public_message = message.model_copy(update={"parent_id": call_id}) + if isinstance(public_message, ExecuteReplyMessage): + if public_message.content.execution_count is not None: + self._last_execution_count = ( + public_message.content.execution_count ) - ) self.status = ( "error" - if public_message.content.get("status") == "error" + if public_message.content.status == "error" else "ready" ) self.last_used_at = datetime.now(UTC).isoformat() diff --git a/src/ipython_shell/jupyter/transport.py b/src/ipython_shell/jupyter/transport.py index 891b673..6d4ff11 100644 --- a/src/ipython_shell/jupyter/transport.py +++ b/src/ipython_shell/jupyter/transport.py @@ -1,19 +1,22 @@ import asyncio +import os from collections.abc import AsyncIterator, Awaitable, Callable from dataclasses import dataclass +from time import monotonic from typing import Any from jupyter_client.manager import AsyncKernelManager - -@dataclass -class KernelMessage: - """A decoded Jupyter message kept at the transport boundary.""" - - msg_type: str - parent_id: str | None - content: dict[str, object] - buffers: list[bytes] +from .messages import ( + ExecuteReplyMessage, + InputRequestMessage, + JupyterChannel, + JupyterMessage, + KernelMessage, + ParsedJupyterMessage, + StatusMessage, + parse_jupyter_message, +) @dataclass @@ -28,14 +31,21 @@ class InputRequest: class JupyterTransport: """Own one persistent ipykernel subprocess and its message channels.""" - def __init__(self) -> None: + _IOPUB_SETTLE_SECONDS = 0.25 + + def __init__( + self, + *, + matplotlib_backend: str | None = "module://matplotlib_inline.backend_inline", + ) -> None: self.manager = AsyncKernelManager() + self.matplotlib_backend = matplotlib_backend self.client = None self._active_call: str | None = None self._call_lock = asyncio.Lock() self._waiting_for_input: str | None = None self._message_queue: ( - asyncio.Queue[tuple[str, dict[str, Any] | BaseException]] | None + asyncio.Queue[tuple[JupyterChannel, dict[str, Any] | BaseException]] | None ) = None self._reader_tasks: set[asyncio.Task[None]] = set() @@ -44,7 +54,15 @@ class JupyterTransport: if self.client is not None: return - await self.manager.start_kernel() + # The parent process may select a non-interactive backend (Streamlit + # sets ``MPLBACKEND=Agg``). That is useful for server-side rendering, + # but it prevents ipykernel from publishing rich display MIME. Give + # each Jupyter subprocess its own inline default without mutating the + # host process environment. + kernel_env = dict(os.environ) + if self.matplotlib_backend is not None: + kernel_env["MPLBACKEND"] = self.matplotlib_backend + await self.manager.start_kernel(env=kernel_env) self.client = self.manager.client() self.client.start_channels() await self.client.wait_for_ready() @@ -67,7 +85,7 @@ class JupyterTransport: async def _read_channel( self, - channel: str, + channel: JupyterChannel, get_message: Callable[..., Awaitable[dict[str, Any]]], ) -> None: """Read one ZMQ channel continuously into the transport queue.""" @@ -106,7 +124,7 @@ class JupyterTransport: async def messages_for( self, call_id: str - ) -> AsyncIterator[KernelMessage | InputRequest]: + ) -> AsyncIterator[ParsedJupyterMessage | InputRequest]: """Yield decoded output, input, and completion messages for one call.""" if self.client is None: raise RuntimeError("JupyterTransport has not been started") @@ -115,11 +133,11 @@ class JupyterTransport: if self._message_queue is None: raise RuntimeError("JupyterTransport message readers are not running") - reply: KernelMessage | None = None + reply: ExecuteReplyMessage | None = None idle = False try: while True: - _, queued_message = await self._message_queue.get() + channel, queued_message = await self._message_queue.get() if isinstance(queued_message, BaseException): raise queued_message @@ -128,37 +146,69 @@ class JupyterTransport: if parent_id != call_id: continue - if message["msg_type"] == "input_request": + decoded = parse_jupyter_message(message, channel=channel) + + if isinstance(decoded, InputRequestMessage): self._waiting_for_input = call_id yield InputRequest( call_id=call_id, - prompt=str(message.get("content", {}).get("prompt", "")), - password=bool( - message.get("content", {}).get("password", False) - ), + prompt=decoded.content.prompt, + password=decoded.content.password, ) continue - decoded = KernelMessage( - msg_type=message["msg_type"], - parent_id=parent_id, - content=dict(message.get("content", {})), - buffers=[bytes(buffer) for buffer in message.get("buffers", [])], - ) - if decoded.msg_type == "execute_reply": + if isinstance(decoded, ExecuteReplyMessage): # The shell reply and IOPub messages use different # channels. Buffer the reply so it remains terminal # even when both channels arrive close together. reply = decoded else: yield decoded - if ( - decoded.msg_type == "status" - and decoded.content.get("execution_state") == "idle" + if isinstance(decoded, StatusMessage) and ( + decoded.content.execution_state == "idle" ): idle = True if reply is not None and idle: + # Shell and IOPub are independent ZMQ channels. The + # kernel can publish a final rich display after the shell + # reply and idle status have already reached this queue. + # Wait briefly for that cross-channel tail before ending + # the call, otherwise inline Matplotlib output can vanish. + settle_deadline = monotonic() + self._IOPUB_SETTLE_SECONDS + while True: + remaining = settle_deadline - monotonic() + if remaining <= 0: + break + try: + late_channel, late_message = await asyncio.wait_for( + self._message_queue.get(), + timeout=remaining, + ) + except TimeoutError: + break + if isinstance(late_message, BaseException): + raise late_message + late_parent_id = late_message.get("parent_header", {}).get( + "msg_id" + ) + if late_parent_id != call_id: + continue + late_decoded = parse_jupyter_message( + late_message, + channel=late_channel, + ) + if isinstance(late_decoded, InputRequestMessage): + self._waiting_for_input = call_id + yield InputRequest( + call_id=call_id, + prompt=late_decoded.content.prompt, + password=late_decoded.content.password, + ) + elif isinstance(late_decoded, ExecuteReplyMessage): + reply = late_decoded + else: + yield late_decoded yield reply return finally: diff --git a/src/ipython_webapp/jupyter/app.py b/src/ipython_webapp/jupyter/app.py index 96aad78..6f72001 100644 --- a/src/ipython_webapp/jupyter/app.py +++ b/src/ipython_webapp/jupyter/app.py @@ -7,7 +7,7 @@ from fastapi import Body, FastAPI, HTTPException, Path, Response from fastapi.responses import StreamingResponse from pydantic import BaseModel -from ipython_shell.jupyter import InputRequest, JupyterApp, KernelMessage +from ipython_shell.jupyter import InputRequest, JupyterApp, ParsedJupyterMessage app = FastAPI(title="IPython Jupyter Web App", version="0.1.0") shell_app = JupyterApp() @@ -19,7 +19,7 @@ class InputReply(BaseModel): value: str -def serialize_event(event: KernelMessage | InputRequest) -> dict[str, object]: +def serialize_event(event: ParsedJupyterMessage | InputRequest) -> dict[str, object]: """Turn a shell event into JSON-safe data for a web client.""" if isinstance(event, InputRequest): return { @@ -31,11 +31,15 @@ def serialize_event(event: KernelMessage | InputRequest) -> dict[str, object]: # Some Jupyter messages keep binary payloads in message buffers. Base64 # makes those buffers safe to carry beside JSON MIME data in the stream. + content = event.content + if isinstance(content, BaseModel): + content = content.model_dump(mode="json") + return { "type": "kernel_message", "msg_type": event.msg_type, "parent_id": event.parent_id, - "content": event.content, + "content": content, "buffers": [ base64.b64encode(buffer).decode("ascii") for buffer in event.buffers ], diff --git a/src/st_demo/__init__.py b/src/st_demo/__init__.py new file mode 100644 index 0000000..2bcd587 --- /dev/null +++ b/src/st_demo/__init__.py @@ -0,0 +1 @@ +"""Streamlit demonstration client for the Jupyter-backed shell.""" diff --git a/src/st_demo/app.py b/src/st_demo/app.py new file mode 100644 index 0000000..319c276 --- /dev/null +++ b/src/st_demo/app.py @@ -0,0 +1,312 @@ +import asyncio +import base64 +import queue +import sys +import threading +from collections.abc import Mapping +from concurrent.futures import Future +from dataclasses import dataclass +from pathlib import Path +from uuid import uuid4 + +from ipython_shell.jupyter import InputRequest, JupyterApp, ParsedJupyterMessage + +from st_demo.ui import event_to_record, preferred_mime + + +@dataclass +class _CallFinished: + """Sentinel placed after a call's final Jupyter message.""" + + +@dataclass +class _CallFailed: + """Exception sentinel placed when the background call task fails.""" + + error: Exception + + +@dataclass +class _BackgroundCall: + events: queue.Queue[ParsedJupyterMessage | InputRequest | _CallFinished | _CallFailed] + task: Future[None] | None = None + + +class ShellController: + """Bridge Streamlit reruns to one persistent async Jupyter application.""" + + def __init__(self) -> None: + self._loop = asyncio.new_event_loop() + self._thread = threading.Thread( + target=self._run_loop, + name="st-demo-jupyter-loop", + daemon=True, + ) + self._ready = threading.Event() + self._app: JupyterApp | None = None + self._calls: dict[str, _BackgroundCall] = {} + self._lock = threading.Lock() + self._thread.start() + self._ready.wait(timeout=5) + if self._app is None: + raise RuntimeError("The Jupyter application did not start") + + def _run_loop(self) -> None: + """Own the asyncio loop so Streamlit's rerun loop stays synchronous.""" + asyncio.set_event_loop(self._loop) + self._app = JupyterApp() + self._ready.set() + self._loop.run_forever() + self._loop.run_until_complete(self._app.shutdown()) + self._loop.close() + + def start_call(self, code: str, shell_name: str) -> str: + """Start a call and return a local handle immediately.""" + handle = uuid4().hex + background_call = _BackgroundCall(events=queue.Queue()) + with self._lock: + self._calls[handle] = background_call + background_call.task = asyncio.run_coroutine_threadsafe( + self._consume_call(handle, code, shell_name), + self._loop, + ) + return handle + + async def _consume_call(self, handle: str, code: str, shell_name: str) -> None: + """Consume the async app stream and publish events for Streamlit.""" + if self._app is None: + raise RuntimeError("The Jupyter application did not start") + background_call = self._calls[handle] + try: + async for event in self._app.run_code_stream(code, shell_name): + background_call.events.put(event) + except Exception as error: + background_call.events.put(_CallFailed(error)) + finally: + background_call.events.put(_CallFinished()) + + def poll( + self, handle: str + ) -> tuple[list[ParsedJupyterMessage | InputRequest], bool, Exception | None]: + """Drain events currently available for one call.""" + try: + background_call = self._calls[handle] + except KeyError as error: + raise KeyError(f"Call {handle} does not exist") from error + + events: list[ParsedJupyterMessage | InputRequest] = [] + failed: Exception | None = None + finished = False + while True: + try: + item = background_call.events.get_nowait() + except queue.Empty: + break + if isinstance(item, _CallFinished): + finished = True + elif isinstance(item, _CallFailed): + failed = item.error + else: + events.append(item) + return events, finished, failed + + def reply_to_input(self, handle: str, call_id: str, value: str) -> None: + """Answer an input request without blocking Streamlit's script.""" + if handle not in self._calls: + raise KeyError(f"Call {handle} does not exist") + if self._app is None: + raise RuntimeError("The Jupyter application did not start") + future = asyncio.run_coroutine_threadsafe( + self._app.reply_to_input(call_id, value), + self._loop, + ) + future.result(timeout=5) + + def shell_names(self) -> list[str]: + """Return known shell names without starting any unused kernels.""" + if self._app is None: + return [] + future = asyncio.run_coroutine_threadsafe(self._shell_names(), self._loop) + return future.result(timeout=5) + + async def _shell_names(self) -> list[str]: + if self._app is None: + return [] + return [shell.name for shell in self._app.list_shells()] + + def close(self) -> None: + """Stop the background application when the Streamlit session ends.""" + if self._loop.is_running(): + self._loop.call_soon_threadsafe(self._loop.stop) + self._thread.join(timeout=5) + + +def _controller() -> ShellController: + import streamlit as st + + if "shell_controller" not in st.session_state: + st.session_state.shell_controller = ShellController() + return st.session_state.shell_controller + + +def _render_mime(data: dict[str, object]) -> None: + import streamlit as st + + selected = preferred_mime(data) + if selected is None: + return + mime, value = selected + if mime == "text/html": + # This is explicitly a local demo: execute only code you trust. + st.html(str(value)) + elif mime in {"image/png", "image/jpeg", "image/svg+xml"}: + if mime == "image/svg+xml": + st.html(str(value)) + else: + st.image(base64.b64decode(str(value))) + elif mime == "application/pdf": + st.download_button( + "Download PDF", + base64.b64decode(str(value)), + file_name="output.pdf", + mime=mime, + ) + elif mime == "application/javascript": + st.code(str(value), language="javascript") + elif mime == "text/latex": + st.latex(str(value)) + else: + st.code(str(value), language="text") + + +def _render_record(record: dict[str, object]) -> None: + import streamlit as st + + kind = record["kind"] + if kind == "stdout": + st.code(str(record.get("text", "")), language="text") + elif kind == "stderr": + st.error(str(record.get("text", ""))) + elif kind in {"display_data", "execute_result"}: + data = record.get("data") + if isinstance(data, dict): + _render_mime(data) + elif kind == "error": + st.error(str(record.get("text", ""))) + data = record.get("data") + if isinstance(data, dict): + traceback = data.get("traceback") + if isinstance(traceback, list): + st.code("".join(str(line) for line in traceback), language="text") + elif kind == "input_request": + st.info(str(record.get("prompt", "Input required"))) + elif kind not in {"status", "execute_reply", "execute_input"}: + with st.expander(f"Jupyter message: {kind}"): + st.json(record) + + +def visible_records( + call_records: Mapping[str, list[dict[str, object]]], +) -> list[dict[str, object]]: + """Flatten stored calls in insertion order for persistent history.""" + return [record for records in call_records.values() for record in records] + + +def _render_active_call(controller: ShellController) -> None: + import streamlit as st + + handle = st.session_state.get("active_handle") + call_records = st.session_state.get("call_records", {}) + if not isinstance(call_records, dict): + return + + finished = False + failed: Exception | None = None + records: list[dict[str, object]] = [] + if isinstance(handle, str): + events, finished, failed = controller.poll(handle) + records = call_records.setdefault(handle, []) + records.extend(event_to_record(event) for event in events) + + for record in visible_records(call_records): + _render_record(record) + + if not isinstance(handle, str): + return + + input_record = next( + (record for record in reversed(records) if record["kind"] == "input_request"), + None, + ) + if input_record is not None and not finished: + value = st.text_input( + str(input_record.get("prompt", "Input")), + type="password" if input_record.get("password") else "default", + key=f"input-value-{handle}", + ) + if st.button("Send input", key=f"send-input-{handle}"): + call_id = str(input_record.get("call_id", "")) + controller.reply_to_input(handle, call_id, value) + st.rerun(scope="fragment") + elif failed is not None: + st.exception(failed) + elif not finished: + st.caption("Running…") + else: + st.success("Call complete") + st.session_state.active_handle = None + + +def render() -> None: + """Render the Streamlit shell workbench.""" + import streamlit as st + + st.set_page_config(page_title="IPython Shell", layout="wide") + st.title("IPython Shell") + st.caption("A persistent Jupyter kernel with typed events and MIME output.") + + controller = _controller() + names = controller.shell_names() + shell_options = ["last", "new", *names] + shell_name = st.sidebar.selectbox("Shell", list(dict.fromkeys(shell_options))) + st.sidebar.caption(f"Known shells: {len(names)}") + + code = st.text_area( + "Code", + value="import matplotlib.pyplot as plt\nplt.plot([1, 2, 3], [4, 5, 6])", + height=180, + ) + if st.button("Run", type="primary"): + handle = controller.start_call(code, shell_name) + st.session_state.active_handle = handle + st.session_state.call_records = getattr( + st.session_state, + "call_records", + {}, + ) + st.session_state.call_records[handle] = [] + + call_records = st.session_state.get("call_records", {}) + active_call = st.session_state.get("active_handle") + if call_records: + st.divider() + st.subheader("Output") + if isinstance(active_call, str) and hasattr(st, "fragment"): + st.fragment(run_every="0.25s")(_render_active_call)(controller) + elif isinstance(active_call, str): + _render_active_call(controller) + else: + for record in visible_records(call_records): + _render_record(record) + + +def main() -> None: + """Launch this module through Streamlit's CLI.""" + from streamlit.web import cli as stcli + + sys.argv = ["streamlit", "run", str(Path(__file__).resolve()), *sys.argv[1:]] + raise SystemExit(stcli.main()) + + +if __name__ == "__main__": + render() diff --git a/src/st_demo/ui.py b/src/st_demo/ui.py new file mode 100644 index 0000000..4627632 --- /dev/null +++ b/src/st_demo/ui.py @@ -0,0 +1,100 @@ +from collections.abc import Mapping +from typing import Final + +from pydantic import BaseModel + +from ipython_shell.jupyter.messages import ( + DisplayDataMessage, + ErrorMessage, + ExecuteResultMessage, + ParsedJupyterMessage, + StreamMessage, + UnknownJupyterMessage, +) +from ipython_shell.jupyter.transport import InputRequest + + +MIME_PRIORITY: Final[tuple[str, ...]] = ( + "text/html", + "image/svg+xml", + "image/png", + "image/jpeg", + "application/pdf", + "application/javascript", + "text/latex", + "text/plain", +) + + +def preferred_mime(data: Mapping[str, object]) -> tuple[str, object] | None: + """Choose the richest representation the demo knows how to display.""" + for mime in MIME_PRIORITY: + if mime in data: + return mime, data[mime] + return next(iter(data.items()), None) + + +def _content_record(content: object) -> dict[str, object]: + if isinstance(content, BaseModel): + return content.model_dump(mode="json") + if isinstance(content, dict): + return dict(content) + return {"value": content} + + +def event_to_record( + event: ParsedJupyterMessage | InputRequest, +) -> dict[str, object]: + """Convert one typed kernel event into a UI-friendly record.""" + if isinstance(event, InputRequest): + return { + "kind": "input_request", + "call_id": event.call_id, + "prompt": event.prompt, + "password": event.password, + } + + call_id = event.parent_id + if isinstance(event, StreamMessage): + return { + "kind": "stdout" if event.content.name == "stdout" else "stderr", + "call_id": call_id, + "text": event.content.text, + } + + if isinstance(event, (DisplayDataMessage, ExecuteResultMessage)): + record: dict[str, object] = { + "kind": event.msg_type, + "call_id": call_id, + "data": dict(event.content.data), + "text": event.content.data.get("text/plain"), + "metadata": dict(event.content.metadata), + } + if event.content.transient is not None: + record["transient"] = dict(event.content.transient) + if isinstance(event, ExecuteResultMessage): + record["execution_count"] = event.content.execution_count + return record + + if isinstance(event, ErrorMessage): + return { + "kind": "error", + "call_id": call_id, + "text": f"{event.content.ename}: {event.content.evalue}", + "data": _content_record(event.content), + } + + if isinstance(event, UnknownJupyterMessage): + return { + "kind": event.msg_type, + "call_id": call_id, + "content": dict(event.content), + "metadata": dict(event.metadata), + "raw": dict(event.raw), + } + + return { + "kind": event.msg_type, + "call_id": call_id, + "content": _content_record(event.content), + } diff --git a/tests/test_jupyter_app.py b/tests/test_jupyter_app.py index 1be3b2a..cfe66d7 100644 --- a/tests/test_jupyter_app.py +++ b/tests/test_jupyter_app.py @@ -4,7 +4,8 @@ import unittest from ipython_shell.jupyter.app import JupyterApp from ipython_shell.jupyter.shell import JupyterShell -from ipython_shell.jupyter.transport import InputRequest, KernelMessage +from ipython_shell.jupyter.messages import ExecuteResultMessage +from ipython_shell.jupyter.transport import InputRequest class JupyterShellTests(unittest.IsolatedAsyncioTestCase): @@ -30,19 +31,17 @@ class JupyterShellTests(unittest.IsolatedAsyncioTestCase): self.assertEqual( next( - message.content["data"]["text/plain"] + message.content.data["text/plain"] for message in first - if isinstance(message, KernelMessage) - and message.msg_type == "execute_result" + if isinstance(message, ExecuteResultMessage) ), "41", ) self.assertEqual( next( - message.content["data"]["text/plain"] + message.content.data["text/plain"] for message in second - if isinstance(message, KernelMessage) - and message.msg_type == "execute_result" + if isinstance(message, ExecuteResultMessage) ), "42", ) @@ -97,19 +96,17 @@ class JupyterAppTests(unittest.IsolatedAsyncioTestCase): self.assertEqual( next( - message.content["data"]["text/plain"] + message.content.data["text/plain"] for message in beta_view - if isinstance(message, KernelMessage) - and message.msg_type == "execute_result" + if isinstance(message, ExecuteResultMessage) ), "False", ) self.assertEqual( next( - message.content["data"]["text/plain"] + message.content.data["text/plain"] for message in alpha_view - if isinstance(message, KernelMessage) - and message.msg_type == "execute_result" + if isinstance(message, ExecuteResultMessage) ), "'alpha'", ) @@ -143,10 +140,9 @@ class JupyterAppTests(unittest.IsolatedAsyncioTestCase): self.assertEqual( next( - message.content["data"]["text/plain"] + message.content.data["text/plain"] for message in messages - if isinstance(message, KernelMessage) - and message.msg_type == "execute_result" + if isinstance(message, ExecuteResultMessage) ), "'Ada'", ) diff --git a/tests/test_st_demo.py b/tests/test_st_demo.py new file mode 100644 index 0000000..d0d1922 --- /dev/null +++ b/tests/test_st_demo.py @@ -0,0 +1,82 @@ +import subprocess +import sys +import unittest +from pathlib import Path + +from ipython_shell.jupyter.messages import ( + ExecuteResultMessage, + UnknownJupyterMessage, +) +from st_demo.app import visible_records +from st_demo.ui import event_to_record, preferred_mime + + +class UIDisplayTests(unittest.TestCase): + def test_completed_call_records_remain_visible_after_active_call_clears(self): + records = { + "finished-call": [{"kind": "execute_result", "text": "42"}], + } + + self.assertEqual(visible_records(records), records["finished-call"]) + + def test_app_module_loads_when_streamlit_executes_file_path(self): + app_path = Path(__file__).parents[1] / "src" / "st_demo" / "app.py" + result = subprocess.run( + [ + sys.executable, + "-c", + "import runpy, sys; runpy.run_path(sys.argv[1], run_name='st_demo_script')", + str(app_path), + ], + capture_output=True, + text=True, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + + def test_preferred_mime_chooses_html_before_plain_text(self): + mime, value = preferred_mime( + { + "text/plain": "fallback", + "text/html": "rich", + } + ) + + self.assertEqual((mime, value), ("text/html", "rich")) + + def test_execute_result_record_keeps_mime_and_repr(self): + event = ExecuteResultMessage( + message_id="message-1", + parent_id="call-1", + content={ + "execution_count": 4, + "data": { + "text/plain": "Line2D(_line0)", + "image/png": "ZmFrZQ==", + }, + }, + ) + + record = event_to_record(event) + + self.assertEqual(record["kind"], "execute_result") + self.assertEqual(record["text"], "Line2D(_line0)") + self.assertEqual(record["data"]["image/png"], "ZmFrZQ==") + + def test_unknown_message_record_keeps_raw_protocol_data(self): + event = UnknownJupyterMessage( + channel="iopub", + msg_type="vendor_extension", + message_id="message-2", + content={"value": 42}, + raw={"msg_type": "vendor_extension", "content": {"value": 42}}, + ) + + record = event_to_record(event) + + self.assertEqual(record["kind"], "vendor_extension") + self.assertEqual(record["content"]["value"], 42) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_transport.py b/tests/test_transport.py index cc2a540..1b82af0 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -1,16 +1,20 @@ import asyncio +import os import unittest +from unittest.mock import patch -from ipython_shell.jupyter.transport import ( - InputRequest, - JupyterTransport, - KernelMessage, +from ipython_shell.jupyter.messages import ( + DisplayDataMessage, + JupyterMessage, + UnknownJupyterMessage, + parse_jupyter_message, ) +from ipython_shell.jupyter.transport import InputRequest, JupyterTransport class TransportTests(unittest.TestCase): def test_kernel_message_keeps_content_and_buffers(self): - message = KernelMessage( + message = JupyterMessage( msg_type="display_data", parent_id="call-1", content={"data": {"text/plain": "42"}}, @@ -21,6 +25,46 @@ class TransportTests(unittest.TestCase): self.assertEqual(message.content["data"], {"text/plain": "42"}) self.assertEqual(message.buffers, [b"binary"]) + def test_parser_types_known_message_content(self): + message = parse_jupyter_message( + { + "msg_id": "message-1", + "msg_type": "display_data", + "parent_header": {"msg_id": "call-1"}, + "metadata": {}, + "content": { + "data": {"text/plain": "42"}, + "metadata": {}, + }, + "buffers": [b"binary"], + }, + channel="iopub", + ) + + self.assertIsInstance(message, DisplayDataMessage) + assert isinstance(message, DisplayDataMessage) + self.assertEqual(message.content.data["text/plain"], "42") + self.assertEqual(message.parent_id, "call-1") + self.assertEqual(message.buffers, [b"binary"]) + + def test_parser_preserves_unknown_message(self): + message = parse_jupyter_message( + { + "msg_id": "message-2", + "msg_type": "future_extension", + "parent_header": {"msg_id": "call-1"}, + "metadata": {"vendor": "example"}, + "content": {"answer": 42}, + "buffers": [], + }, + channel="iopub", + ) + + self.assertIsInstance(message, UnknownJupyterMessage) + self.assertEqual(message.msg_type, "future_extension") + self.assertEqual(message.content["answer"], 42) + self.assertEqual(message.metadata["vendor"], "example") + class AsyncTransportTests(unittest.IsolatedAsyncioTestCase): async def test_transport_keeps_channel_readers_alive_during_a_call(self): @@ -54,7 +98,7 @@ class AsyncTransportTests(unittest.IsolatedAsyncioTestCase): any(message.msg_type == "execute_result" for message in messages) ) self.assertEqual(messages[-1].msg_type, "execute_reply") - self.assertEqual(messages[-1].content["status"], "ok") + self.assertEqual(messages[-1].content.status, "ok") async def test_transport_preserves_mime_and_errors(self): transport = JupyterTransport() @@ -80,12 +124,68 @@ class AsyncTransportTests(unittest.IsolatedAsyncioTestCase): for message in display_messages if message.msg_type == "display_data" ) - self.assertEqual(display.content["data"]["text/plain"], "hello") + self.assertEqual(display.content.data["text/plain"], "hello") error = next( message for message in error_messages if message.msg_type == "error" ) - self.assertEqual(error.content["ename"], "ValueError") + self.assertEqual(error.content.ename, "ValueError") + + async def test_transport_waits_for_iopub_output_after_idle_and_reply(self): + transport = JupyterTransport() + transport.client = object() + transport._active_call = "call-1" + transport._message_queue = asyncio.Queue() + transport._message_queue.put_nowait( + ( + "shell", + { + "msg_id": "reply-1", + "msg_type": "execute_reply", + "parent_header": {"msg_id": "call-1"}, + "metadata": {}, + "content": {"status": "ok", "execution_count": 1}, + "buffers": [], + }, + ) + ) + transport._message_queue.put_nowait( + ( + "iopub", + { + "msg_id": "status-1", + "msg_type": "status", + "parent_header": {"msg_id": "call-1"}, + "metadata": {}, + "content": {"execution_state": "idle"}, + "buffers": [], + }, + ) + ) + + async def enqueue_late_display() -> None: + await asyncio.sleep(0.15) + await transport._message_queue.put( + ( + "iopub", + { + "msg_id": "display-1", + "msg_type": "display_data", + "parent_header": {"msg_id": "call-1"}, + "metadata": {}, + "content": { + "data": {"image/png": "ZmFrZQ=="}, + "metadata": {}, + }, + "buffers": [], + }, + ) + ) + + asyncio.create_task(enqueue_late_display()) + messages = [message async for message in transport.messages_for("call-1")] + + self.assertTrue(any(message.msg_type == "display_data" for message in messages)) async def test_transport_preserves_matplotlib_mime_output(self): transport = JupyterTransport() @@ -101,7 +201,30 @@ class AsyncTransportTests(unittest.IsolatedAsyncioTestCase): display = next( message for message in messages if message.msg_type == "display_data" ) - self.assertIn("image/png", display.content["data"]) + self.assertIn("image/png", display.content.data) + + async def test_transport_keeps_mime_output_when_parent_forces_agg(self): + # Streamlit sets MPLBACKEND=Agg in its own process. The kernel must + # still use an inline backend so rich output survives the transport. + with patch.dict(os.environ, {"MPLBACKEND": "Agg"}): + transport = JupyterTransport() + await transport.start() + try: + call_id = await transport.execute( + "import matplotlib.pyplot as plt\n" + "plt.plot([1, 2, 3], [4, 5, 6])" + ) + messages = [ + message + async for message in transport.messages_for(call_id) + ] + finally: + await transport.shutdown() + + display = next( + message for message in messages if message.msg_type == "display_data" + ) + self.assertIn("image/png", display.content.data) async def test_transport_routes_input_reply_without_parent_stdin(self): transport = JupyterTransport() diff --git a/uv.lock b/uv.lock index 0568941..9fc9042 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,11 @@ version = 1 revision = 3 requires-python = ">=3.14" +resolution-markers = [ + "sys_platform == 'win32'", + "sys_platform == 'emscripten'", + "sys_platform != 'emscripten' and sys_platform != 'win32'", +] [[package]] name = "aiofile" @@ -14,6 +19,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/79/6e45e778c4c3cab39e0937b007b720c15f76c50c6453d153282d0fcc3588/aiofile-3.12.3-py3-none-any.whl", hash = "sha256:5c1bcc9e929c50834608e8cc1a4cc1d7503eb60c15a535b779fd39e2f372c017", size = 22122, upload-time = "2026-08-04T22:59:25.838Z" }, ] +[[package]] +name = "altair" +version = "6.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "narwhals" }, + { name = "packaging" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/a1/5e6cc638a66da48cfc89a79c2f4810dfec00b63385f9b009ab1f069779bb/altair-6.2.2.tar.gz", hash = "sha256:a1ff9d9cfe81c75414641826312b9471780e19d39293ba0b012933f6b6cba0fe", size = 766606, upload-time = "2026-06-23T12:47:13.384Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/99/d6031f4f146298951c46b1bf1cc160c2a63f6e44b3c13a30054add100d5f/altair-6.2.2-py3-none-any.whl", hash = "sha256:94014f8ad8617c3cb163d1137359cd6db5ba134b9b46d93cfd8b609fd245a583", size = 797613, upload-time = "2026-06-23T12:47:11.451Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.5" @@ -93,6 +114,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, ] +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + [[package]] name = "cachetools" version = "7.1.7" @@ -636,6 +666,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, ] +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -753,10 +805,16 @@ dependencies = [ { name = "langchain-openai" }, { name = "matplotlib" }, { name = "matplotlib-inline" }, + { name = "pydantic" }, { name = "uvicorn" }, { name = "wonderwords" }, ] +[package.dev-dependencies] +dev = [ + { name = "streamlit" }, +] + [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.141.1" }, @@ -768,10 +826,14 @@ requires-dist = [ { name = "langchain-openai", specifier = ">=1.6.0" }, { name = "matplotlib", specifier = ">=3.11.1" }, { name = "matplotlib-inline", specifier = ">=0.1.7" }, + { name = "pydantic", specifier = ">=2.13.5" }, { name = "uvicorn", specifier = ">=0.52.4" }, { name = "wonderwords", specifier = ">=2.2.0" }, ] +[package.metadata.requires-dev] +dev = [{ name = "streamlit", specifier = ">=1.37" }] + [[package]] name = "ipython-pygments-lexers" version = "1.1.1" @@ -784,6 +846,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + [[package]] name = "jaraco-classes" version = "3.4.0" @@ -838,6 +909,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "jiter" version = "0.16.0" @@ -1237,6 +1320,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "matplotlib" version = "3.11.1" @@ -1325,6 +1438,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, ] +[[package]] +name = "narwhals" +version = "2.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/7b/6248dada39781db1ab3ebf08943080df0796098515a87f6f8696d14ec744/narwhals-2.25.0.tar.gz", hash = "sha256:62c036c810662bf7820b7737077176313bc59350eeeefb808510f388c743e4b2", size = 677076, upload-time = "2026-08-20T18:10:15.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/dc/55481808fd70ef1567cf13540ffd4702af3f74b112e35427564b03f79c2d/narwhals-2.25.0-py3-none-any.whl", hash = "sha256:1f0f403e8c7e4463cde9bfe78b12fdd809e3ae3dda6d9b2f802934fb9c7a6a8f", size = 467373, upload-time = "2026-08-20T18:10:13.834Z" }, +] + [[package]] name = "nest-asyncio2" version = "1.7.2" @@ -1486,6 +1608,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "pandas" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" }, + { url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" }, + { url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" }, + { url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" }, + { url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" }, + { url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" }, + { url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" }, + { url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, +] + [[package]] name = "parso" version = "0.8.7" @@ -1587,6 +1738,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, ] +[[package]] +name = "protobuf" +version = "7.36.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/e7/0553e21d25ca4d9f573135775348a372c3ec34a93a71d5f297c3bac38341/protobuf-7.36.0.tar.gz", hash = "sha256:e8e09cb0d794c6687926fa558a8a6e72aa10edb997d5ca61da0765f12a3e00ea", size = 510034, upload-time = "2026-08-20T16:34:01.071Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/ae/58e3ca96cb2e118cc546b677359b3c6659f79a140935c08dec94c7998585/protobuf-7.36.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:9103532dffd80c6fab7e50c65a31007680a06eb57537d437bb1b35812c138a37", size = 453256, upload-time = "2026-08-20T16:33:53.945Z" }, + { url = "https://files.pythonhosted.org/packages/f0/15/5162230af4912697f0fe406f6800f80760945babcff0e2c2fe6c84ef2d5d/protobuf-7.36.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:bf94a5917c71058262de683669bc0a797a7669d3de71f0b36d058e3194f47b44", size = 341436, upload-time = "2026-08-20T16:33:55.134Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/1670b2bfc9a45e807e520c3e9be36524db9ccc7dc05ea17af7681cabdc61/protobuf-7.36.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:3297e60abdff301e5f74393d87f6cc59dacab5f024a89548a6e8de1d26576b16", size = 354440, upload-time = "2026-08-20T16:33:56.077Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f8/bd5804695ba400e423c33fd4d9f58c28d86633d5ba1945c36ff3967d98cb/protobuf-7.36.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:70f5ec8eb0da81a44360c0dc0beac99a0d78071d21956a7076bae8bd2051841b", size = 340439, upload-time = "2026-08-20T16:33:56.992Z" }, + { url = "https://files.pythonhosted.org/packages/ef/9f/acd02338235a3e7d03168c4303478347b7624fc8189ff4e7f0d2654bbe86/protobuf-7.36.0-cp310-abi3-win32.whl", hash = "sha256:7326fd717bdc419162a735938d89d4032332bcc3408804012b24ff3a37086071", size = 440216, upload-time = "2026-08-20T16:33:57.99Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4e/12cb93270967a2affff5b3f720694700d4d87712a67afd05c8cb3f6fa52c/protobuf-7.36.0-cp310-abi3-win_amd64.whl", hash = "sha256:1781cc1de61249b750848029bca452c0a8b7e990080316b9bbc2518b2117b488", size = 453731, upload-time = "2026-08-20T16:33:58.951Z" }, + { url = "https://files.pythonhosted.org/packages/01/c3/629999e78d46c1115c11886d51c6bd68c17ce4a944f1ea3e153a91316a33/protobuf-7.36.0-py3-none-any.whl", hash = "sha256:53374d53fc29a67f7dbbf0ade47d7526a0f0137bf0f9c90e48d8a60790ef748c", size = 177024, upload-time = "2026-08-20T16:34:00.053Z" }, +] + [[package]] name = "psutil" version = "7.2.2" @@ -1652,6 +1818,28 @@ memory = [ { name = "cachetools" }, ] +[[package]] +name = "pyarrow" +version = "25.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/e3/27f57f80141379d60defe6703eb50a707325706f07fedfd1312c7a751995/pyarrow-25.0.1.tar.gz", hash = "sha256:9150a83248bfed9813ea3c3af74c3856c1984d444aa28e58bf7733b9750ddf6a", size = 1201653, upload-time = "2026-08-10T12:40:53.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/4c/b525824ad3094076919273cd97db61fb3d78252dee76fa3b8dc8f76774aa/pyarrow-25.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bf0b672390cdcb640d7288f96b826d71ff4e9abb254a86c89890baf51a29cee6", size = 35885255, upload-time = "2026-08-10T12:39:32.366Z" }, + { url = "https://files.pythonhosted.org/packages/08/62/448bb0e940de41aec31d1a956e63ad9c54afdf122a103cc3ab20c2a3ce33/pyarrow-25.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:38a9a4b4b9613380e200641891495a56c3d5a98a092db4a870af9975e220471d", size = 37644461, upload-time = "2026-08-10T12:39:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9a/13587e38bd4806fd218f50fd13b8903fab60588a699ff0c406372e5b4043/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b726ad7e7b669be982b0c71c07fe4b037d654354130da79a7902a669e93a66b", size = 46877146, upload-time = "2026-08-10T12:39:43.722Z" }, + { url = "https://files.pythonhosted.org/packages/8d/61/1c5d1229fa21da4cff5365e41e57177aaac57c563c727f35419b8513d1c1/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:9171748cdf796972d85a4b60157c279913e242992e350c90c7450182a9838b2a", size = 50131616, upload-time = "2026-08-10T12:39:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/43/20/291e1d65cc0b09aa19f03cf25cf51a2f5fa94b5db315178f2d254ed5cad4/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b7a296aac7a71fa0886c08e155ddb6c636a50013f801f6178daafa0f9e726188", size = 50008879, upload-time = "2026-08-10T12:39:56.891Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7c/1b7c9ec28e76576337e4f97b31141c9a181b89b6d1d6221e9d8205621a58/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0fe7c8b6c03969b49c8c66182e4a18e3819ab92d07cfab5d8370c531b9369ef0", size = 53170864, upload-time = "2026-08-10T12:40:04.918Z" }, + { url = "https://files.pythonhosted.org/packages/b7/75/f3d789dc06011a765d14d86bda799cf72ac1d715b6a6edecaa0d73d95062/pyarrow-25.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:f729cfdbd36fd99d543b67a914d2de044c84ebe45be8b34902b299b608c15c8f", size = 28620729, upload-time = "2026-08-10T12:40:51.41Z" }, + { url = "https://files.pythonhosted.org/packages/fc/05/647a8ee6f7c2662feb6921315617bc04dcd6034763fb61b1199720bf6162/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:59a2de54c0cbd954da861eee4d1d330f8e909c45b53455baef696380f2c55033", size = 36130288, upload-time = "2026-08-10T12:40:11.014Z" }, + { url = "https://files.pythonhosted.org/packages/93/f8/c9ee997554d7bea94520667dd1933f109ac1da3ee3556d2b49381e023484/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:35935cd5de130aa5cf4dea052a63e6bf2e17006c35c3a468194242b9b2bf5956", size = 37762187, upload-time = "2026-08-10T12:40:16.592Z" }, + { url = "https://files.pythonhosted.org/packages/a2/08/a28c01c7fe9e96e8233ce2d13df1d402f4f999f848f51d2daacd6bb4c036/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f3831aaa25c67a99f99dc8b05873cb9d64560390372e2aa197ce9dd4a3f06a44", size = 46888003, upload-time = "2026-08-10T12:40:23.242Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b9/58612e977d28dc58c878448866838369ee8da2f1e7cc8ed2c84b952aafee/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6a1fdfc6659b6b19022f2e50627fb5cf7156a66c46bf4299379955cbe742382a", size = 50079036, upload-time = "2026-08-10T12:40:29.169Z" }, + { url = "https://files.pythonhosted.org/packages/72/13/66e1402dcc860e1dc2760b1e0292c9a569b62b3bccab69def1b3e907d006/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:169d3429d5be7c752125890620f75a60776d38b0035eddae939651640822332e", size = 50040226, upload-time = "2026-08-10T12:40:35.186Z" }, + { url = "https://files.pythonhosted.org/packages/78/10/3f1a5497a7ef732ab0f03ecca3e66d89d9c0f57fdc61b4794c456b781f01/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:119297a6dc197e45d9c6d4415f7814a67ffa36c180d26f68c154c58067ae782d", size = 53149035, upload-time = "2026-08-10T12:40:41.454Z" }, + { url = "https://files.pythonhosted.org/packages/93/c0/37d4a7e8e2f7a6076283673d5298018ca26478b934c6ee369e10505ab32c/pyarrow-25.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4288f27577352d608ca08553b0865e4a9b3aa14820c5d95b53337218d609835b", size = 28753071, upload-time = "2026-08-10T12:40:46.623Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -1736,6 +1924,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, ] +[[package]] +name = "pydeck" +version = "0.9.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/c9/f71032fca47ecc09d30904d9a610234b07139f89eabc2f054b141edcc30f/pydeck-0.9.3.tar.gz", hash = "sha256:695775cbfe51f5fdffbd9735ba469987fdc5efc96bc40a0ee4808170509c78b2", size = 5900912, upload-time = "2026-07-02T23:27:08.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/34/3998411437aff304a9ed4fa37a6fe1ef3132bcd2b5eac59851b80c86123c/pydeck-0.9.3-py2.py3-none-any.whl", hash = "sha256:d8a47c11c81fb12d51b1feb42427ff4f0e13cb599e48931021b2cba98b6849a6", size = 11428091, upload-time = "2026-07-02T23:27:06.399Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -2143,6 +2344,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, ] +[[package]] +name = "streamlit" +version = "1.62.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altair" }, + { name = "anyio" }, + { name = "blinker" }, + { name = "click" }, + { name = "httptools" }, + { name = "itsdangerous" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "pyarrow" }, + { name = "pydeck" }, + { name = "python-multipart" }, + { name = "requests" }, + { name = "starlette" }, + { name = "toml" }, + { name = "typing-extensions" }, + { name = "uvicorn" }, + { name = "watchdog", marker = "sys_platform != 'darwin'" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/94/928c44a8b7bcd602fc4a16025e9868bcdb88b92bcdb2e53dec188d034fc4/streamlit-1.62.0.tar.gz", hash = "sha256:9d2571da6e6799cbaf0f59548f5773926260a87a69807cf3e2f0f68f9f5e4d45", size = 9883501, upload-time = "2026-08-19T18:31:22.864Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/57/78864764c53885db8a378cc1c47329b4b6e095f7ebd89cd1ffcca4027c89/streamlit-1.62.0-py3-none-any.whl", hash = "sha256:294dbcfe0d6531b0d8593a095e6872dcc6ec4b731723fbb318a0f8102e69162e", size = 10490146, upload-time = "2026-08-19T18:31:19.957Z" }, +] + [[package]] name = "tenacity" version = "9.1.4" @@ -2192,6 +2425,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/35/5e/9b01afd037bfa22a0033963fa091e0f75b6fb15cd85bffb42ff86e697323/tiktoken-0.14.0-cp315-cp315t-win_amd64.whl", hash = "sha256:561e7580f84a79859af1ef6f676968e9030fcc3fe195700b15235bca64f009c9", size = 987929, upload-time = "2026-08-17T19:49:38.947Z" }, ] +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + [[package]] name = "tornado" version = "6.5.8" @@ -2248,6 +2490,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, ] +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + [[package]] name = "uncalled-for" version = "0.4.0" @@ -2316,6 +2567,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" }, ] +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + [[package]] name = "watchfiles" version = "1.2.0"