Skip to content

Commit 88311cd

Browse files
Suppress stdio file descriptors in the test adapter. (microsoft#13494)
(for microsoft#11729) The test adapter already hides away `sys.stdout` and `sys.stderr`. However, some libraries (e.g. Cython-build extension modules) use the stdio file descriptors directly. This causes the test adapter to fail. The solution is to hide away those FDs as well.
1 parent 0f9f7b8 commit 88311cd

3 files changed

Lines changed: 152 additions & 34 deletions

File tree

news/2 Fixes/11729.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#11729
2+
Prevent test discovery from picking up stdout from low level file descriptors
3+
(thanks [Ryo Miyajima](https://github.com/sergeant-wizard))

pythonFiles/testing_tools/adapter/util.py

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
from io import StringIO
88
except ImportError:
99
from StringIO import StringIO # 2.7
10+
import os
1011
import os.path
1112
import sys
13+
import tempfile
1214

1315

1416
@contextlib.contextmanager
@@ -171,27 +173,81 @@ def fix_fileid(
171173

172174

173175
@contextlib.contextmanager
174-
def hide_stdio():
175-
"""Swallow stdout and stderr."""
176-
ignored = StdioStream()
177-
sys.stdout = ignored
178-
sys.stderr = ignored
176+
def _replace_fd(file, target):
177+
"""
178+
Temporarily replace the file descriptor for `file`,
179+
for which sys.stdout or sys.stderr is passed.
180+
"""
181+
try:
182+
fd = file.fileno()
183+
except AttributeError:
184+
# `file` does not have fileno() so it's been replaced from the
185+
# default sys.stdout, etc. Return with noop.
186+
yield
187+
return
188+
target_fd = target.fileno()
189+
190+
# Keep the original FD to be restored in the finally clause.
191+
dup_fd = os.dup(fd)
179192
try:
180-
yield ignored
193+
# Point the FD at the target.
194+
os.dup2(target_fd, fd)
195+
try:
196+
yield
197+
finally:
198+
# Point the FD back at the original.
199+
os.dup2(dup_fd, fd)
181200
finally:
182-
sys.stdout = sys.__stdout__
183-
sys.stderr = sys.__stderr__
201+
os.close(dup_fd)
184202

185203

186-
if sys.version_info < (3,):
204+
@contextlib.contextmanager
205+
def _replace_stdout(target):
206+
orig = sys.stdout
207+
sys.stdout = target
208+
try:
209+
yield orig
210+
finally:
211+
sys.stdout = orig
187212

188-
class StdioStream(StringIO):
189-
def write(self, msg):
190-
StringIO.write(self, msg.decode())
213+
214+
@contextlib.contextmanager
215+
def _replace_stderr(target):
216+
orig = sys.stderr
217+
sys.stderr = target
218+
try:
219+
yield orig
220+
finally:
221+
sys.stderr = orig
191222

192223

224+
if sys.version_info < (3,):
225+
_coerce_unicode = lambda s: unicode(s)
193226
else:
194-
StdioStream = StringIO
227+
_coerce_unicode = lambda s: s
228+
229+
230+
@contextlib.contextmanager
231+
def _temp_io():
232+
sio = StringIO()
233+
with tempfile.TemporaryFile("r+") as tmp:
234+
try:
235+
yield sio, tmp
236+
finally:
237+
tmp.seek(0)
238+
buff = tmp.read()
239+
sio.write(_coerce_unicode(buff))
240+
241+
242+
@contextlib.contextmanager
243+
def hide_stdio():
244+
"""Swallow stdout and stderr."""
245+
with _temp_io() as (sio, fileobj):
246+
with _replace_fd(sys.stdout, fileobj):
247+
with _replace_stdout(fileobj):
248+
with _replace_fd(sys.stderr, fileobj):
249+
with _replace_stderr(fileobj):
250+
yield sio
195251

196252

197253
#############################

pythonFiles/tests/testing_tools/adapter/pytest/test_discovery.py

Lines changed: 80 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
except ImportError: # 2.7
99
from StringIO import StringIO
1010
from os import name as OS_NAME
11+
from os import write
1112
import sys
13+
import tempfile
1214
import unittest
1315

1416
import pytest
@@ -246,6 +248,18 @@ def _parse_item(item):
246248
# tests
247249

248250

251+
def fake_pytest_main(stub, use_fd, pytest_stdout):
252+
def ret(args, plugins):
253+
stub.add_call("pytest.main", None, {"args": args, "plugins": plugins})
254+
if use_fd:
255+
write(sys.stdout.fileno(), pytest_stdout.encode())
256+
else:
257+
print(pytest_stdout, end="")
258+
return 0
259+
260+
return ret
261+
262+
249263
class DiscoverTests(unittest.TestCase):
250264

251265
DEFAULT_ARGS = [
@@ -313,15 +327,9 @@ def test_no_tests_found(self):
313327
self.assertEqual(tests, expected)
314328
self.assertEqual(stub.calls, calls)
315329

316-
def test_stdio_hidden(self):
317-
pytest_stdout = "spamspamspamspamspamspamspammityspam"
330+
def test_stdio_hidden_file(self):
318331
stub = Stub()
319332

320-
def fake_pytest_main(args, plugins):
321-
stub.add_call("pytest.main", None, {"args": args, "plugins": plugins})
322-
print(pytest_stdout, end="")
323-
return 0
324-
325333
plugin = StubPlugin(stub)
326334
plugin.discovered = []
327335
calls = [
@@ -330,31 +338,54 @@ def fake_pytest_main(args, plugins):
330338
("discovered.__len__", None, None),
331339
("discovered.__getitem__", (0,), None),
332340
]
341+
pytest_stdout = "spamspamspamspamspamspamspammityspam"
333342

334343
# In Python 3.8 __len__ is called twice.
335344
if PYTHON_38_OR_LATER:
336345
calls.insert(3, ("discovered.__len__", None, None))
337346

338-
buf = StringIO()
347+
# to simulate stdio behavior in methods like os.dup,
348+
# use actual files (rather than StringIO)
349+
with tempfile.TemporaryFile("r+") as mock:
350+
sys.stdout = mock
351+
try:
352+
discover(
353+
[],
354+
hidestdio=True,
355+
_pytest_main=fake_pytest_main(stub, False, pytest_stdout),
356+
_plugin=plugin,
357+
)
358+
finally:
359+
sys.stdout = sys.__stdout__
339360

340-
sys.stdout = buf
341-
try:
342-
discover([], hidestdio=True, _pytest_main=fake_pytest_main, _plugin=plugin)
343-
finally:
344-
sys.stdout = sys.__stdout__
345-
captured = buf.getvalue()
361+
mock.seek(0)
362+
captured = mock.read()
346363

347364
self.assertEqual(captured, "")
348365
self.assertEqual(stub.calls, calls)
349366

350-
def test_stdio_not_hidden(self):
351-
pytest_stdout = "spamspamspamspamspamspamspammityspam"
367+
def test_stdio_hidden_fd(self):
368+
# simulate cases where stdout comes from the lower layer than sys.stdout
369+
# via file descriptors (e.g., from cython)
352370
stub = Stub()
371+
plugin = StubPlugin(stub)
372+
pytest_stdout = "spamspamspamspamspamspamspammityspam"
353373

354-
def fake_pytest_main(args, plugins):
355-
stub.add_call("pytest.main", None, {"args": args, "plugins": plugins})
356-
print(pytest_stdout, end="")
357-
return 0
374+
sys.stdio = StringIO()
375+
try:
376+
discover(
377+
[],
378+
hidestdio=True,
379+
_pytest_main=fake_pytest_main(stub, True, pytest_stdout),
380+
_plugin=plugin,
381+
)
382+
finally:
383+
captured = sys.stdout.read()
384+
sys.stdout = sys.__stdout__
385+
self.assertEqual(captured, b"")
386+
387+
def test_stdio_not_hidden_file(self):
388+
stub = Stub()
358389

359390
plugin = StubPlugin(stub)
360391
plugin.discovered = []
@@ -364,6 +395,7 @@ def fake_pytest_main(args, plugins):
364395
("discovered.__len__", None, None),
365396
("discovered.__getitem__", (0,), None),
366397
]
398+
pytest_stdout = "spamspamspamspamspamspamspammityspam"
367399

368400
# In Python 3.8 __len__ is called twice.
369401
if PYTHON_38_OR_LATER:
@@ -373,14 +405,41 @@ def fake_pytest_main(args, plugins):
373405

374406
sys.stdout = buf
375407
try:
376-
discover([], hidestdio=False, _pytest_main=fake_pytest_main, _plugin=plugin)
408+
discover(
409+
[],
410+
hidestdio=False,
411+
_pytest_main=fake_pytest_main(stub, False, pytest_stdout),
412+
_plugin=plugin,
413+
)
377414
finally:
378415
sys.stdout = sys.__stdout__
379416
captured = buf.getvalue()
380417

381418
self.assertEqual(captured, pytest_stdout)
382419
self.assertEqual(stub.calls, calls)
383420

421+
def test_stdio_not_hidden_fd(self):
422+
# simulate cases where stdout comes from the lower layer than sys.stdout
423+
# via file descriptors (e.g., from cython)
424+
stub = Stub()
425+
plugin = StubPlugin(stub)
426+
pytest_stdout = "spamspamspamspamspamspamspammityspam"
427+
stub.calls = []
428+
with tempfile.TemporaryFile("r+") as mock:
429+
sys.stdout = mock
430+
try:
431+
discover(
432+
[],
433+
hidestdio=False,
434+
_pytest_main=fake_pytest_main(stub, True, pytest_stdout),
435+
_plugin=plugin,
436+
)
437+
finally:
438+
mock.seek(0)
439+
captured = sys.stdout.read()
440+
sys.stdout = sys.__stdout__
441+
self.assertEqual(captured, pytest_stdout)
442+
384443

385444
class CollectorTests(unittest.TestCase):
386445
def test_modifyitems(self):

0 commit comments

Comments
 (0)