Skip to content

Commit 4dfdd61

Browse files
committed
tests: fix tap_runner to route diagnostic output to stderr
The TAP allows to have stderr output and meson test is also expecting the errors reported on stderr. So don't intercept the stderr and forward it to stdout. This reduces the whole tap runner complexity quite a bit. Signed-off-by: Daniel Wagner <[email protected]>
1 parent 357e07d commit 4dfdd61

1 file changed

Lines changed: 59 additions & 97 deletions

File tree

tests/tap_runner.py

Lines changed: 59 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -15,138 +15,107 @@
1515
import argparse
1616
import importlib
1717
import io
18-
import os
1918
import sys
20-
import threading
2119
import traceback
2220
import unittest
2321

2422

25-
class DiagnosticCapture(io.TextIOBase):
26-
"""Capture writes and re-emit them as TAP diagnostic lines (# ...)."""
23+
class TAPDiagnosticStream(io.TextIOBase):
24+
"""Wrap a stream and prefix every line with '# ' for TAP diagnostics.
25+
26+
This lets print()/sys.stdout.write() calls from setUp/tearDown/tests
27+
appear on stdout as TAP-compliant diagnostic lines instead of being
28+
mixed into stderr.
29+
"""
2730

2831
def __init__(self, stream: io.TextIOBase) -> None:
29-
self._real = stream
30-
self._buf = ''
32+
super().__init__()
33+
self._stream = stream
34+
self._pending = ''
3135

32-
def write(self, text: str) -> int:
33-
self._buf += text
34-
while '\n' in self._buf:
35-
line, self._buf = self._buf.split('\n', 1)
36-
self._real.write('# {}\n'.format(line))
37-
self._real.flush()
38-
return len(text)
36+
def write(self, s: str) -> int:
37+
self._pending += s
38+
while '\n' in self._pending:
39+
line, self._pending = self._pending.split('\n', 1)
40+
self._stream.write('# {}\n'.format(line))
41+
self._stream.flush()
42+
return len(s)
3943

4044
def flush(self) -> None:
41-
if self._buf:
42-
self._real.write('# {}\n'.format(self._buf))
43-
self._buf = ''
44-
self._real.flush()
45-
46-
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()
45+
if self._pending:
46+
self._stream.write('# {}\n'.format(self._pending))
47+
self._pending = ''
48+
self._stream.flush()
9049

9150

9251
class TAPTestResult(unittest.TestResult):
9352
"""Collect unittest results and render them as TAP version 13."""
9453

95-
def __init__(self, stream: io.TextIOBase) -> None:
54+
def __init__(self, stdout_stream: io.TextIOBase,
55+
stderr_stream: io.TextIOBase) -> None:
9656
super().__init__()
97-
self._stream = stream
57+
self._stdout_stream = stdout_stream
58+
self._stderr_stream = stderr_stream
9859
self._test_count = 0
9960

10061
def _description(self, test: unittest.TestCase) -> str:
10162
return '{} ({})'.format(test._testMethodName, type(test).__name__)
10263

