Skip to content

Commit c2fa718

Browse files
committed
Add tests for the thread-local parent header API
Covers set_thread_parent/reset_thread_parent on OutStream, ZMQDisplayHook, ZMQShellDisplayHook, and ZMQDisplayPublisher, and set_thread_parent/reset_thread_parent/parent_override on ZMQInteractiveShell: - a thread that never set a parent falls back to the global parent - set_thread_parent affects only the calling thread; tokens undo it in LIFO order - messages (stream output, execute_result, display_data) are sent with the calling thread's parent header - the shell propagates thread parents to its displayhook, display_pub, and sys.stdout/sys.stderr when they support it, and parent_override restores everything on exit, including on exceptions Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UvKd3pX2HBzbEE1y4P53gF
1 parent a613a3e commit c2fa718

3 files changed

Lines changed: 368 additions & 1 deletion

File tree

tests/test_displayhook.py

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from jupyter_client.session import Session
1313
from traitlets import Int
1414

15-
from ipykernel.displayhook import ZMQShellDisplayHook
15+
from ipykernel.displayhook import ZMQDisplayHook, ZMQShellDisplayHook
1616

1717

1818
class NoReturnHook:
@@ -202,6 +202,107 @@ def test_empty_data_skips_send_and_hooks(self):
202202
assert self.session.send_count == 0
203203
assert self.disp.msg is None
204204

205+
def test_thread_parent(self):
206+
"""set_thread_parent affects only the calling thread's messages."""
207+
parent1 = {"header": {"msg_id": "parent-1"}}
208+
parent2 = {"header": {"msg_id": "parent-2"}}
209+
210+
self.disp.set_parent(parent1)
211+
assert self.disp.parent_header == parent1["header"]
212+
213+
results = {}
214+
215+
def worker():
216+
try:
217+
# A thread that never set a parent falls back to the global one.
218+
results["inherited"] = self.disp.parent_header
219+
token = self.disp.set_thread_parent(parent2)
220+
_drive(self.disp)
221+
results["msg_parent"] = self.session.last_msg["parent_header"]
222+
self.disp.reset_thread_parent(token)
223+
results["after"] = self.disp.parent_header
224+
except BaseException as e:
225+
results["error"] = e
226+
227+
t = Thread(target=worker)
228+
t.start()
229+
t.join(timeout=10)
230+
assert not t.is_alive()
231+
assert results.get("error") is None
232+
233+
assert results["inherited"] == parent1["header"]
234+
assert results["msg_parent"] == parent2["header"]
235+
assert results["after"] == parent1["header"]
236+
237+
# The main thread still sends with the global parent.
238+
_drive(self.disp)
239+
assert self.session.last_msg["parent_header"] == parent1["header"]
240+
assert self.disp.parent_header == parent1["header"]
241+
242+
243+
class ParentRecordingSession(CounterSession):
244+
"""Records the parent= keyword argument passed to send()."""
245+
246+
last_parent = None
247+
248+
def send(self, *args, **kwargs):
249+
self.last_parent = kwargs.get("parent")
250+
return super().send(*args, **kwargs)
251+
252+
253+
class ZMQDisplayHookTests(unittest.TestCase):
254+
"""Tests for the simple (non-shell) ZMQDisplayHook."""
255+
256+
def setUp(self):
257+
self.context = zmq.Context()
258+
self.socket = self.context.socket(zmq.PUB)
259+
self.session = ParentRecordingSession()
260+
self.hook = ZMQDisplayHook(self.session, self.socket)
261+
262+
def tearDown(self):
263+
self.socket.close()
264+
self.context.term()
265+
266+
def test_thread_parent(self):
267+
"""set_thread_parent affects only the calling thread's messages."""
268+
parent1 = {"header": {"msg_id": "parent-1"}}
269+
parent2 = {"header": {"msg_id": "parent-2"}}
270+
271+
self.hook.set_parent(parent1)
272+
assert self.hook.parent_header == parent1["header"]
273+
274+
self.hook(12)
275+
assert self.session.last_parent == parent1["header"]
276+
277+
results = {}
278+
279+
def worker():
280+
try:
281+
# A thread that never set a parent falls back to the global one.
282+
results["inherited"] = self.hook.parent_header
283+
token = self.hook.set_thread_parent(parent2)
284+
self.hook(34)
285+
results["msg_parent"] = self.session.last_parent
286+
self.hook.reset_thread_parent(token)
287+
results["after"] = self.hook.parent_header
288+
except BaseException as e:
289+
results["error"] = e
290+
291+
t = Thread(target=worker)
292+
t.start()
293+
t.join(timeout=10)
294+
assert not t.is_alive()
295+
assert results.get("error") is None
296+
297+
assert results["inherited"] == parent1["header"]
298+
assert results["msg_parent"] == parent2["header"]
299+
assert results["after"] == parent1["header"]
300+
301+
# The main thread still sends with the global parent.
302+
self.hook(56)
303+
assert self.session.last_parent == parent1["header"]
304+
assert self.hook.parent_header == parent1["header"]
305+
205306

