1515import argparse
1616import importlib
1717import io
18- import os
1918import sys
20- import threading
2119import traceback
2220import 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
9251class 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
152121def 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
202164if __name__ == '__main__' :
0 commit comments