Skip to content

Commit 59f52fc

Browse files
committed
tests/tap_runner: intercept stderr
Obviously, besides intercepting stdout, the tap runner should also intercept stderr. Signed-off-by: Daniel Wagner <[email protected]>
1 parent b68eb65 commit 59f52fc

1 file changed

Lines changed: 79 additions & 23 deletions

File tree

tests/tap_runner.py

Lines changed: 79 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,18 @@
1515
import argparse
1616
import importlib
1717
import io
18+
import os
1819
import sys
20+
import threading
1921
import traceback
2022
import unittest
2123

2224

2325
class DiagnosticCapture(io.TextIOBase):
2426
"""Capture writes and re-emit them as TAP diagnostic lines (# ...)."""
2527

26-
def __init__(self, real_stdout: io.TextIOBase) -> None:
27-
self._real = real_stdout
28+
def __init__(self, stream: io.TextIOBase) -> None:
29+
self._real = stream
2830
self._buf = ''
2931

3032
def write(self, text: str) -> int:
@@ -42,64 +44,109 @@ def flush(self) -> None:
4244
self._real.flush()
4345

4446

47+
class FDCapture:
48+
"""Redirect a file descriptor at the OS level and re-emit captured output
49+
as TAP diagnostic lines. This intercepts writes from subprocesses which
50+
bypass the Python-level sys.stderr redirect."""
51+
52+
def __init__(self, fd: int, real_stdout: io.TextIOBase) -> None:
53+
self._fd = fd
54+
self._real = real_stdout
55+
self._saved_fd = os.dup(fd)
56+
r_fd, w_fd = os.pipe()
57+
os.dup2(w_fd, fd)
58+
os.close(w_fd)
59+
self._thread = threading.Thread(target=self._reader, args=(r_fd,),
60+
daemon=True)
61+
# daemon=True: if restore() is somehow never called (e.g. os._exit()),
62+
# the process can still exit rather than hang on a blocking read.
63+
self._thread.start()
64+
65+
def _reader(self, r_fd: int) -> None:
66+
buf = b''
67+
# Open unbuffered (bufsize=0) so bytes are delivered to the reader
68+
# as soon as they are written, without waiting for a buffer to fill.
69+
with open(r_fd, 'rb', 0) as f:
70+
while True:
71+
chunk = f.read(4096)
72+
if not chunk:
73+
break
74+
buf += chunk
75+
while b'\n' in buf:
76+
line, buf = buf.split(b'\n', 1)
77+
self._real.write(
78+
'# {}\n'.format(line.decode('utf-8', errors='replace')))
79+
self._real.flush()
80+
if buf:
81+
self._real.write(
82+
'# {}\n'.format(buf.decode('utf-8', errors='replace')))
83+
self._real.flush()
84+
85+
def restore(self) -> None:
86+
"""Restore the original file descriptor and wait for the reader to drain."""
87+
os.dup2(self._saved_fd, self._fd)
88+
os.close(self._saved_fd)
89+
self._thread.join()
90+
91+
4592
class TAPTestResult(unittest.TestResult):
4693
"""Collect unittest results and render them as TAP version 13."""
4794

48-
def __init__(self) -> None:
95+
def __init__(self, stream: io.TextIOBase) -> None:
4996
super().__init__()
97+
self._stream = stream
5098
self._test_count = 0
51-
self._lines: list[str] = []
5299

53100
def _description(self, test: unittest.TestCase) -> str:
54101
return '{} ({})'.format(test._testMethodName, type(test).__name__)
55102