206307
if __name__ == "__main__":
207308
unittest.main()

tests/test_io.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,121 @@ def test_outstream(iopub_thread):
117117
assert stream.writable()
118118

119119

120+
class RecordingSession(Session):
121+
"""Session subclass that records the messages it sends."""
122+
123+
def __init__(self, *args, **kwargs):
124+
super().__init__(*args, **kwargs)
125+
self.sent_msgs = []
126+
127+
def send(self, stream, msg_or_type, *args, **kwargs):
128+
if isinstance(msg_or_type, dict):
129+
self.sent_msgs.append(msg_or_type)
130+
return super().send(stream, msg_or_type, *args, **kwargs)
131+
132+
133+
def _run_in_thread(target):
134+
"""Run target in a thread, re-raising any exception in the caller."""
135+
error = []
136+
137+
def wrapper():
138+
try:
139+
target()
140+
except BaseException as e:
141+
error.append(e)
142+
143+
t = threading.Thread(target=wrapper)
144+
t.start()
145+
t.join(timeout=10)
146+
assert not t.is_alive()
147+
if error:
148+
raise error[0]
149+
150+
151+
def test_outstream_thread_parent(iopub_thread):
152+
"""set_thread_parent only affects the calling thread and is undone by its token."""
153+
session = Session()
154+
stream = OutStream(session, iopub_thread, "stdout")
155+
parent1 = {"header": {"msg_id": "parent-1"}}
156+
parent2 = {"header": {"msg_id": "parent-2"}}
157+
parent3 = {"header": {"msg_id": "parent-3"}}
158+
159+
stream.set_parent(parent1)
160+
assert stream.parent_header == parent1["header"]
161+
162+
results = {}
163+
164+
def worker():
165+
# A thread that never set a parent falls back to the global one.
166+
results["inherited"] = stream.parent_header
167+
token1 = stream.set_thread_parent(parent2)
168+
results["local"] = stream.parent_header
169+
# Thread parents nest, and tokens undo them in LIFO order.
170+
token2 = stream.set_thread_parent(parent3)
171+
results["nested"] = stream.parent_header
172+
stream.reset_thread_parent(token2)
173+
results["unnested"] = stream.parent_header
174+
stream.reset_thread_parent(token1)
175+
results["reset"] = stream.parent_header
176+
177+
_run_in_thread(worker)
178+
179+
assert results["inherited"] == parent1["header"]
180+
assert results["local"] == parent2["header"]
181+
assert results["nested"] == parent3["header"]
182+
assert results["unnested"] == parent2["header"]
183+
assert results["reset"] == parent1["header"]
184+
# The main thread never saw the thread-local parents.
185+
assert stream.parent_header == parent1["header"]
186+
187+
188+
def test_outstream_set_parent_is_global(iopub_thread):
189+
"""set_parent from any thread updates the fallback for other threads."""
190+
session = Session()
191+
stream = OutStream(session, iopub_thread, "stdout")
192+
parent1 = {"header": {"msg_id": "parent-1"}}
193+
parent2 = {"header": {"msg_id": "parent-2"}}
194+
195+
stream.set_parent(parent1)
196+
_run_in_thread(lambda: stream.set_parent(parent2))
197+
198+
results = {}
199+
200+
def fresh_thread():
201+
results["seen"] = stream.parent_header
202+
203+
_run_in_thread(fresh_thread)
204+
assert results["seen"] == parent2["header"]
205+
206+
207+
def test_outstream_thread_parent_routing(iopub_thread):
208+
"""Output written under a thread parent is sent with that parent header."""
209+
session = RecordingSession(key=b"abc")
210+
stream = OutStream(session, iopub_thread, "stdout")
211+
parent1 = {"header": {"msg_id": "parent-1"}}
212+
parent2 = {"header": {"msg_id": "parent-2"}}
213+
214+
stream.set_parent(parent1)
215+
216+
def worker():
217+
token = stream.set_thread_parent(parent2)
218+
try:
219+
stream.write("from-thread")
220+
finally:
221+
stream.reset_thread_parent(token)
222+
223+
stream.write("from-main")
224+
_run_in_thread(worker)
225+
stream.flush()
226+
227+
routed = {
228+
msg["content"]["text"]: msg["parent_header"]["msg_id"]
229+
for msg in session.sent_msgs
230+
if msg["msg_type"] == "stream"
231+
}
232+
assert routed == {"from-main": "parent-1", "from-thread": "parent-2"}
233+
234+
120235
async def test_event_pipe_gc(iopub_thread):
121236
session = Session(key=b"abc")
122237
stream = OutStream(

0 commit comments

Comments
 (0)