conkernelclient 0.0.16__tar.gz → 0.0.17__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conkernelclient
3
- Version: 0.0.16
3
+ Version: 0.0.17
4
4
  Summary: Concurrent-safe Jupyter KernelClient
5
5
  Author-email: Jeremy Howard <github@jhoward.fastmail.fm>
6
6
  License: Apache-2.0
@@ -13,9 +13,14 @@ Requires-Python: >=3.10
13
13
  Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
15
  Requires-Dist: jupyter_client
16
- Requires-Dist: fastcore
16
+ Requires-Dist: fastcore>=2.1.17
17
+ Requires-Dist: jupywire>=0.0.1
17
18
  Provides-Extra: dev
18
19
  Requires-Dist: ipykernel; extra == "dev"
20
+ Requires-Dist: ipymini>=0.1.17; extra == "dev"
21
+ Requires-Dist: ipyfuncs>=0.0.1; extra == "dev"
22
+ Requires-Dist: pytest; extra == "dev"
23
+ Requires-Dist: pytest-timeout; extra == "dev"
19
24
  Dynamic: license-file
20
25
 
21
26
  # conkernelclient
@@ -1,3 +1,3 @@
1
- __version__ = "0.0.16"
1
+ __version__ = "0.0.17"
2
2
  from .core import *
3
3
  from .ops import *
