- st_demo: persist failure in call history, clear active_handle, and disable Run while call active to avoid overwriting handle - JupyterApp.shutdown: gather all shutdowns with return_exceptions=True and re-raise as BaseExceptionGroup so one failure doesn't leak kernels - messages: fallback known-but-malformed messages to UnknownJupyterMessage via ValidationError, make Unknown channel permissive (str) for nuance - transport: record reader failures as terminal state (_reader_failure) and fail fast from execute/messages_for instead of hanging - shell: don't reset execution_count to 0 when IPython returns None - README: use ipython_shell imports Skipped non-urgent/deferred items: line(), get_shell_2, test renames, import laziness, call-ID bounding, settle window config (worth doing later).
271 lines
8.0 KiB
Python
271 lines
8.0 KiB
Python
from typing import Annotated, ClassVar, Final, Literal
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
|
|
|
|
|
|
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."""
|
|
|
|
# Allow any channel value so known-but-malformed fallbacks never fail
|
|
# on a strict Literal. The parser preserves the transport channel verbatim.
|
|
channel: str = "iopub" # type: ignore[assignment]
|
|
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})
|
|
try:
|
|
return _KNOWN_MESSAGE_ADAPTER.validate_python(values)
|
|
except ValidationError:
|
|
# Known msg_type but content or channel doesn't match the strict
|
|
# model (e.g., extra execution_state, wrong stdout/stderr name,
|
|
# or msg delivered on an unexpected channel). Degrade losslessly
|
|
# instead of aborting the whole call.
|
|
return UnknownJupyterMessage.model_validate({**values, "raw": raw})
|
|
|
|
|
|
# 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
|