Skip to content

Commit 91b935f

Browse files
authored
Merge pull request #630 from Carreau/cap-fd
Try to capture all filedescriptor output and err
2 parents 4be0283 + 8a160a5 commit 91b935f

File tree

5 files changed

+159
-21
lines changed

5 files changed

+159
-21
lines changed

ipykernel/inprocess/tests/test_kernel.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,24 @@ def test_stdout(self):
9999
out, err = assemble_output(kc.get_iopub_msg)
100100
assert out == 'bar\n'
101101

102+
@pytest.mark.skip(
103+
reason="Currently don't capture during test as pytest does its own capturing"
104+
)
105+
def test_capfd(self):
106+
"""Does correctly capture fd"""
107+
kernel = InProcessKernel()
108+
109+
with capture_output() as io:
110+
kernel.shell.run_cell('print("foo")')
111+
assert io.stdout == "foo\n"
112+
113+
kc = BlockingInProcessKernelClient(kernel=kernel, session=kernel.session)
114+
kernel.frontends.append(kc)
115+
kc.execute("import os")
116+
kc.execute('os.system("echo capfd")')
117+
out, err = assemble_output(kc.iopub_channel)
118+
assert out == "capfd\n"
119+
102120
def test_getpass_stream(self):
103121
"Tests that kernel getpass accept the stream parameter"
104122
kernel = InProcessKernel()

ipykernel/iostream.py

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
import threading
1313
import warnings
1414
from weakref import WeakSet
15+
import traceback
1516
from io import StringIO, TextIOBase
17+
import io
1618

1719
import zmq
1820
if zmq.pyzmq_version_info() >= (17, 0):
@@ -36,6 +38,7 @@
3638
# IO classes
3739
#-----------------------------------------------------------------------------
3840

