Build typed IPython app execution pipeline
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
"""Small, side-effect-free API for the in-process IPython runner."""
|
||||
|
||||
from .app import App, generate_good_names
|
||||
from .events import (
|
||||
error_from_execution_result,
|
||||
event_from_execution_result,
|
||||
event_from_history_output,
|
||||
)
|
||||
from .models import CallError, CallEvent, CallEventKind, CallResponse, CallResult
|
||||
from .shell import (
|
||||
Shell,
|
||||
isolate_output_history,
|
||||
run_cell_and_collect,
|
||||
setup_shell,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"App",
|
||||
"CallEvent",
|
||||
"CallEventKind",
|
||||
"CallError",
|
||||
"CallResponse",
|
||||
"CallResult",
|
||||
"error_from_execution_result",
|
||||
"event_from_execution_result",
|
||||
"Shell",
|
||||
"event_from_history_output",
|
||||
"generate_good_names",
|
||||
"isolate_output_history",
|
||||
"run_cell_and_collect",
|
||||
"setup_shell",
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the package's minimal command-line entry point."""
|
||||
print("Hello from ipython_demo!")
|
||||
@@ -0,0 +1,82 @@
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
from random import choice
|
||||
from uuid import uuid4
|
||||
|
||||
from .models import CallResponse
|
||||
from .shell import Shell
|
||||
|
||||
|
||||
_ADJECTIVES = ("bright", "calm", "curious", "quiet", "swift")
|
||||
_NOUNS = ("otter", "comet", "raven", "panda", "badger")
|
||||
|
||||
|
||||
def generate_good_names() -> str:
|
||||
"""Generate a readable, collision-resistant shell name."""
|
||||
return f"{choice(_ADJECTIVES)}-{choice(_NOUNS)}-{uuid4().hex[:4]}"
|
||||
|
||||
|
||||
class App:
|
||||
"""Coordinate named shells and expose frontend-shaped call responses."""
|
||||
|
||||
def __init__(self):
|
||||
self.shells: dict[str, Shell] = {}
|
||||
self.last_shell: str | None = None
|
||||
|
||||
def _new_shell(self) -> tuple[str, Shell]:
|
||||
name = generate_good_names()
|
||||
while name in self.shells:
|
||||
name = generate_good_names()
|
||||
shell = Shell(name)
|
||||
self.shells[name] = shell
|
||||
self.last_shell = name
|
||||
return name, shell
|
||||
|
||||
def _select_shell(self, shell_name: str) -> tuple[str, Shell]:
|
||||
if shell_name == "new":
|
||||
return self._new_shell()
|
||||
|
||||
if shell_name == "last":
|
||||
if self.last_shell is None:
|
||||
return self._new_shell()
|
||||
return self.last_shell, self.shells[self.last_shell]
|
||||
|
||||
if shell_name not in self.shells:
|
||||
self.shells[shell_name] = Shell(shell_name)
|
||||
self.last_shell = shell_name
|
||||
return shell_name, self.shells[shell_name]
|
||||
|
||||
def run_code(
|
||||
self,
|
||||
code: str,
|
||||
shell_name: str = "last",
|
||||
*,
|
||||
collapsed: bool = False,
|
||||
) -> CallResponse:
|
||||
"""Run code in the selected shell and return the complete call payload."""
|
||||
selected_name, shell = self._select_shell(shell_name)
|
||||
call_id = uuid4().hex
|
||||
result, events = shell.run_cell(code, call_id=call_id)
|
||||
return CallResponse(
|
||||
call_id=call_id,
|
||||
shell_name=selected_name,
|
||||
code=code,
|
||||
collapsed=collapsed,
|
||||
result=result,
|
||||
events=events,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Show the payload a frontend would receive from two related calls."""
|
||||
app = App()
|
||||
# Keep the transport clean: stdout is represented by an event in the
|
||||
# payload, not emitted beside the JSON document.
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
calls = [
|
||||
app.run_code("print('hello from the shell'); 2 + 2", shell_name="new"),
|
||||
app.run_code("40 + 2", shell_name="last", collapsed=True),
|
||||
]
|
||||
print(json.dumps([asdict(call) for call in calls], indent=2, default=repr))
|
||||
@@ -0,0 +1,79 @@
|
||||
import traceback as traceback_module
|
||||
|
||||
from IPython.core.history import HistoryOutput
|
||||
from IPython.core.interactiveshell import ExecutionResult
|
||||
|
||||
from .models import CallError, CallEvent
|
||||
|
||||
|
||||
def event_from_history_output(
|
||||
output: HistoryOutput,
|
||||
*,
|
||||
call_id: str,
|
||||
sequence: int,
|
||||
) -> CallEvent | None:
|
||||
"""Convert one IPython ``HistoryOutput`` into the event model."""
|
||||
if output.output_type == "out_stream":
|
||||
return CallEvent(
|
||||
call_id=call_id,
|
||||
sequence=sequence,
|
||||
kind="stdout",
|
||||
text="".join(output.bundle.get("stream", [])),
|
||||
)
|
||||
|
||||
if output.output_type == "err_stream":
|
||||
return CallEvent(
|
||||
call_id=call_id,
|
||||
sequence=sequence,
|
||||
kind="stderr",
|
||||
text="".join(output.bundle.get("stream", [])),
|
||||
)
|
||||
|
||||
if output.output_type in {"display_data", "execute_result"}:
|
||||
return CallEvent(
|
||||
call_id=call_id,
|
||||
sequence=sequence,
|
||||
kind=output.output_type,
|
||||
data=dict(output.bundle),
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def error_from_execution_result(
|
||||
execution: ExecutionResult,
|
||||
) -> CallError | None:
|
||||
"""Convert an IPython execution error into the application model."""
|
||||
error = execution.error_before_exec or execution.error_in_exec
|
||||
if error is None:
|
||||
return None
|
||||
|
||||
return CallError(
|
||||
ename=type(error).__name__,
|
||||
evalue=str(error),
|
||||
traceback=traceback_module.format_exception(error),
|
||||
)
|
||||
|
||||
|
||||
def event_from_execution_result(
|
||||
execution: ExecutionResult,
|
||||
*,
|
||||
call_id: str,
|
||||
sequence: int,
|
||||
) -> CallEvent | None:
|
||||
"""Convert an IPython execution error into an ``error`` event."""
|
||||
error = error_from_execution_result(execution)
|
||||
if error is None:
|
||||
return None
|
||||
|
||||
return CallEvent(
|
||||
call_id=call_id,
|
||||
sequence=sequence,
|
||||
kind="error",
|
||||
text=error.evalue,
|
||||
data={
|
||||
"ename": error.ename,
|
||||
"evalue": error.evalue,
|
||||
"traceback": error.traceback,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
|
||||
CallEventKind = Literal[
|
||||
"stdout",
|
||||
"stderr",
|
||||
"display_data",
|
||||
"execute_result",
|
||||
"clear_output",
|
||||
"update_display_data",
|
||||
"error",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CallEvent:
|
||||
"""One ordered, UI-safe event emitted while a call executes."""
|
||||
|
||||
call_id: str
|
||||
sequence: int
|
||||
kind: CallEventKind
|
||||
text: str | None = None
|
||||
data: dict[str, object] | None = None
|
||||
metadata: dict[str, object] | None = None
|
||||
transient: dict[str, object] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CallError:
|
||||
"""Serializable error information from one cell execution."""
|
||||
|
||||
ename: str
|
||||
evalue: str
|
||||
traceback: list[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CallResult:
|
||||
"""The application-level result of one cell execution."""
|
||||
|
||||
call_id: str
|
||||
execution_count: int | None
|
||||
result: object | None
|
||||
error: CallError | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CallResponse:
|
||||
"""Complete response envelope intended for a UI or graph node."""
|
||||
|
||||
call_id: str
|
||||
shell_name: str
|
||||
code: str
|
||||
collapsed: bool
|
||||
result: CallResult
|
||||
events: list[CallEvent]
|
||||
@@ -0,0 +1,158 @@
|
||||
from collections import defaultdict
|
||||
|
||||
from IPython.core.interactiveshell import InteractiveShell
|
||||
from IPython.core.pylabtools import (
|
||||
activate_matplotlib,
|
||||
import_pylab,
|
||||
mpl_runner,
|
||||
select_figure_formats,
|
||||
)
|
||||
from matplotlib_inline.backend_inline import configure_inline_support
|
||||
|
||||
from .events import (
|
||||
error_from_execution_result,
|
||||
event_from_execution_result,
|
||||
event_from_history_output,
|
||||
)
|
||||
from .models import CallEvent, CallResult
|
||||
|
||||
|
||||
class Shell:
|
||||
"""Own one configured, in-process IPython shell."""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self._shell: InteractiveShell | None = None
|
||||
|
||||
@property
|
||||
def shell(self) -> InteractiveShell:
|
||||
if self._shell is not None:
|
||||
return self._shell
|
||||
return self.init_shell()
|
||||
|
||||
def init_shell(self) -> InteractiveShell:
|
||||
if self._shell is not None:
|
||||
return self._shell
|
||||
|
||||
self._shell = InteractiveShell()
|
||||
setup_shell(self._shell, plt=True, pylab=True)
|
||||
return self._shell
|
||||
|
||||
def run_cell(
|
||||
self,
|
||||
code: str,
|
||||
*,
|
||||
call_id: str,
|
||||
) -> tuple[CallResult, list[CallEvent]]:
|
||||
"""Execute one cell and return its result plus ordered output events."""
|
||||
return run_cell_and_collect(self.init_shell(), code, call_id=call_id)
|
||||
|
||||
|
||||
def isolate_output_history(shell: InteractiveShell) -> None:
|
||||
"""Give this shell an instance-owned output ledger.
|
||||
|
||||
IPython declares ``HistoryManager.outputs`` on the class. Shadowing it
|
||||
during shell setup prevents output records from leaking across shells.
|
||||
"""
|
||||
history_manager = shell.history_manager
|
||||
if history_manager is None:
|
||||
raise RuntimeError("InteractiveShell has no history manager")
|
||||
history_manager.outputs = defaultdict(list)
|
||||
|
||||
|
||||
def run_cell_and_collect(
|
||||
shell: InteractiveShell,
|
||||
code: str,
|
||||
*,
|
||||
call_id: str,
|
||||
) -> tuple[CallResult, list[CallEvent]]:
|
||||
"""Run one cell and snapshot its output records after execution."""
|
||||
history_manager = shell.history_manager
|
||||
if history_manager is None:
|
||||
raise RuntimeError("InteractiveShell has no history manager")
|
||||
if "outputs" not in history_manager.__dict__:
|
||||
isolate_output_history(shell)
|
||||
|
||||
execution = shell.run_cell(code, store_history=True)
|
||||
records = tuple(
|
||||
history_manager.outputs.get(execution.execution_count, ())
|
||||
)
|
||||
events = [
|
||||
event_from_history_output(
|
||||
record,
|
||||
call_id=call_id,
|
||||
sequence=sequence,
|
||||
)
|
||||
for sequence, record in enumerate(records)
|
||||
]
|
||||
events = [event for event in events if event is not None]
|
||||
|
||||
error = error_from_execution_result(execution)
|
||||
if error is not None:
|
||||
error_event = event_from_execution_result(
|
||||
execution,
|
||||
call_id=call_id,
|
||||
sequence=len(events),
|
||||
)
|
||||
if error_event is not None:
|
||||
events.append(error_event)
|
||||
|
||||
return (
|
||||
CallResult(
|
||||
call_id=call_id,
|
||||
execution_count=execution.execution_count,
|
||||
result=execution.result,
|
||||
error=error,
|
||||
),
|
||||
events,
|
||||
)
|
||||
|
||||
|
||||
def setup_shell(shell: InteractiveShell, plt: bool = False, pylab: bool = True):
|
||||
"""Configure an ``InteractiveShell`` without requiring GUI support.
|
||||
|
||||
``InteractiveShell.enable_matplotlib`` and ``enable_pylab`` eventually
|
||||
call ``enable_gui``. The base shell intentionally does not implement that
|
||||
method, so the lower-level matplotlib-inline setup is used here instead.
|
||||
"""
|
||||
history_manager = shell.history_manager
|
||||
if history_manager is not None:
|
||||
history_manager.enabled = False
|
||||
|
||||
# ``HistoryManager.outputs`` is class-level in the IPython version used
|
||||
# here. Give every managed shell an instance-local mapping so execution
|
||||
# records cannot leak between Shell objects.
|
||||
if "outputs" not in history_manager.__dict__:
|
||||
isolate_output_history(shell)
|
||||
|
||||
backend = "module://matplotlib_inline.backend_inline"
|
||||
|
||||
# This is the non-GUI part of enable_matplotlib().
|
||||
if plt:
|
||||
activate_matplotlib(backend)
|
||||
shell.magics_manager.registry[
|
||||
"ExecutionMagics"
|
||||
].default_runner = mpl_runner(shell.safe_execfile)
|
||||
|
||||
# This connects matplotlib-inline to THIS shell.
|
||||
configure_inline_support(shell, backend)
|
||||
|
||||
# Optional explicit format selection; configure_inline_support()
|
||||
# also sets up the configured default formats.
|
||||
select_figure_formats(
|
||||
shell,
|
||||
{"svg", "pdf", "png", "jpg"},
|
||||
)
|
||||
if pylab:
|
||||
# Match enable_pylab's namespace behavior without its GUI activation:
|
||||
# hidden names are used by %who/%whos bookkeeping as well as by code.
|
||||
pylab_ns: dict[str, object] = {}
|
||||
import_pylab(pylab_ns, import_all=False) # no import * allowed
|
||||
shell.user_ns.update(pylab_ns)
|
||||
shell.user_ns_hidden.update(pylab_ns)
|
||||
|
||||
|
||||
# tisi = InteractiveShell.instance()
|
||||
# default_shell = Shell("default_shell")
|
||||
# default_shell.init_shell(tisi)
|
||||
# use me, or dont. idc. but we need to give the default instance the select_figure_formats because i neeeeeeeeeeeeeeeeeeeed it.
|
||||
@@ -0,0 +1,12 @@
|
||||
import inspect
|
||||
|
||||
|
||||
def line():
|
||||
|
||||
this = inspect.currentframe()
|
||||
if not this: # i want ?. operator SOOOOOOOO
|
||||
return None
|
||||
frame = this.f_back # should exist?
|
||||
if not frame:
|
||||
return None
|
||||
return frame.f_lineno
|
||||
Reference in New Issue
Block a user