1515import argparse
1616import importlib
1717import io
18+ import os
1819import sys
20+ import threading
1921import traceback
2022import unittest
2123
2224
2325class 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+
4592class 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
105152def 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