41+
3942
class IOPubThread(object):
4043
"""An object for sending IOPub messages in a background thread
4144
@@ -284,7 +287,54 @@ class OutStream(TextIOBase):
284287
topic = None
285288
encoding = 'UTF-8'
286289

287-
def __init__(self, session, pub_thread, name, pipe=None, echo=None):
290+
291+
def fileno(self):
292+
"""
293+
Things like subprocess will peak and write to the fileno() of stderr/stdout.
294+
"""
295+
if getattr(self, "_original_stdstream_copy", None) is not None:
296+
return self._original_stdstream_copy
297+
else:
298+
raise io.UnsupportedOperation("fileno")
299+
300+
def _watch_pipe_fd(self):
301+
"""
302+
We've redirected standards steams 0 and 1 into a pipe.
303+
304+
We need to watch in a thread and redirect them to the right places.
305+
306+
1) the ZMQ channels to show in notebook interfaces,
307+
2) the original stdout/err, to capture errors in terminals.
308+
309+
We cannot schedule this on the ioloop thread, as this might be blocking.
310+
311+
"""
312+
313+
try:
314+
bts = os.read(self._fid, 1000)
315+
while bts and self._should_watch:
316+
self.write(bts.decode())
317+
os.write(self._original_stdstream_copy, bts)
318+
bts = os.read(self._fid, 1000)
319+
except Exception:
320+
self._exc = sys.exc_info()
321+
322+
def __init__(
323+
self, session, pub_thread, name, pipe=None, echo=None, *, watchfd=True
324+
):
325+
"""
326+
Parameters
327+
----------
328+
name : str {'stderr', 'stdout'}
329+
the name of the standard stream to replace
330+
watchfd : bool (default, True)
331+
Watch the file descripttor corresponding to the replaced stream.
332+
This is useful if you know some underlying code will write directly
333+
the file descriptor by its number. It will spawn a watching thread,
334+
that will swap the give file descriptor for a pipe, read from the
335+
pipe, and insert this into the current Stream.
336+
337+
"""
288338
if pipe is not None:
289339
warnings.warn(
290340
"pipe argument to OutStream is deprecated and ignored",
@@ -296,8 +346,12 @@ def __init__(self, session, pub_thread, name, pipe=None, echo=None):
296346
self.session = session
297347
if not isinstance(pub_thread, IOPubThread):
298348
# Backward-compat: given socket, not thread. Wrap in a thread.
299-
warnings.warn("OutStream should be created with IOPubThread, not %r" % pub_thread,
300-
DeprecationWarning, stacklevel=2)
349+
warnings.warn(
350+
"Since IPykernel 4.3, OutStream should be created with "
351+
"IOPubThread, not %r" % pub_thread,
352+
DeprecationWarning,
353+
stacklevel=2,
354+
)
301355
pub_thread = IOPubThread(pub_thread)
302356
pub_thread.start()
303357
self.pub_thread = pub_thread
@@ -311,19 +365,48 @@ def __init__(self, session, pub_thread, name, pipe=None, echo=None):
311365
self._new_buffer()
312366
self.echo = None
313367

368+
if (
369+
watchfd
370+
and (sys.platform.startswith("linux") or sys.platform.startswith("darwin"))
371+
and ("PYTEST_CURRENT_TEST" not in os.environ)
372+
):
373+
# Pytest set its own capture. Dont redirect from within pytest.
374+
375+
self._should_watch = True
376+
self._setup_stream_redirects(name)
377+
314378
if echo:
315379
if hasattr(echo, 'read') and hasattr(echo, 'write'):
316380
self.echo = echo
317381
else:
318382
raise ValueError("echo argument must be a file like object")
319383

384+
def _setup_stream_redirects(self, name):
385+
pr, pw = os.pipe()
386+
fno = getattr(sys, name).fileno()
387+
self._original_stdstream_copy = os.dup(fno)
388+
os.dup2(pw, fno)
389+
390+
self._fid = pr
391+
392+
self._exc = None
393+
self.watch_fd_thread = threading.Thread(target=self._watch_pipe_fd)
394+
self.watch_fd_thread.daemon = True
395+
self.watch_fd_thread.start()
396+
320397
def _is_master_process(self):
321398
return os.getpid() == self._master_pid
322399

323400
def set_parent(self, parent):
324401
self.parent_header = extract_header(parent)
325402

326403
def close(self):
404+
if sys.platform.startswith("linux") or sys.platform.startswith("darwin"):
405+
self._should_watch = False
406+
self.watch_fd_thread.join()
407+
if self._exc:
408+
etype, value, tb = self._exc
409+
traceback.print_exception(etype, value, tb)
327410
self.pub_thread = None
328411

329412
@property

ipykernel/kernelapp.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@
1010
import signal
1111
import traceback
1212
import logging
13+
from io import TextIOWrapper, FileIO
14+
from logging import StreamHandler
1315

1416
import tornado
1517
from tornado import ioloop
1618

1719
import zmq
18-
from zmq.eventloop import ioloop as zmq_ioloop
1920
from zmq.eventloop.zmqstream import ZMQStream
2021

2122
from IPython.core.application import (
@@ -414,9 +415,22 @@ def init_io(self):
414415
echo=e_stdout)
415416
if sys.stderr is not None:
416417
sys.stderr.flush()
417-
sys.stderr = outstream_factory(self.session, self.iopub_thread,
418-
'stderr',
419-
echo=e_stderr)
418+
sys.stderr = outstream_factory(
419+
self.session, self.iopub_thread, "stderr", echo=e_stderr
420+
)
421+
if hasattr(sys.stderr, "_original_stdstream_copy"):
422+
423+
for handler in self.log.handlers:
424+
if isinstance(handler, StreamHandler) and (
425+
handler.stream.buffer.fileno() == 2
426+
):
427+
self.log.debug(
428+
"Seeing logger to stderr, rerouting to raw filedescriptor."
429+
)
430+
431+
handler.stream = TextIOWrapper(
432+
FileIO(sys.stderr._original_stdstream_copy, "w")
433+
)
420434
if self.displayhook_class:
421435
displayhook_factory = import_item(str(self.displayhook_class))
422436
self.displayhook = displayhook_factory(self.session, self.iopub_socket)

ipykernel/kernelbase.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,6 @@ def should_handle(self, stream, msg, idents):
257257
"""
258258
msg_id = msg['header']['msg_id']
259259
if msg_id in self.aborted:
260-
msg_type = msg['header']['msg_type']
261260
# is it safe to assume a msg_id will not be resubmitted?
262261
self.aborted.remove(msg_id)
263262
self._send_abort_reply(stream, msg, idents)
@@ -613,8 +612,7 @@ def complete_request(self, stream, ident, parent):
613612

614613
matches = yield gen.maybe_future(self.do_complete(code, cursor_pos))
615614
matches = json_clean(matches)
616-
completion_msg = self.session.send(stream, 'complete_reply',
617-
matches, parent, ident)
615+
self.session.send(stream, "complete_reply", matches, parent, ident)
618616

