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 (# ...)."""
27-
28- def __init__ (self , real_stdout : io .TextIOBase ) -> None :
29- self ._real = real_stdout
30- self ._buf = ''
31-
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 )
39-
40- 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 ()
90-
91-
9223class TAPTestResult (unittest .TestResult ):
9324 """Collect unittest results and render them as TAP version 13."""
9425
95- def __init__ (self , stream : io .TextIOBase ) -> None :
26+ def __init__ (self , stream : io .TextIOBase , diag_stream : io . TextIOBase ) -> None :
9627 super ().__init__ ()
9728 self ._stream = stream
29+ self ._diag_stream = diag_stream
9830 self ._test_count = 0
9931
10032 def _description (self , test : unittest .TestCase ) -> str :
@@ -112,20 +44,20 @@ def addError(self, test: unittest.TestCase, err: object) -> None:
11244 self ._test_count += 1
11345 self ._stream .write ('not ok {} - {}\n ' .format (
11446 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 ))
11847 self ._stream .flush ()
48+ self ._diag_stream .write (
49+ '' .join (traceback .format_exception (* err ))) # type: ignore[misc]
50+ self ._diag_stream .flush ()
11951
12052 def addFailure (self , test : unittest .TestCase , err : object ) -> None :
12153 super ().addFailure (test , err )
12254 self ._test_count += 1
12355 self ._stream .write ('not ok {} - {}\n ' .format (
12456 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 ))
12857 self ._stream .flush ()
58+ self ._diag_stream .write (
59+ '' .join (traceback .format_exception (* err ))) # type: ignore[misc]
60+ self ._diag_stream .flush ()
12961
13062 def addSkip (self , test : unittest .TestCase , reason : str ) -> None :
13163 super ().addSkip (test , reason )
@@ -165,23 +97,17 @@ def run_tests(test_module_name: str, start_dir: str | None = None) -> bool:
16597 real_stdout .write ('1..{}\n ' .format (suite .countTestCases ()))
16698 real_stdout .flush ()
16799
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 )
100+ # Redirect sys.stdout to real_stderr so that print()/sys.stdout.write()
101+ # calls from setUp/tearDown/tests do not pollute the TAP stream on stdout.
102+ # All diagnostic output (print statements, subprocess stderr via fd 2, and
103+ # test failure tracebacks) goes to stderr so that 'meson test -v' shows it
104+ # live on the terminal.
105+ sys .stdout = real_stderr # type: ignore[assignment]
176106 try :
177- result = TAPTestResult (real_stdout )
107+ result = TAPTestResult (real_stdout , real_stderr )
178108 suite .run (result )
179109 finally :
180- sys .stdout .flush ()
181110 sys .stdout = real_stdout
182- sys .stderr .flush ()
183- sys .stderr = real_stderr
184- stderr_fd_capture .restore ()
185111
186112 return result .wasSuccessful ()
187113
0 commit comments