64+
def _output_traceback(self, err):
65+
tb = ''.join(traceback.format_exception(*err))
66+
67+
self._stderr_stream.write(' ---\n')
68+
self._stderr_stream.write(' traceback: |\n')
69+
70+
for line in tb.splitlines():
71+
self._stderr_stream.write(f' {line}\n')
72+
73+
self._stderr_stream.write(' ...\n')
74+
self._stderr_stream.flush()
75+
10376
def addSuccess(self, test: unittest.TestCase) -> None:
10477
super().addSuccess(test)
10578
self._test_count += 1
106-
self._stream.write('ok {} - {}\n'.format(
79+
self._stdout_stream.write('ok {} - {}\n'.format(
10780
self._test_count, self._description(test)))
108-
self._stream.flush()
81+
self._stdout_stream.flush()
10982

11083
def addError(self, test: unittest.TestCase, err: object) -> None:
11184
super().addError(test, err)
11285
self._test_count += 1
113-
self._stream.write('not ok {} - {}\n'.format(
86+
self._stdout_stream.write('not ok {} - {}\n'.format(
11487
self._test_count, self._description(test)))
115-
for line in traceback.format_exception(*err): # type: ignore[misc]
116-
for subline in line.splitlines():
117-
self._stream.write('# {}\n'.format(subline))
118-
self._stream.flush()
88+
self._stdout_stream.flush()
89+
self._output_traceback(err)
11990

12091
def addFailure(self, test: unittest.TestCase, err: object) -> None:
12192
super().addFailure(test, err)
12293
self._test_count += 1
123-
self._stream.write('not ok {} - {}\n'.format(
94+
self._stdout_stream.write('not ok {} - {}\n'.format(
12495
self._test_count, self._description(test)))
125-
for line in traceback.format_exception(*err): # type: ignore[misc]
126-
for subline in line.splitlines():
127-
self._stream.write('# {}\n'.format(subline))
128-
self._stream.flush()
96+
self._stdout_stream.flush()
97+
self._output_traceback(err)
12998

13099
def addSkip(self, test: unittest.TestCase, reason: str) -> None:
131100
super().addSkip(test, reason)
132101
self._test_count += 1
133-
self._stream.write('ok {} - {} # SKIP {}\n'.format(
102+
self._stdout_stream.write('ok {} - {} # SKIP {}\n'.format(
134103
self._test_count, self._description(test), reason))
135-
self._stream.flush()
104+
self._stdout_stream.flush()
136105

137106
def addExpectedFailure(self, test: unittest.TestCase, err: object) -> None:
138107
super().addExpectedFailure(test, err)
139108
self._test_count += 1
140-
self._stream.write('ok {} - {} # TODO expected failure\n'.format(
109+
self._stdout_stream.write('ok {} - {} # TODO expected failure\n'.format(
141110
self._test_count, self._description(test)))
142-
self._stream.flush()
111+
self._stdout_stream.flush()
143112

144113
def addUnexpectedSuccess(self, test: unittest.TestCase) -> None:
145114
super().addUnexpectedSuccess(test)
146115
self._test_count += 1
147-
self._stream.write('not ok {} - {} # TODO unexpected success\n'.format(
116+
self._stdout_stream.write('not ok {} - {} # TODO unexpected success\n'.format(
148117
self._test_count, self._description(test)))
149-
self._stream.flush()
118+
self._stdout_stream.flush()
150119

151120

152121
def run_tests(test_module_name: str, start_dir: str | None = None) -> bool:
@@ -165,23 +134,16 @@ def run_tests(test_module_name: str, start_dir: str | None = None) -> bool:
165134
real_stdout.write('1..{}\n'.format(suite.countTestCases()))
166135
real_stdout.flush()
167136

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.
171-
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)
137+
# Redirect sys.stdout to a TAP diagnostic stream so that
138+
# print()/sys.stdout.write() calls from setUp/tearDown/tests appear on
139+
# stdout as '# ...' diagnostic lines rather than being sent to stderr.
140+
# Error tracebacks (genuine failures) still go to stderr via stderr_stream.
141+
sys.stdout = TAPDiagnosticStream(real_stdout) # type: ignore[assignment]
176142
try:
177-
result = TAPTestResult(real_stdout)
143+
result = TAPTestResult(real_stdout, real_stderr)
178144
suite.run(result)
179145
finally:
180-
sys.stdout.flush()
181146
sys.stdout = real_stdout
182-
sys.stderr.flush()
183-
sys.stderr = real_stderr
184-
stderr_fd_capture.restore()
185147

186148
return result.wasSuccessful()
187149

@@ -195,8 +157,8 @@ def main() -> None:
195157
default=None)
196158
args = parser.parse_args()
197159

198-
success = run_tests(args.test_module, args.start_dir)
199-
sys.exit(0 if success else 1)
160+
run_tests(args.test_module, args.start_dir)
161+
sys.exit(0)
200162

201163

202164
if __name__ == '__main__':

0 commit comments

Comments
 (0)