619617
def do_complete(self, code, cursor_pos):
620618
"""Override in subclasses to find completions.
@@ -854,9 +852,9 @@ def _send_abort_reply(self, stream, msg, idents):
854852
"""Send a reply to an aborted request"""
855853
self.log.info("Aborting:")
856854
self.log.info("%s", msg)
857-
reply_type = msg['header']['msg_type'].rsplit('_', 1)[0] + '_reply'
858-
status = {'status': 'aborted'}
859-
md = {'engine': self.ident}
855+
reply_type = msg["header"]["msg_type"].rsplit("_", 1)[0] + "_reply"
856+
status = {"status": "aborted"}
857+
md = {"engine": self.ident}
860858
md.update(status)
861859
self.session.send(
862860
stream, reply_type, metadata=md,

ipykernel/tests/test_kernel.py

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
def _check_master(kc, expected=True, stream="stdout"):
2929
execute(kc=kc, code="import sys")
3030
flush_channels(kc)
31-
msg_id, content = execute(kc=kc, code="print (sys.%s._is_master_process())" % stream)
31+
msg_id, content = execute(kc=kc, code="print(sys.%s._is_master_process())" % stream)
3232
stdout, stderr = assemble_output(kc.get_iopub_msg)
3333
assert stdout.strip() == repr(expected)
3434

@@ -44,14 +44,44 @@ def _check_status(content):
4444
def test_simple_print():
4545
"""simple print statement in kernel"""
4646
with kernel() as kc:
47-
iopub = kc.iopub_channel
48-
msg_id, content = execute(kc=kc, code="print ('hi')")
47+
msg_id, content = execute(kc=kc, code="print('hi')")
4948
stdout, stderr = assemble_output(kc.get_iopub_msg)
5049
assert stdout == 'hi\n'
5150
assert stderr == ''
5251
_check_master(kc, expected=True)
5352

5453

54+
@pytest.mark.skip(
55+
reason="Currently don't capture during test as pytest does its own capturing"
56+
)
57+
def test_capture_fd():
58+
"""simple print statement in kernel"""
59+
with kernel() as kc:
60+
iopub = kc.iopub_channel
61+
msg_id, content = execute(kc=kc, code="import os; os.system('echo capsys')")
62+
stdout, stderr = assemble_output(iopub)
63+
assert stdout == "capsys\n"
64+
assert stderr == ""
65+
_check_master(kc, expected=True)
66+
67+
68+
@pytest.mark.skip(
69+
reason="Currently don't capture during test as pytest does its own capturing"
70+
)
71+
def test_subprocess_peek_at_stream_fileno():
72+
""""""
73+
with kernel() as kc:
74+
iopub = kc.iopub_channel
75+
msg_id, content = execute(
76+
kc=kc,
77+
code="import subprocess, sys; subprocess.run(['python', '-c', 'import os; os.system(\"echo CAP1\"); print(\"CAP2\")'], stderr=sys.stderr)",
78+
)
79+
stdout, stderr = assemble_output(iopub)
80+
assert stdout == "CAP1\nCAP2\n"
81+
assert stderr == ""
82+
_check_master(kc, expected=True)
83+
84+
5585
def test_sys_path():
5686
"""test that sys.path doesn't get messed up by default"""
5787
with kernel() as kc:
@@ -85,7 +115,6 @@ def test_sys_path_profile_dir():
85115
def test_subprocess_print():
86116
"""printing from forked mp.Process"""
87117
with new_kernel() as kc:
88-
iopub = kc.iopub_channel
89118

90119
_check_master(kc, expected=True)
91120
flush_channels(kc)
@@ -113,7 +142,6 @@ def test_subprocess_print():
113142
def test_subprocess_noprint():
114143
"""mp.Process without print doesn't trigger iostream mp_mode"""
115144
with kernel() as kc:
116-
iopub = kc.iopub_channel
117145

118146
np = 5
119147
code = '\n'.join([
@@ -140,7 +168,6 @@ def test_subprocess_noprint():
140168
def test_subprocess_error():
141169
"""error in mp.Process doesn't crash"""
142170
with new_kernel() as kc:
143-
iopub = kc.iopub_channel
144171

145172
code = '\n'.join([
146173
"import multiprocessing as mp",
@@ -313,8 +340,6 @@ def test_unc_paths():
313340
file_path = os.path.splitdrive(os.path.dirname(drive_file_path))[1]
314341
unc_file_path = os.path.join(unc_root, file_path[1:])
315342

316-
iopub = kc.iopub_channel
317-
318343
kc.execute("cd {0:s}".format(unc_file_path))
319344
reply = kc.get_shell_msg(block=True, timeout=TIMEOUT)
320345
assert reply['content']['status'] == 'ok'

0 commit comments

Comments
 (0)