@@ -14,6 +14,8 @@ d = { 'settings': { 'branch': 'main',
14
14
  'conkernelclient/core.py'),
15
15
  'conkernelclient.core.ConKernelClient._fail_pending': ( 'core.html#conkernelclient._fail_pending',
16
16
  'conkernelclient/core.py'),
17
+ 'conkernelclient.core.ConKernelClient.astop_channels': ( 'core.html#conkernelclient.astop_channels',
18
+ 'conkernelclient/core.py'),
17
19
  'conkernelclient.core.ConKernelClient.execute': ( 'core.html#conkernelclient.execute',
18
20
  'conkernelclient/core.py'),
19
21
  'conkernelclient.core.ConKernelClient.start_channels': ( 'core.html#conkernelclient.start_channels',
@@ -22,6 +24,8 @@ d = { 'settings': { 'branch': 'main',
22
24
  'conkernelclient/core.py'),
23
25
  'conkernelclient.core.ConKernelClient.stop_channels': ( 'core.html#conkernelclient.stop_channels',
24
26
  'conkernelclient/core.py'),
27
+ 'conkernelclient.core.ConKernelClient.wait_for_ready': ( 'core.html#conkernelclient.wait_for_ready',
28
+ 'conkernelclient/core.py'),
25
29
  'conkernelclient.core.ConKernelManager': ('core.html#conkernelmanager', 'conkernelclient/core.py'),
26
30
  'conkernelclient.core.ConKernelManager._transport_encryption_default': ( 'core.html#conkernelmanager._transport_encryption_default',
27
31
  'conkernelclient/core.py'),
@@ -29,7 +33,9 @@ d = { 'settings': { 'branch': 'main',
29
33
  'conkernelclient.core._send': ('core.html#_send', 'conkernelclient/core.py'),
30
34
  'conkernelclient.core.apply_session_patch': ( 'core.html#apply_session_patch',
31
35
  'conkernelclient/core.py')},
32
- 'conkernelclient.ops': { 'conkernelclient.ops.ConKernelClient.cmd': ('ops.html#conkernelclient.cmd', 'conkernelclient/ops.py'),
36
+ 'conkernelclient.ops': { 'conkernelclient.ops.ConKernelClient._pre_ipy': ( 'ops.html#conkernelclient._pre_ipy',
37
+ 'conkernelclient/ops.py'),
38
+ 'conkernelclient.ops.ConKernelClient.cmd': ('ops.html#conkernelclient.cmd', 'conkernelclient/ops.py'),
33
39
  'conkernelclient.ops.ConKernelClient.control_request': ( 'ops.html#conkernelclient.control_request',
34
40
  'conkernelclient/ops.py'),
35
41
  'conkernelclient.ops.ConKernelClient.ctl': ('ops.html#conkernelclient.ctl', 'conkernelclient/ops.py'),
@@ -50,6 +56,8 @@ d = { 'settings': { 'branch': 'main',
50
56
  'conkernelclient/ops.py'),
51
57
  'conkernelclient.ops.ConKernelClient.iopub_flush': ( 'ops.html#conkernelclient.iopub_flush',
52
58
  'conkernelclient/ops.py'),
59
+ 'conkernelclient.ops.ConKernelClient.reply': ( 'ops.html#conkernelclient.reply',
60
+ 'conkernelclient/ops.py'),
53
61
  'conkernelclient.ops.ConKernelClient.shell_request': ( 'ops.html#conkernelclient.shell_request',
54
62
  'conkernelclient/ops.py'),
55
63
  'conkernelclient.ops.ConKernelClient.user_exprs': ( 'ops.html#conkernelclient.user_exprs',
@@ -66,7 +74,6 @@ d = { 'settings': { 'branch': 'main',
66
74
  'conkernelclient.ops.iopub_msgs': ('ops.html#iopub_msgs', 'conkernelclient/ops.py'),
67
75
  'conkernelclient.ops.iopub_streams': ('ops.html#iopub_streams', 'conkernelclient/ops.py'),
68
76
  'conkernelclient.ops.iter_timeout': ('ops.html#iter_timeout', 'conkernelclient/ops.py'),
69
- 'conkernelclient.ops.nb_outputs': ('ops.html#nb_outputs', 'conkernelclient/ops.py'),
70
77
  'conkernelclient.ops.parent_id': ('ops.html#parent_id', 'conkernelclient/ops.py'),
71
78
  'conkernelclient.ops.parse_expr': ('ops.html#parse_expr', 'conkernelclient/ops.py'),
72
79
  'conkernelclient.ops.reconnect': ('ops.html#reconnect', 'conkernelclient/ops.py'),
@@ -12,6 +12,9 @@ from jupyter_client import AsyncKernelClient, AsyncKernelManager
12
12
  from jupyter_client.session import Session
13
13
  from zmq.error import ZMQError
14
14
  from traitlets import Type, default
15
+ from fastcore.utils import patch
16
+ from jupywire.ops import EvalOps
17
+ from queue import Empty
15
18
  import asyncio, zmq.asyncio, time, logging
16
19
 
17
20
  # %% ../nbs/00_core.ipynb #737a0fc1
@@ -45,7 +48,7 @@ def apply_session_patch():
45
48
  Session.send = _send
46
49
 
47
50
  # %% ../nbs/00_core.ipynb #d6a5fa6a
48
- class ConKernelClient(AsyncKernelClient):
51
+ class ConKernelClient(EvalOps, AsyncKernelClient):
49
52
  def __init__(self, *args, **kwargs):
50
53
  apply_session_patch()
51
54
  # jupyter_client's `get_connection_info` returns curve keys as str, but the client traits are `Bytes`
@@ -91,7 +94,6 @@ class ConKernelClient(AsyncKernelClient):
91
94
  self._fail_pending(exc, skip=mid)
92
95
  self._shell_reader_task = asyncio.create_task(_reader())
93
96
  await _ready.wait()
94
- await asyncio.sleep(0.2)
95
97
  return self
96
98
 
97
99
  def stop_channels(self):
@@ -101,7 +103,6 @@ class ConKernelClient(AsyncKernelClient):
101
103
  if (tk := getattr(self, '_shell_reader_task', None)):
102
104
  tk.cancel()
103
105
  self._shell_reader_task = None
104
- time.sleep(0.2)
105
106
 
106
107
  async def _async_recv_reply(self, msg_id, timeout=None, channel="shell"):
107
108
  if channel == "control": return await self._async_get_control_msg(timeout=timeout)
@@ -136,6 +137,52 @@ class ConKernelClient(AsyncKernelClient):
136
137
  except (AssertionError, OSError, ZMQError) as e: raise DeadKernelError(f"Kernel socket closed: {e}") from e
137
138
  await asyncio.sleep(0.01)
138
139
 
140
+ # %% ../nbs/00_core.ipynb #3771c654
141
+ @patch
142
+ async def wait_for_ready(self:ConKernelClient, timeout=None):
143
+ "Wait for the kernel to be ready: deterministic via `iopub_welcome` (JEP 65) when the kernel sends one, else jupyter_client's probe loop"
144
+ deadline = time.monotonic()+timeout if timeout is not None else None
145
+ def _left():
146
+ if deadline is None: return 1.0
147
+ t = deadline-time.monotonic()
148
+ if t <= 0: raise RuntimeError(f"Kernel didn't respond in {timeout} seconds")
149
+ return min(t, 1.0)
150
+ async def _get(ch):
151
+ while True:
152
+ try: return await ch.get_msg(timeout=_left())
153
+ except Empty:
154
+ if not await self._async_is_alive(): raise RuntimeError("Kernel died before replying to kernel_info")
155
+ msg = None
156
+ while msg is None:
157
+ self.kernel_info()
158
+ try: msg = await self.iopub_channel.get_msg(timeout=_left())
159
+ except Empty:
160
+ if not await self._async_is_alive(): raise RuntimeError("Kernel died before replying to kernel_info")
161
+ if msg['msg_type']=='iopub_welcome':
162
+ mid = self.kernel_info()
163
+ while (reply := await _get(self.shell_channel))['parent_header'].get('msg_id') != mid: pass
164
+ self._handle_kernel_info_reply(reply)
165
+ while not (msg['msg_type']=='status' and msg['parent_header'].get('msg_id')==mid and msg['content']['execution_state']=='idle'):
166
+ msg = await _get(self.iopub_channel)
167
+ else:
168
+ while (reply := await _get(self.shell_channel))['msg_type'] != 'kernel_info_reply': pass
169
+ self._handle_kernel_info_reply(reply)
170
+ while True:
171
+ try: await self.iopub_channel.get_msg(timeout=0.2)
172
+ except Empty: break
173
+
174
+ # %% ../nbs/00_core.ipynb #967c10a1
175
+ @patch
176
+ async def astop_channels(self:ConKernelClient):
177
+ "Stop channels and cancel the background shell-reply reader, awaiting its exit"
178
+ self._fail_pending(RuntimeError("Shell channels stopped before reply"))
179
+ if (tk := getattr(self, '_shell_reader_task', None)):
180
+ tk.cancel()
181
+ try: await tk
182
+ except asyncio.CancelledError: pass
183
+ self._shell_reader_task = None
184
+ super(ConKernelClient, self).stop_channels()
185
+
139
186
  # %% ../nbs/00_core.ipynb #b828c222
140
187
  class ConKernelManager(AsyncKernelManager):
141
188
  client_class,client_factory = ConKernelClient,Type(ConKernelClient)
@@ -5,12 +5,14 @@ Docs: https://AnswerDotAI.github.io/conkernelclient/ops.html.md"""
5
5
  # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/01_ops.ipynb.
6
6
 
7
7
  # %% auto #0
8
- __all__ = ['default_timeout', 'output_types', 'parent_id', 'iter_timeout', 'nb_outputs', 'iopub_msgs', 'iopub_streams',
9
- 'EvalError', 'parse_expr', 'run_kernel', 'reconnect']
8
+ __all__ = ['default_timeout', 'output_types', 'parent_id', 'iter_timeout', 'iopub_msgs', 'iopub_streams', 'EvalError',
9
+ 'parse_expr', 'run_kernel', 'reconnect']
10
10
 
11
11
  # %% ../nbs/01_ops.ipynb #23936f1c
12
12
  from fastcore.utils import *
13
- from .core import ConKernelClient, ConKernelManager
13
+ from fastcore.ansi import strip_ansi
14
+ from fastcore.nbio import msgs2outs, preferred_out
15
+ from .core import ConKernelClient, ConKernelManager, DeadKernelError
14
16
  from contextlib import asynccontextmanager
15
17
  from ast import literal_eval
16
18
  from queue import Empty
@@ -52,15 +54,6 @@ async def iopub_flush(self:ConKernelClient, timeout=0.1):
52
54
  # %% ../nbs/01_ops.ipynb #add288fe
53
55
  output_types = {'stream', 'execute_result', 'display_data', 'error'}
54
56
 
55
- def nb_outputs(msgs):
56
- "Convert iopub `msgs` to nbformat-style output dicts, dropping non-output messages"
57
- res = []
58
- for m in msgs:
59
- if (mt := m['msg_type']) not in output_types: continue
60
- d = dict(output_type=mt, **m['content'])
61
- d.pop('transient', None)
62
- res.append(d)
63
- return res
64
57
 
65
58
  # %% ../nbs/01_ops.ipynb #ebdbd6eb
66
59
  def iopub_msgs(msgs, msg_type=None):
@@ -92,7 +85,7 @@ async def exec_ok(self:ConKernelClient, code, timeout=default_timeout, **kw):
92
85
  async def exec_outs(self:ConKernelClient, code, timeout=default_timeout, **kw):
93
86
  "Execute `code` and return just its nbformat-style outputs"
94
87
  reply, outputs = await self.exec_drain(code, timeout=timeout, **kw)
95
- return nb_outputs(outputs)
88
+ return msgs2outs(outputs)
96
89
 
97
90
  # %% ../nbs/01_ops.ipynb #77ab9da4
98
91
  class EvalError(Exception):
@@ -117,6 +110,26 @@ async def eval_expr(self:ConKernelClient, expr:str, code:str='', timeout=default
117
110
  if r.get('status') != 'ok': raise EvalError(f"{r.get('ename')}: {r.get('evalue')}")
118
111
  return parse_expr(r['data']['text/plain'])
119
112
 
113
+ # %% ../nbs/01_ops.ipynb #d9bbaa8d
114
+ @patch
115
+ def reply(self:ConKernelClient,
116
+ code, # A string of code in the kernel's language.
117
+ user_expressions:NoneType=None, # A dict mapping names to expressions to be evaluated in the user's dict
118
+ allow_stdin:NoneType=None, # Flag for whether the kernel can send stdin requests to frontends.
119
+ cts_typ:str='code', timeout:int=None, msg_id:NoneType=None, priority:bool=False, **kw
120
+ ):
121
+ if priority: assert self.priority, 'no priority subshell configured'
122
+ return self.execute(code, user_expressions=user_expressions, allow_stdin=allow_stdin, reply=True,
123
+ cts_typ=cts_typ, timeout=timeout or getattr(self, 'default_timeout', default_timeout), msg_id=msg_id, subsh_id=self.priority if priority else None, **kw)
124
+
125
+ # %% ../nbs/01_ops.ipynb #5de02acb
126
+ from jupywire.ops import EvalOps, EvalException, try_eval
127
+
128
+ @patch
129
+ def _pre_ipy(self:ConKernelClient):
130
+ if not self.shell_channel.is_alive(): raise DeadKernelError()
131
+
132
+
120
133
  # %% ../nbs/01_ops.ipynb #aafc1ab4
121
134
  @patch
122
135
  def shell_request(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conkernelclient
3
- Version: 0.0.16
3
+ Version: 0.0.17
4
4
  Summary: Concurrent-safe Jupyter KernelClient
5
5
  Author-email: Jeremy Howard <github@jhoward.fastmail.fm>
6
6
  License: Apache-2.0
@@ -13,9 +13,14 @@ Requires-Python: >=3.10
13
13
  Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
15
  Requires-Dist: jupyter_client
16
- Requires-Dist: fastcore
16
+ Requires-Dist: fastcore>=2.1.17
17
+ Requires-Dist: jupywire>=0.0.1
17
18
  Provides-Extra: dev
18
19
  Requires-Dist: ipykernel; extra == "dev"
20
+ Requires-Dist: ipymini>=0.1.17; extra == "dev"
21
+ Requires-Dist: ipyfuncs>=0.0.1; extra == "dev"
22
+ Requires-Dist: pytest; extra == "dev"
23
+ Requires-Dist: pytest-timeout; extra == "dev"
19
24
  Dynamic: license-file
20
25
 
21
26
  # conkernelclient
@@ -11,4 +11,5 @@ conkernelclient.egg-info/SOURCES.txt
11
11
  conkernelclient.egg-info/dependency_links.txt
12
12
  conkernelclient.egg-info/entry_points.txt
13
13
  conkernelclient.egg-info/requires.txt
14
- conkernelclient.egg-info/top_level.txt
14
+ conkernelclient.egg-info/top_level.txt
15
+ tests/test_races.py
@@ -0,0 +1,10 @@
1
+ jupyter_client
2
+ fastcore>=2.1.17
3
+ jupywire>=0.0.1
4
+
5
+ [dev]
6
+ ipykernel
7
+ ipymini>=0.1.17
8
+ ipyfuncs>=0.0.1
9
+ pytest
10
+ pytest-timeout
@@ -17,12 +17,17 @@ classifiers = [
17
17
  ]
18
18
  dependencies = [
19
19
  'jupyter_client',
20
- 'fastcore',
20
+ 'fastcore>=2.1.17',
21
+ 'jupywire>=0.0.1',
21
22
  ]
22
23
 
23
24
  [project.optional-dependencies]
24
25
  dev = [
25
26
  "ipykernel",
27
+ "ipymini>=0.1.17",
28
+ "ipyfuncs>=0.0.1",
29
+ "pytest",
30
+ "pytest-timeout",
26
31
  ]
27
32
 
28
33
  [project.urls]
@@ -44,3 +49,6 @@ allowed_cell_metadata_keys = ['solveit_ai']
44
49
 
45
50
  [tool.chkstyle]
46
51
  skip_paths = ["_modidx.py"]
52
+
53
+ [tool.pytest.ini_options]
54
+ timeout = 120
@@ -0,0 +1,76 @@
1
+ """Race-condition regression tests: adversarial timing on the shell send path.
2
+
3
+ `LockedSendChannel` serializes shell sends under a lock with a random 10-15ms sleep,
4
+ widening the window between the sync zmq send path and the async receive side. The burst
5
+ test then keeps many replies pending at once, so the background reader races every send.
6
+ The parked-recv test is the minimal repro of the edge-triggered FD bug the Session patch
7
+ fixes: a recv parked in poll(None) must still wake after a sync send consumes the FD edge.
8
+ """
9
+ import asyncio, functools, random, threading, time
10
+ from queue import Empty
11
+
12
+ from jupyter_client import AsyncKernelManager
13
+ from jupyter_client.channels import AsyncZMQSocketChannel
14
+ from traitlets import Type
15
+
16
+ from conkernelclient import ConKernelClient, ConKernelManager
17
+ from conkernelclient.core import apply_session_patch
18
+ from conkernelclient.ops import run_kernel
19
+
20
+
21
+ def locked_sleep(f):
22
+ @functools.wraps(f)
23
+ def _f(self, *args, **kwargs):
24
+ with self.lock:
25
+ time.sleep(random.uniform(0.01, 0.015))
26
+ return f(self, *args, **kwargs)
27
+ return _f
28
+
29
+ class LockedSendChannel(AsyncZMQSocketChannel):
30
+ lock = threading.RLock()
31
+ @locked_sleep
32
+ def send(self, msg): return super().send(msg)
33
+
34
+ class LockedClient(ConKernelClient):
35
+ shell_channel_class = LockedSendChannel
36
+
37
+ class LockedKernelManager(ConKernelManager):
38
+ client_class,client_factory = LockedClient,Type(LockedClient)
39
+
40
+
41
+ def test_burst_replies_under_send_jitter():
42
+ "20 pending replies at once, every send jittered: the reader must route each reply to its waiter."
43
+ async def _run():
44
+ async with run_kernel(kernel_name="ipymini", manager_cls=LockedKernelManager) as (km, kc):
45
+ reps = [kc.execute(f"x{i} = {i}; x{i}", reply=True, timeout=30) for i in range(20)]
46
+ for i, r in enumerate(await asyncio.gather(*reps)): assert r["content"]["status"] == "ok", (i, r["content"])
47
+ asyncio.run(_run())
48
+
49
+
50
+ def test_parked_recv_survives_sync_send():
51
+ "A recv parked in poll(None) must wake with the right reply after sync sends while the loop was busy."
52
+ async def _run():
53
+ apply_session_patch()
54
+ km = AsyncKernelManager()
55
+ await km.start_kernel()
56
+ kc = km.client()
57
+ kc.start_channels()
58
+ await kc.wait_for_ready()
59
+ try:
60
+ # Drain replies to any extra kernel_info requests from a slow start,
61
+ # so the parked recv below can only receive the execute reply.
62
+ try:
63
+ while True: await kc.get_shell_msg(timeout=0.3)
64
+ except Empty: pass
65
+
66
+ fut = asyncio.create_task(kc.get_shell_msg(timeout=None))
67
+ await asyncio.sleep(0.1)
68
+ mid = kc.execute("1+1") # loop busy below, so the reply buffers and sets the FD edge
69
+ time.sleep(0.4)
70
+ kc.execute("2+2") # sync send consumes the edge via process_commands
71
+ reply = await asyncio.wait_for(fut, 3) # TimeoutError here is the regression
72
+ assert reply["parent_header"]["msg_id"] == mid
73
+ finally:
74
+ kc.stop_channels()
75
+ await km.shutdown_kernel(now=True)
76
+ asyncio.run(_run())
@@ -1,5 +0,0 @@
1
- jupyter_client
2
- fastcore
3
-
4
- [dev]
5
- ipykernel