Skip to content

Commit dfb3467

Browse files
authored
Enable setting and resetting a thread-local parent header (#1546)
As mentioned in #1289 (comment) and #1451, there should be a public api for setting and clearing a thread-local parent header. This PR creates a recommended public api for setting/resetting a single thread's parent without setting the global fallback header. An ipywidget OutputWidget can use this to temporarily redirect output from a single thread, for example. Changelog entry: > Output from threads can be explicitly routed using the new `get_ipython().set_thread_parent()`, which also returns a token that can be used to undo the set with `get_ipython().reset_thread_parent(token)`. Unlike `set_parent()`, using `set_thread_parent()` does not affect the default shell parent, so does not affect output routing in other threads. Since the thread parent is stored in a thread ContextVar, techniques for propagating a thread's ContextVars will also propagate the thread parent without having to explicitly call `set_thread_parent()`. I also added a number of `x.parent = ...` parent setters to be more consistent - I'm not directly going to use those, but happy to remove those if there is an objection. Claude Fable 5 assisted in this PR
2 parents c742270 + cc87037 commit dfb3467

4 files changed

Lines changed: 103 additions & 33 deletions

File tree

ipykernel/displayhook.py

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,22 @@ def parent_header(self):
6464
except LookupError:
6565
return self._parent_header_global
6666

67+
@parent_header.setter
68+
def parent_header(self, value):
69+
self._parent_header.set(value)
70+
self._parent_header_global = value
71+
72+
def set_thread_parent(self, parent):
73+
"""Set the parent header for the calling thread only. Returns a reset token that can be used with reset_thread_parent."""
74+
return self._parent_header.set(extract_header(parent))
75+
76+
def reset_thread_parent(self, token):
77+
"""Reset the parent header to undo the set_thread_parent call that returned the token."""
78+
self._parent_header.reset(token)
79+
6780
def set_parent(self, parent):
68-
"""Set the parent header."""
69-
parent_header = extract_header(parent)
70-
self._parent_header.set(parent_header)
71-
self._parent_header_global = parent_header
81+
"""Set the global and thread parent header."""
82+
self.parent_header = extract_header(parent)
7283

7384

7485
class ZMQShellDisplayHook(DisplayHook):
@@ -88,6 +99,7 @@ def __init__(self, *args, **kwargs):
8899
super().__init__(*args, **kwargs)
89100
self._parent_header = ContextVar("parent_header")
90101
self._parent_header.set({})
102+
self._parent_header_global = {}
91103

92104
@default("_thread_local")
93105
def _default_thread_local(self):
@@ -123,11 +135,22 @@ def parent_header(self):
123135
except LookupError:
124136
return self._parent_header_global
125137

138+
@parent_header.setter
139+
def parent_header(self, value):
140+
self._parent_header.set(value)
141+
self._parent_header_global = value
142+
143+
def set_thread_parent(self, parent):
144+
"""Set the parent header for the calling thread only. Returns a reset token that can be used with reset_thread_parent."""
145+
return self._parent_header.set(extract_header(parent))
146+
147+
def reset_thread_parent(self, token):
148+
"""Reset the parent header to undo the set_thread_parent call that returned the token."""
149+
self._parent_header.reset(token)
150+
126151
def set_parent(self, parent):
127-
"""Set the parent header."""
128-
parent_header = extract_header(parent)
129-
self._parent_header.set(parent_header)
130-
self._parent_header_global = parent_header
152+
"""Set the global and thread parent header."""
153+
self.parent_header = extract_header(parent)
131154

132155
def start_displayhook(self):
133156
"""Start the display hook."""

ipykernel/iostream.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -605,8 +605,8 @@ def parent_header(self):
605605

606606
@parent_header.setter
607607
def parent_header(self, value):
608+
self._parent_header.set(value)
608609
self._parent_header_global = value
609-
return self._parent_header.set(value)
610610

611611
def isatty(self):
612612
"""Return a bool indicating whether this is an 'interactive' stream.
@@ -632,8 +632,16 @@ def _setup_stream_redirects(self, name):
632632
def _is_master_process(self):
633633
return os.getpid() == self._master_pid
634634

635+
def set_thread_parent(self, parent):
636+
"""Set the parent header for the calling thread only. Returns a reset token that can be used with reset_thread_parent."""
637+
return self._parent_header.set(extract_header(parent))
638+
639+
def reset_thread_parent(self, token):
640+
"""Reset the parent header to undo the set_thread_parent call that returned the token."""
641+
self._parent_header.reset(token)
642+
635643
def set_parent(self, parent):
636-
"""Set the parent header."""
644+
"""Set the global and thread parent header."""
637645
self.parent_header = extract_header(parent)
638646

639647
def close(self):

ipykernel/zmqshell.py

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,22 @@ def parent_header(self):
8383
except LookupError:
8484
return self._parent_header_global
8585

86+
@parent_header.setter
87+
def parent_header(self, value):
88+
self._parent_header.set(value)
89+
self._parent_header_global = value
90+
91+
def set_thread_parent(self, parent):
92+
"""Set the parent header for the calling thread only. Returns a reset token that can be used with reset_thread_parent."""
93+
return self._parent_header.set(extract_header(parent))
94+
95+
def reset_thread_parent(self, token):
96+
"""Reset the parent header to undo the set_thread_parent call that returned the token."""
97+
self._parent_header.reset(token)
98+
8699
def set_parent(self, parent):
87-
"""Set the parent for outbound messages."""
88-
parent_header = extract_header(parent)
89-
self._parent_header.set(parent_header)
90-
self._parent_header_global = parent_header
100+
"""Set the global and thread parent header."""
101+
self.parent_header = extract_header(parent)
91102

92103
def _flush_streams(self):
93104
"""flush IO Streams prior to display"""
@@ -535,14 +546,16 @@ def __init__(self, *args, **kwargs):
535546
if "IPKernelApp" not in self.config:
536547
self.config.IPKernelApp.tqdm = "dummy value for https://github.com/tqdm/tqdm/pull/1628"
537548

538-
self._parent_header = contextvars.ContextVar("parent_header")
549+
self._parent_header: contextvars.ContextVar[dict[str, typing.Any]] = contextvars.ContextVar(
550+
"parent_header"
551+
)
539552
self._parent_header.set({})
553+
self._parent_header_global = {}
540554

541555
displayhook_class = Type(ZMQShellDisplayHook)
542556
display_pub_class = Type(ZMQDisplayPublisher)
543557
data_pub_class = Any()
544558
kernel = Any()
545-
_parent_header: contextvars.ContextVar[dict[str, Any]]
546559

547560
@default("banner1")
548561
def _default_banner1(self):
@@ -725,15 +738,11 @@ def parent_header(self):
725738

726739
@parent_header.setter
727740
def parent_header(self, value):
728-
self._parent_header_global = value
729741
self._parent_header.set(value)
742+
self._parent_header_global = value
730743

731744
def set_parent(self, parent):
732-
"""Set the parent header for associating output with its triggering input
733-
734-
When called from a thread, sets the thread-local value, which persists
735-
until the next call from this thread.
736-
"""
745+
"""Set the global and thread parent header for associating output with its triggering input."""
737746
self.parent_header = parent
738747
self.displayhook.set_parent(parent) # type:ignore[attr-defined]
739748
self.display_pub.set_parent(parent) # type:ignore[attr-defined]
@@ -753,6 +762,24 @@ def get_parent(self):
753762
"""
754763
return self.parent_header
755764

765+
def set_thread_parent(self, parent):
766+
"""Set the parent header for only the current thread associating output with its triggering input"""
767+
tokens = [(self._parent_header.reset, self._parent_header.set(parent))]
768+
objs = [self.displayhook, self.display_pub, sys.stdout, sys.stderr]
769+
if hasattr(self, "_data_pub"):
770+
objs.append(self.data_pub)
771+
for obj in objs:
772+
set_thread = getattr(obj, "set_thread_parent", None)
773+
reset_thread = getattr(obj, "reset_thread_parent", None)
774+
if set_thread is not None and reset_thread is not None:
775+
tokens.append((reset_thread, set_thread(parent)))
776+
return tuple(tokens)
777+
778+
def reset_thread_parent(self, tokens):
779+
"""Reset the parent header to undo the set_thread_parent call that returned the token."""
780+
for reset, token in reversed(tokens):
781+
reset(token)
782+
756783
def init_magics(self):
757784
"""Initialize magics."""
758785
super().init_magics()

tests/test_kernel.py

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -121,28 +121,31 @@ def collect_outputs(get_iopub_msg, parent_msg_id, timeout=5):
121121
print(msg["msg_type"])
122122

123123

124-
@pytest.mark.parametrize("explicit_parent", [True, False])
125-
def test_print_to_correct_cell_from_thread(explicit_parent: bool):
124+
@pytest.mark.parametrize("explicit_parent", ["global", "thread-local", "none"])
125+
def test_print_to_correct_cell_from_thread(explicit_parent: str):
126126
"""should print to the current cell unless
127127
128-
get_ipython().set_parent sets the thread-local value,
129-
which supersedes the default.
128+
get_ipython().set_parent sets the thread-local parent and the global parent,
129+
which supersedes the default parent set by the current shell execution.
130130
131+
get_ipython().set_thread_parent sets the thread-local parent for only the thread.
131132
"""
132133
code = f"""\
133134
from threading import Event, Thread
134135
from time import sleep
135136
from IPython.display import display
136137
137-
explicit_parent = {explicit_parent}
138+
explicit_parent = "{explicit_parent}"
138139
parent = get_ipython().get_parent()
139140
140141
cell_start_event = Event()
141142
cell_end_event = Event()
142143
143144
def thread_target():
144-
if explicit_parent:
145+
if explicit_parent == "global":
145146
get_ipython().set_parent(parent)
147+
elif explicit_parent == "thread-local":
148+
reset_parent_token = get_ipython().set_thread_parent(parent)
146149
147150
print("before", flush=True)
148151
display(1)
@@ -151,6 +154,8 @@ def thread_target():
151154
152155
print("during", flush=True)
153156
display(2)
157+
if explicit_parent == "thread-local":
158+
get_ipython().reset_thread_parent(reset_parent_token)
154159
cell_end_event.set()
155160
cell_start_event.wait(timeout=10)
156161
cell_start_event.clear()
@@ -190,13 +195,20 @@ def add_output(msg):
190195
last_cell_msg_id = kc.execute("cell_start_event.set()\nthread.join()")
191196
for msg in collect_outputs(kc.get_iopub_msg, last_cell_msg_id):
192197
add_output(msg)
193-
print(outputs)
194-
if explicit_parent:
195-
# assert next_cell_msg_id not in outputs
196-
# assert last_cell_msg_id not in outputs
198+
if explicit_parent == "global":
199+
assert next_cell_msg_id not in outputs
200+
assert last_cell_msg_id not in outputs
197201
thread_cell_output = outputs[thread_msg_id]
198202
assert thread_cell_output["stdout"] == "before\nduring\nafter\n"
199203
assert thread_cell_output["display_data"] == ["1", "2", "3"]
204+
elif explicit_parent == "thread-local":
205+
assert next_cell_msg_id not in outputs
206+
thread_cell_output = outputs[thread_msg_id]
207+
assert thread_cell_output["stdout"] == "before\nduring\n"
208+
assert thread_cell_output["display_data"] == ["1", "2"]
209+
last_cell_output = outputs[last_cell_msg_id]
210+
assert last_cell_output["stdout"] == "after\n"
211+
assert last_cell_output["display_data"] == ["3"]
200212
else:
201213
thread_cell_output = outputs[thread_msg_id]
202214
assert thread_cell_output["stdout"] == "before\n"
@@ -220,7 +232,7 @@ def test_print_to_correct_cell_from_child_thread():
220232
parent = get_ipython().get_parent()
221233
222234
def child_target():
223-
get_ipython().set_parent(parent)
235+
get_ipython().set_thread_parent(parent)
224236
for i in range({iterations}):
225237
print(i, end='', flush=True)
226238
sleep({interval})

0 commit comments

Comments
 (0)