Pynteract is a lightweight, dependency-minimal, embeddable Python “cell shell” with IPython-style ergonomics:
magics (%, %%), system commands (!, !!), per-cell filenames for better tracebacks, and a hookable execution pipeline.
It is designed to be used inside notebooks, web apps, AI agents, CLIs, and other embedded environments where you want: deterministic execution, controllable IO, and rich integration points.
- Install
- Quick start
- Core concepts
- Magics and system commands
- Hooks
- Interactive terminal mode
- CLI
- Threads and late output routing
- API reference
- Development
- License
pip install pynteractOptional terminal interactive mode (adds prompt_toolkit):
pip install "pynteract[terminal]"from pynteract import Shell
shell = Shell(display_mode="none")
resp = shell.run("x = 41 + 1\nx")
assert resp.result == 42
assert resp.stdout == ""
assert resp.stderr == ""
assert resp.exception is None- Code is executed node-by-node (AST-level), enabling pre/post hooks per statement/expression.
display_modecontrols what expression results are displayed:"last"(default): display only the last expression value"all": display every expression value"none": never display expression values
- A semicolon after an expression suppresses display, mirroring IPython:
x;.
Pynteract runs in a module-backed namespace by default (good __module__ behavior, better tracebacks).
You can embed it in an existing namespace:
import sys
from pynteract import Shell
shell = Shell(module_name="__main__", namespace=sys.modules["__main__"].__dict__)Each run() is assigned a synthetic filename like <shell-input-3> (or a custom one via filename=...).
This filename is used for:
- tracebacks
Shell.historykeys- hook routing via
RunContext.name(handy in notebook-like UIs)
Shell.history is an OrderedDict of recent ShellResponse objects keyed by synthetic/custom filename.
The size is capped by history_size.
By default, Shell(use_internal_cwd=True) keeps a shell-local cwd. run(..., cwd=...), %cd, or os.chdir() inside user code update shell.cwd, while the caller process cwd is restored after each run.
For embedders that intentionally want Python execution to share and mutate the host process cwd, use Shell(use_internal_cwd=False). In that mode, shell.cwd reflects os.getcwd(), and a run leaves the process cwd wherever user code ended.
from pynteract import Shell
shell = Shell(display_mode="none")
@shell.register_magic(name="caps", mode="both") # "line" | "cell" | "both"
def caps(text: str) -> str:
return text.upper()
assert shell.run("%caps hello").result == "HELLO"- Line magic:
%name rest of line - Cell magic:
%%nameon the first line; the remaining cell body is passed as a string - Inline magic:
x = %name rest of line(also works after;)
Inside magics and system commands, {expr} is evaluated in the current namespace and replaced with str(value).
- Escape literal braces with
{{/}}.
!cmd ...runs a system command and streams stdout/stderr into the captured streams.!!cmd ...runs a system command, captures stdout, and returns it as the cell result (stderr still streams).
shell = Shell(display_mode="none")
shell.run('!python3 -c "print(123)"').stdout
shell.run('!!python3 -c "print(456)"').resultPynteract ships a small set of “IPython-like” convenience magics (registered automatically by Shell.ensure_builtins()):
%pwd,%cd [path|-],%ls [path]%env,%env KEY,%env KEY=value,%env -u KEY%who,%whos%run [-i] script.py [args...]%time <code>,%timeit [-n N] [-r R] <code>%%bash
Hooks are stored in shell.hooks (a dict) and are looked up dynamically during execution. Updating a hook
while the shell is running takes effect immediately.
from pynteract import Shell
def stdout_hook(data: str, buffer: str, ctx) -> None:
print("STDOUT:", data, end="")
shell = Shell(display_mode="none", stdout_hook=stdout_hook)
shell.hooks["stdout_hook"] = stdout_hook # can be swapped dynamicallyHooks receive a final ctx argument (RunContext) for routing. ctx.name matches the synthetic/custom
filename of the current run (or another routing name if you override it). This routing context is intended
for advanced late redirection or custom dynamic routing.
from pynteract import Shell
Shell().interact()Interactive sessions store prompt histories under ~/.pynteract/ (or PYNTERACT_CONFIG_DIR):
history_python.txt: interactive>>>input historyhistory_text.txt: stdin “text” history used forinput()reads
If present and non-empty, ~/.pynteract/startup.py is executed at the start of interactive sessions.
- The startup file is executed via
Shell.run(..., silent=True)so magics/system commands are supported. - If the startup fails, Pynteract prints the enriched traceback and exits (non-zero status in the CLI).
- When using
pynteract -i script.py, the startup script runs before the script (same namespace kept for the REPL).
In interactive mode, you can reset the namespace and rerun startup:
__shell__.restart_session()Installing the package provides a pynteract executable.
# Interactive session
pynteract
# Run a script (similar to `python script.py`)
pynteract path/to/script.py [args...]
# Run a script, then enter interactive mode with the same namespace
pynteract -i path/to/script.py [args...]To keep output from background threads routed to the originating cell/run, call shell.enable_stdio_proxy() once,
then capture and propagate a contextvars.Context:
import threading, time
from pynteract import Shell
shell = Shell(display_mode="none")
shell.enable_stdio_proxy()
def worker():
for i in range(3):
print(f"tick {i}")
time.sleep(0.1)
shell.run("print('cell start')", filename="<cell-1>")
ctx = shell.capture_context() # captures ctx.name == "<cell-1>"
threading.Thread(target=lambda: ctx.run(worker), daemon=True).start()In a notebook UI, you typically want each cell to own its own stdout/stderr widget, and you want background threads spawned by a cell to keep streaming into that same widget even after the cell finished.
Use the run filename= (exposed as ctx.name to hooks) as your routing key:
from pynteract import Shell
cell_widgets = {} # e.g. {"cell-42": StdoutTextArea(...)}
def cell_id_from_ctx(ctx) -> str:
return ctx.name.split(":")[1] # e.g. "<nb:cell-42:run-7>" -> "cell-42"
def stdout_router(data: str, _buffer: str, ctx) -> None:
cell_widgets[cell_id_from_ctx(ctx)].append(data)
shell = Shell(display_mode="none", stdout_hook=stdout_router)
shell.enable_stdio_proxy()
def run_cell(cell_id: str, run_no: int, code: str) -> None:
shell.run(code, filename=f"<nb:{cell_id}:run-{run_no}>")Constructor:
Shell(
namespace: dict | None = None,
module_name: str | None = None,
ensure_cwd_on_syspath: bool = True,
display_mode: Literal["all", "last", "none"] = "last",
history_size: int = 200,
silent: bool = False,
use_internal_cwd: bool = True,
**hooks,
)Key methods:
| Method | Signature | Notes |
|---|---|---|
| Execute | run(code, globals=None, locals=None, silent=None, filename=None, cwd=None) -> ShellResponse |
cwd selects the run working directory. With use_internal_cwd=True (default), the shell keeps its own cwd and restores the caller process cwd; with False, runs use/mutate the ambient process cwd. |
| Interactive | interact() -> int |
Terminal REPL; returns process-like exit code. |
| Restart | restart_session(rerun_startup=True, announce=True) -> int |
Resets namespace and (optionally) reruns startup. |
| Namespace | update_namespace(**kwargs) |
Adds symbols to the execution namespace. |
| Namespace | set_namespace(namespace: dict) |
Switches to a different dict namespace. |
| Namespace | reset_namespace() |
Clears user symbols and resets __future__ flags. |
| Magics | register_magic(func=None, *, name=None, mode="both") |
Decorator or direct call; mode `"line" |
| Threads | enable_stdio_proxy() |
Installs a routing proxy on sys.stdout/sys.stderr for late-thread capture. |
| Threads | capture_context(name=None) -> contextvars.Context |
Capture routing context for another thread. |
| Builtins | ensure_builtins() |
(Re)adds __shell__, __magics__, display, and built-in magics. |
Important public attributes:
| Attribute | Type | Meaning |
|---|---|---|
namespace |
dict |
Module-backed execution namespace. |
hooks |
dict[str, Any] |
Hook registry; updated dynamically. |
magics |
dict[str, Any] |
Registered magics. |
history |
OrderedDict[str, ShellResponse] |
Recent run history keyed by filename. |
cwd |
str |
Current working directory used by run()/arun(): shell-local in internal-cwd mode, process cwd in ambient mode. |
last_result |
Any |
Last expression value. |
silent |
bool |
Instance default for suppressing stdout/stderr hooks while still capturing output. |
Returned by Shell.run(...).
| Field | Type | Meaning |
|---|---|---|
input |
str |
Original source string. |
processed_input |
str |
Expanded source (magics/system commands). |
stdout / stderr |
str |
Captured output for the run. |
result |
Any |
Last expression value (depending on display_mode). |
exception |
`Exception | None` |
Hooks receive a RunContext object (ctx) with:
ctx.name: routing name (by default the synthetic/customfilenameof the currentrun()).
Use ctx.name as the routing key when you need late redirection (background threads) or custom dynamic routing.
Use shell.capture_context() to propagate routing/capture context to a new thread:
ctx = shell.capture_context()
threading.Thread(target=lambda: ctx.run(worker)).start()All hooks are optional. Hook keys live in shell.hooks and are passed to Shell(...) via **hooks (kwargs must end with _hook).
When provided, hooks must accept a final ctx: RunContext to support late redirection and custom dynamic routing.
| Hook key | Signature | When it runs |
|---|---|---|
input_hook |
input_hook(code: str, ctx) -> None |
Before parsing. |
pre_run_hook |
pre_run_hook(code: str, ctx) -> str |
Before tokenization/execution; can rewrite source. |
code_block_hook |
code_block_hook(code_block: str, ctx) -> None |
For each executed AST block. |
pre_execute_hook |
pre_execute_hook(node, source, ctx) -> ast.AST |
Before compiling a node. |
post_execute_hook |
post_execute_hook(node, result, ctx) -> None |
After a node executes. |
display_hook |
display_hook(obj, kwargs, ctx) -> None |
When displaying expression values. |
stdout_hook |
stdout_hook(data: str, buffer: str, ctx) -> None |
As stdout is flushed. |
stderr_hook |
stderr_hook(data: str, buffer: str, ctx) -> None |
As stderr is flushed. |
stdin_hook |
`stdin_hook(ctx) -> str | None` |
exception_hook |
exception_hook(exc: Exception, ctx) -> None |
When a run finishes with an error. |
namespace_change_hook |
namespace_change_hook(old, new, locals, ctx) -> None |
After a run, with before/after namespaces. |
post_run_hook |
post_run_hook(resp: ShellResponse, ctx) -> ShellResponse |
Final response override/hook. |
Pynteract stores user-facing state under ~/.pynteract/ (override with PYNTERACT_CONFIG_DIR):
startup.py: optional startup file (interactive +-ionly)history_python.txt: persistent REPL historyhistory_text.txt: persistent stdin history
pip install -e ".[dev]"
pytest -qTo develop terminal interactive mode:
pip install -e ".[dev,terminal]"MIT. See LICENSE file.