56103
def addSuccess(self, test: unittest.TestCase) -> None:
57104
super().addSuccess(test)
58105
self._test_count += 1
59-
self._lines.append('ok {} - {}\n'.format(
106+
self._stream.write('ok {} - {}\n'.format(
60107
self._test_count, self._description(test)))
108+
self._stream.flush()
61109

62110
def addError(self, test: unittest.TestCase, err: object) -> None:
63111
super().addError(test, err)
64112
self._test_count += 1
65-
self._lines.append('not ok {} - {}\n'.format(
113+
self._stream.write('not ok {} - {}\n'.format(
66114
self._test_count, self._description(test)))
67115
for line in traceback.format_exception(*err): # type: ignore[misc]
68116
for subline in line.splitlines():
69-
self._lines.append('# {}\n'.format(subline))
117+
self._stream.write('# {}\n'.format(subline))
118+
self._stream.flush()
70119

71120
def addFailure(self, test: unittest.TestCase, err: object) -> None:
72121
super().addFailure(test, err)
73122
self._test_count += 1
74-
self._lines.append('not ok {} - {}\n'.format(
123+
self._stream.write('not ok {} - {}\n'.format(
75124
self._test_count, self._description(test)))
76125
for line in traceback.format_exception(*err): # type: ignore[misc]
77126
for subline in line.splitlines():
78-
self._lines.append('# {}\n'.format(subline))
127+
self._stream.write('# {}\n'.format(subline))
128+
self._stream.flush()
79129

80130
def addSkip(self, test: unittest.TestCase, reason: str) -> None:
81131
super().addSkip(test, reason)
82132
self._test_count += 1
83-
self._lines.append('ok {} - {} # SKIP {}\n'.format(
133+
self._stream.write('ok {} - {} # SKIP {}\n'.format(
84134
self._test_count, self._description(test), reason))
135+
self._stream.flush()
85136

86137
def addExpectedFailure(self, test: unittest.TestCase, err: object) -> None:
87138
super().addExpectedFailure(test, err)
88139
self._test_count += 1
89-
self._lines.append('ok {} - {} # TODO expected failure\n'.format(
140+
self._stream.write('ok {} - {} # TODO expected failure\n'.format(
90141
self._test_count, self._description(test)))
142+
self._stream.flush()
91143

92144
def addUnexpectedSuccess(self, test: unittest.TestCase) -> None:
93145
super().addUnexpectedSuccess(test)
94146
self._test_count += 1
95-
self._lines.append('not ok {} - {} # TODO unexpected success\n'.format(
147+
self._stream.write('not ok {} - {} # TODO unexpected success\n'.format(
96148
self._test_count, self._description(test)))
97-
98-
def print_tap(self, stream: io.TextIOBase) -> None:
99-
stream.write('1..{}\n'.format(self._test_count))
100-
for line in self._lines:
101-
stream.write(line)
102-
stream.flush()
149+
self._stream.flush()
103150

104151

105152
def run_tests(test_module_name: str, start_dir: str | None = None) -> bool:
@@ -112,21 +159,30 @@ def run_tests(test_module_name: str, start_dir: str | None = None) -> bool:
112159
suite = loader.loadTestsFromModule(module)
113160

114161
real_stdout = sys.stdout
115-
# TAP version header must be the very first line on stdout.
162+
real_stderr = sys.stderr
163+
# TAP version header and plan must appear before any test output.
116164
real_stdout.write('TAP version 13\n')
165+
real_stdout.write('1..{}\n'.format(suite.countTestCases()))
117166
real_stdout.flush()
118167

119-
# Redirect stdout so any print() calls from setUp/tearDown/tests are
120-
# re-emitted as TAP diagnostic lines and do not break the TAP stream.
168+
# Redirect stdout and stderr so any print()/sys.stderr.write() calls from
169+
# setUp/tearDown/tests are re-emitted as TAP diagnostic lines and do not
170+
# break the TAP stream.
121171
sys.stdout = DiagnosticCapture(real_stdout) # type: ignore[assignment]
172+
sys.stderr = DiagnosticCapture(real_stdout) # type: ignore[assignment]
173+
# Also redirect fd 2 at the OS level so that subprocess stderr (which
174+
# inherits the raw file descriptor and bypasses sys.stderr) is captured.
175+
stderr_fd_capture = FDCapture(2, real_stdout)
122176
try:
123-
result = TAPTestResult()
177+
result = TAPTestResult(real_stdout)
124178
suite.run(result)
125179
finally:
126180
sys.stdout.flush()
127181
sys.stdout = real_stdout
182+
sys.stderr.flush()
183+
sys.stderr = real_stderr
184+
stderr_fd_capture.restore()
128185

129-
result.print_tap(real_stdout)
130186
return result.wasSuccessful()
131187

132188

0 commit comments

Comments
 (0)