from fastcore.test import test_eq, test_fail
from jupyter_client.session import Sessionops
ConKernelClient
conkernelclient.core makes concurrent execute() calls safe; this module adds the operations every client ends up needing on top of that: request-scoped iopub collection, execute-and-drain composites, nbformat-style output conversion, interrupt, generic shell/control requests, and a kernel lifecycle context manager. It distills helpers that grew up independently in ipymini’s test suite and solveit’s gateway, keeping the strongest implementation of each.
One caveat applies throughout: shell replies are demultiplexed per-request by ConKernelClient, but iopub is a single broadcast stream. iopub_drain filters it by parent msg_id and discards what it skips, so run one drain at a time. Concurrent executes are fine; concurrent drains are not.
Imports
Message basics
km = ConKernelManager(session=Session(key=b'x'))
await km.start_kernel()
kc = await km.client().start_channels()
await kc.is_alive()True
iter_timeout
def iter_timeout(
timeout:NoneType=None, default:int=10
):Yield remaining seconds until timeout expires, using monotonic time
Every reply carries its request’s msg_id in the parent header; all routing below keys on it. iter_timeout drives every wait loop in this module: it yields the time still available, so a loop body can pass a shrinking timeout to each blocking call.
r = await kc.execute('1+1', reply=True)
test_eq(parent_id(r), r['parent_header']['msg_id'])
assert parent_id({}) is NoneIopub collection
ConKernelClient.iopub_drain
async def iopub_drain(
msg_id, timeout:int=10
):Collect iopub messages parented to msg_id until its idle status arrives. Other requests’ messages are discarded: one drain at a time
ConKernelClient.iopub_flush
async def iopub_flush(
timeout:float=0.1
):Discard all pending iopub messages, e.g. leftovers from fire-and-forget executes
iopub_flush is the counterpart for when you don’t want collection: clear the backlog before starting a push-based consumer or a fresh interaction.
kc.execute('print("junk")')
await kc.iopub_flush()
try:
await kc.get_iopub_msg(timeout=0.1)
assert False, 'iopub should be empty after flush'
except Empty: passCollecting a request’s output means draining iopub until the kernel publishes the idle status for that request - that is the protocol’s “this request is done publishing” signal, far more reliable than sweeping whatever is pending after a fixed delay.
mid = kc.execute('print("hi"); 42')
msgs = await kc.iopub_drain(mid)
test_eq([m['msg_type'] for m in msgs if m['msg_type']!='status'], ['execute_input', 'stream', 'execute_result'])Outputs
iopub_streams
def iopub_streams(
msgs, name:NoneType=None
):The stream messages in msgs, optionally only stream name (‘stdout’/‘stderr’)
test_eq([m['msg_type'] for m in iopub_msgs(msgs, 'execute_result')], ['execute_result'])
test_eq([m['msg_type'] for m in iopub_msgs(msgs, output_types)], ['stream', 'execute_result'])
test_eq(iopub_streams(msgs)[0]['content']['text'], 'hi\n')
test_eq(iopub_streams(msgs, 'stderr'), [])Raw-message filters for when the protocol wrapping is wanted (parent ids, per-message metadata) - msgs2outs is the lossy convenience, these are the lossless ones.
Iopub messages carry protocol wrapping (headers, status chatter, execute_input echoes) that consumers rarely want. fastcore.nbio.msgs2outs reduces them to the same output dicts a notebook file stores (mirroring nbformat.v4.output_from_msg without the dependency, minus schema validation), and exec_outs below composes it with a drain. output_types names the message types that carry outputs, for callers filtering by hand.
outs = msgs2outs(msgs)
test_eq(outs[0], dict(output_type='stream', name='stdout', text='hi\n'))
test_eq(outs[1]['data']['text/plain'], '42')Execute composites
ConKernelClient.exec_outs
async def exec_outs(
code, timeout:int=10, **kw
):Execute code and return just its nbformat-style outputs
ConKernelClient.exec_ok
async def exec_ok(
code, timeout:int=10, **kw
):exec_drain, asserting the reply status is ok
ConKernelClient.exec_drain
async def exec_drain(
code, timeout:int=10, **kw
):Execute code; return (reply, outputs) where outputs are the request’s iopub messages
The composites cover the three common shapes: full protocol detail (exec_drain), tests that just need success (exec_ok), and consumers that only want outputs (exec_outs).
reply, outputs = await kc.exec_ok('x = 3; x')
test_eq(reply['content']['status'], 'ok')
test_eq((await kc.exec_outs('print(x*2)')), [dict(output_type='stream', name='stdout', text='6\n')])
reply, _ = await kc.exec_drain('1/0')
test_eq(reply['content']['ename'], 'ZeroDivisionError')Expression values
ConKernelClient.eval_expr
async def eval_expr(
expr:str, code:str='', timeout:int=10, **kw
):Evaluate expr in the kernel (optionally after running code) and return its value: parsed via literal_eval when its repr allows, else the repr string. Raises EvalError if the kernel raises
ConKernelClient.user_exprs
async def user_exprs(
exprs:dict, code:str='', timeout:int=10, **kw
):Run code and evaluate each of the exprs expressions in the same round trip; returns the reply content (statuses and mimebundles unparsed)
parse_expr
def parse_expr(
s
):The literal_eval of s when its form allows, else s unchanged
EvalError
def EvalError(
*args, **kwargs
):An eval_expr expression raised in the kernel
The user_expressions round trip: one execute carries the expression, the kernel evaluates it after the (empty) cell and returns its repr inside the execute_reply - no iopub involved, so it works cleanly alongside streaming output. Values whose repr is not literal_eval-able come back as that repr string. This is the general core of solveit’s richer eval RPC.
await kc.exec_ok("v = [1, 'a', {'b': 2}]")
test_eq(await kc.eval_expr('v'), [1, 'a', {'b': 2}])
test_eq(await kc.eval_expr('w * 2', code='w = 21'), 42)
cts = await kc.user_exprs({'a': 'v[0]', 'b': 'len(v)'})
test_eq(cts['status'], 'ok')
test_eq(parse_expr(cts['user_expressions']['a']['data']['text/plain']), 1)
test_eq(parse_expr(cts['user_expressions']['b']['data']['text/plain']), 3)
test_eq(await kc.eval_expr('sum(v[2].values())'), 2)
r = await kc.eval_expr('print')
assert isinstance(r, str) and 'print' in r, r
try:
await kc.eval_expr('nope_undefined')
assert False, 'expected EvalError'
except EvalError as e: assert 'NameError' in str(e)Calling kernel functions
eval turns the user_expressions round trip into a function call: run func(*args, **kw) kernel-side (awaiting coroutines), bring the result back by repr, and reconstruct it client-side — try_eval wraps primitive results in a dynamic class named after the kernel-side type, so Markdown reprs still compare equal while remembering what they were. _call=False skips the call and evaluates func as a bare expression. The whole family – eval, ipy (get_ipython() methods), the generated service methods mirroring what ipyfuncs patches onto the kernel’s shell (sig_help, get_schemas, ranked_complete, …), and xpush/retr/xenv – is inherited from jupywire’s EvalOps mixin over this module’s reply; _pre_ipy below adds the zmq liveness check. priority= sends kernmini’s priority: 1 execute metadata, so the call overtakes queued work on a kernel that honours it; ipykernel ignores the key. The service methods default it on.
ConKernelClient.reply
def reply(
code, # A string of code in the kernel's language.
user_expressions:NoneType=None, # A dict mapping names to expressions to be evaluated in the user's dict
allow_stdin:NoneType=None, # Flag for whether the kernel can send stdin requests to frontends.
cts_typ:str='code', timeout:int=None, msg_id:NoneType=None, **kw
):The awaited transport seam: run code, returning an awaitable of the execute_reply message.
await kc.reply('def add(a, b): return a+b')
test_eq(await kc.eval('add', a=10, b=20), 30)
await kc.reply('async def add(a, b): return a+b')
test_eq(await kc.eval('add', a=10, b=20, _literal=False), '30')
await kc.reply('a = [1,2,3]')
test_eq(await kc.eval('a', _call=False), [1,2,3])
test_eq((await kc.eval('add', a=30, b=40, _literal=False), await kc.eval('add', a=30, b=40)), ('70', 70))await kc.reply('from fastcore.xml import Safe')
r = await kc.eval('Safe', 'hello')
isinstance_str(r, 'Safe')True
await kc.reply('from IPython.display import Markdown')
await kc.eval('Markdown', data=r"*a\a*", _literal=False)'*a\\a*'
With literal=False, eval() skips literal_eval on the result:
Not evaluating allows getting non-builtin types as results:
await kc.reply("""
class Foo:
def __init__(self, val: int): self.val = val
def __repr__(self): return f'Foo instance with a value of {self.val}'
def make_foo(): return Foo(123)
""")
result = await kc.eval("Foo", val=99, _literal=False)
assert 'Foo instance' in result
test_eq(type(result), str)
result, type(result)('Foo instance with a value of 99', str)
_r = await kc.eval('f', _timeout=10)
assert 'NameError' in _r, f'Expected NameError, got: {_r}'
print(_r)---------------------------------------------------------------------------
NameError Traceback (most recent call last)
Cell In[9], line 2
1 import asyncio
----> 2 __254d11bb = f(*(), **{})
3 if asyncio.iscoroutine(__254d11bb): __254d11bb = await __254d11bb
NameError: name 'f' is not defined
The _ipy_funcs services live kernel-side in ipyfuncs; importing it is the whole setup, so this battery doesn’t depend on a venv-level IPython config:
await kc.exec_ok('import ipyfuncs')await kc.reply('''def range_ex(
a:str # some param
):
"some func docstring"
...'''){'header': {'msg_id': 'f40f78a3-249cb73f14dbb4b0d1640fd1_89345_172',
'msg_type': 'execute_reply',
'username': 'jhoward',
'session': 'f40f78a3-249cb73f14dbb4b0d1640fd1',
'date': datetime.datetime(2026, 8, 3, 7, 18, 32, 384722, tzinfo=tzutc()),
'version': '5.4'},
'msg_id': 'f40f78a3-249cb73f14dbb4b0d1640fd1_89345_172',
'msg_type': 'execute_reply',
'parent_header': {'msg_id': 'fc18205f-f426f5401e4dca936a58e529_89336_38',
'msg_type': 'execute_request',
'username': 'jhoward',
'session': 'fc18205f-f426f5401e4dca936a58e529',
'date': datetime.datetime(2026, 8, 3, 7, 18, 32, 383610, tzinfo=tzutc()),
'version': '5.4'},
'metadata': {},
'content': {'status': 'ok',
'execution_count': 21,
'user_expressions': {},
'payload': []},
'buffers': []}
await kc.eval('get_ipython().ranked_complete', code='rang', line_no=1, col_no=5, _timeout=0.2)[{'text': 'range',
'type': 'class',
'signature': '',
'start': 0,
'end': 4,
'mod': None,
'rank': 5},
{'text': 'range_ex',
'type': 'function',
'signature': '(a: str)',
'start': 0,
'end': 4,
'mod': '__main__',
'rank': 2}]
await kc.ranked_complete(code='rang', line_no=1, col_no=5)[{'text': 'range',
'type': 'class',
'signature': '',
'start': 0,
'end': 4,
'mod': None,
'rank': 5},
{'text': 'range_ex',
'type': 'function',
'signature': '(a: str)',
'start': 0,
'end': 4,
'mod': '__main__',
'rank': 2}]
await kc.reply('a=1')
test_eq(await kc.get_vars(vs=['a']), {'a': 1})
await kc.user_items(max_len=100)({'a': '1',
'payl': "{'source': 'testing', 'foo': 'bar'}",
'pm': '<IPython.core.payload.PayloadManager object>',
'user_input': 'bbb',
'test_var': '42',
'another_var': 'hello'},
{'add': '(a, b)', 'make_foo': '()', 'range_ex': '(a: str)'})
res = await kc.sig_help(code='range(', line_no=1, col_no=6)
res[0]{'label': 'class range',
'typ': 'class',
'mod': 'builtins',
'doc': 'range(stop: SupportsIndex, /)\nrange(start: SupportsIndex, stop: SupportsIndex, step: SupportsIndex=1, /)\n\nrange(stop) -> range object\nrange(start, stop[, step]) -> range object\n\nReturn an object that produces a sequence of integers from start (inclusive)\nto stop (exclusive) by step. range(i, j) produces i, i+1, i+2, ..., j-1.\nstart defaults to 0, and stop is omitted! range(4) produces 0, 1, 2, 3.\nThese are exactly the valid indices for a list of 4 elements.\nWhen step is given, it specifies the increment (or decrement).',
'idx': 0,
'params': [{'name': 'stop', 'desc': 'param stop: SupportsIndex'}]}
await kc.get_schemas(fs=['range_ex']){'range_ex': {'type': 'function',
'function': {'name': 'range_ex',
'description': 'some func docstring',
'parameters': {'type': 'object',
'properties': {'a': {'description': 'some param', 'type': 'string'}},
'required': ['a']}}}}
kc.xpush(asdf=4)test_eq(await kc.retr('asdf'), 4)kc.xenv(hi='johno')
test_eq(await kc.eval('__os.environ["hi"]', _call=False), 'johno')Generic requests
ConKernelClient.control_request
async def control_request(
msg_type, timeout:int=10, **content
):Send a request on the control channel and await its matching reply
ConKernelClient.shell_request
def shell_request(
msg_type, timeout:int=10, reply:bool=True, buffers:NoneType=None,
msg_id:NoneType=None, # Override the auto-generated message id
subshell_id:NoneType=None, # Route via this subshell (header field)
metadata:NoneType=None, # Message metadata (e.g. ipywidgets control-comm version check)
**content
):Send an arbitrary shell request, routed through the reply reader like execute. Returns a coroutine for the reply, or just the msg_id when reply=False (for fire-and-forget types like comm_open that never get replies)
ConKernelClient’s background reader consumes every shell message, so a bare get_shell_msg would race it and lose. Any non-execute shell request (kernel_info_request, complete_request, …) must therefore register with the same routing table execute uses - that is what shell_request does. Pass reply=False for fire-and-forget message types the kernel never answers (comm_open, comm_msg, comm_close), and buffers for binary payloads. Control-channel replies have no reader competing for them, so control_request matches by parent id directly. Note control requests should not be issued concurrently with each other.
r = await kc.shell_request('kernel_info_request')
test_eq(r['header']['msg_type'], 'kernel_info_reply')
r = await kc.control_request('kernel_info_request')
test_eq(r['header']['msg_type'], 'kernel_info_reply')Read the input_request from the stdin channel with get_stdin_msg, then answer it; the blocked execute’s reply follows.
ConKernelClient.input_reply
async def input_reply(
value:str
):Answer the kernel’s pending input_request (from input() in an execute sent with allow_stdin=True)
c = kc.execute("x = input('name? ')", reply=True, timeout=10, allow_stdin=True)
msg = await kc.get_stdin_msg(timeout=10)
test_eq(msg['header']['msg_type'], 'input_request')
await kc.input_reply('Ada')
test_eq((await c)['content']['status'], 'ok')
test_eq((await kc.exec_outs('x'))[0]['data']['text/plain'], "'Ada'")code = """def _t(comm, msg):
global nbuf; nbuf = len(msg.get('buffers', []))
get_ipython().kernel.comm_manager.register_target('t', _t)
"""
await kc.exec_ok(code)
mid = kc.shell_request('comm_open', reply=False, comm_id='c1', target_name='t', data={}, buffers=[b'12345'])
assert isinstance(mid, str)
await kc.exec_ok('import time; time.sleep(0.2)')
test_eq((await kc.exec_outs('nbuf'))[0]['data']['text/plain'], '1')code = """def _tm(comm, msg):
global nmd; nmd = msg.get('metadata')
get_ipython().kernel.comm_manager.register_target('tm', _tm)
"""
await kc.exec_ok(code)
kc.shell_request('comm_open', reply=False, comm_id='cm1', target_name='tm', data={}, metadata={'version':'2.1.0'})
await kc.exec_ok('import time; time.sleep(0.2)')
test_eq((await kc.exec_outs('nmd'))[0]['data']['text/plain'], "{'version': '2.1.0'}")Proxies
ConKernelClient.ctl
def ctl():Control requests as methods: await kc.ctl.shutdown(restart=True) sends shutdown_request
ConKernelClient.cmd
def cmd():Shell requests as methods: await kc.cmd.history(...) sends history_request
ConKernelClient.dap
def dap():DAP debug requests as methods. Stateful (seq counter), so cached per client, unlike cmd/ctl
dap speaks the Debug Adapter Protocol over debug_request control messages. A trailing underscore escapes Python keywords (kc.dap.continue_(...) sends the DAP continue command); full=True returns the whole reply message instead of just its content.
The proxies make one-off protocol requests read like methods without hand-assembling messages. They are stateless, so no caching is needed.
r = await kc.cmd.complete(code='pri', cursor_pos=3)
assert 'print' in r['content']['matches']
r = await kc.cmd.is_complete(code='def f():')
test_eq(r['content']['status'], 'incomplete')r = await kc.shell_request('kernel_info_request', msg_id='fixed-id-1')
test_eq(parent_id(r), 'fixed-id-1')
r = await kc.dap.debugInfo()
test_eq(r['command'], 'debugInfo')
assert r['success']
r2 = await kc.dap.debugInfo(full=True)
test_eq(parent_id(r2), parent_id(r2)) # full message shape
assert r2['header']['msg_type'] == 'debug_reply'Interrupt
ConKernelClient.interrupt
async def interrupt(
timeout:int=5
):Interrupt the running request via interrupt_request on control; returns the reply
The interrupted execute returns an error reply with ename KeyboardInterrupt, and the kernel stays usable. (Solveit’s gateway historically followed the interrupt with an empty execute to wake a parked recv; the Session.send patch in core addresses that wake-up at the transport level, so it is not repeated here.)
task = asyncio.ensure_future(kc.execute('import time; time.sleep(30)', reply=True, timeout=15))
await asyncio.sleep(0.5)
r = await kc.interrupt()
test_eq(r['header']['msg_type'], 'interrupt_reply')
reply = await task
test_eq(reply['content']['ename'], 'KeyboardInterrupt')
test_eq((await kc.exec_outs('40+2'))[0]['data']['text/plain'], '42')Lifecycle
run_kernel
def run_kernel(
kernel_name:str='python3', # Kernelspec name to launch
manager_cls:MetaHasTraits=ConKernelManager, # Manager class, e.g. a subclass customizing launch
**kwargs
):Start a kernel, yield (km, kc) with channels running, and always shut both down
reconnect
async def reconnect(
km, kc:NoneType=None
):Restart km’s kernel (fresh process, state discarded) and return a newly connected client, stopping kc if given
async with run_kernel() as (km3, kc3):
await kc3.exec_ok('x = 42')
pid1 = km3.provisioner.pid
kc3 = await reconnect(km3, kc3)
assert km3.provisioner.pid != pid1
reply, _ = await kc3.exec_drain('x')
test_eq(reply['content']['ename'], 'NameError')
test_eq((await kc3.exec_outs('1+1'))[0]['data']['text/plain'], '2')After a restart the old client’s channels and routed-reply reader are stale, so reconnect hands back a fresh client bound to the restarted process. State is gone: redo any imports/setup.
The context manager guarantees teardown even when a test body raises, which is what keeps a big protocol test suite from leaking kernel processes.
async with run_kernel() as (km2, kc2):
test_eq((await kc2.exec_outs('1+1'))[0]['data']['text/plain'], '2')
pid = km2.provisioner.pid
assert not await km2.is_alive()Cleanup
if await km.is_alive():
kc.stop_channels()
await km.shutdown_kernel()