1515import inspect
1616import os
1717import re
18+ import signal
1819import sys
1920import time
2021import unittest
2425
2526results : dict = {}
2627
28+ _SIGALRM_AVAILABLE = hasattr (signal , "SIGALRM" )
29+
30+
31+ class _TestTimeoutError (Exception ):
32+ """Raised when a single test exceeds the per-test timeout."""
33+
34+
35+ def _alarm_handler (signum , frame ):
36+ raise _TestTimeoutError ("Test timed out" )
37+
2738
2839class TimeLoggingTestResult (unittest .TextTestResult ):
2940 """Overload the default results so that we can store the results."""
3041
42+ # Set by the caller before running; 0 means no timeout.
43+ timeout : int = 0
44+
3145 def __init__ (self , * args , ** kwargs ):
3246 super ().__init__ (* args , ** kwargs )
3347 self .timed_tests = {}
@@ -37,10 +51,15 @@ def startTest(self, test): # noqa: N802
3751 self .start_time = time .time ()
3852 name = self .getDescription (test )
3953 self .stream .write (f"Starting test: { name } ...\n " )
54+ if _SIGALRM_AVAILABLE and self .timeout > 0 :
55+ signal .signal (signal .SIGALRM , _alarm_handler )
56+ signal .alarm (self .timeout )
4057 super ().startTest (test )
4158
4259 def stopTest (self , test ): # noqa: N802
4360 """On test end, get time, print, store and do normal behaviour."""
61+ if _SIGALRM_AVAILABLE and self .timeout > 0 :
62+ signal .alarm (0 ) # cancel any pending alarm
4463 elapsed = time .time () - self .start_time
4564 name = self .getDescription (test )
4665 self .stream .write (f"Finished test: { name } ({ elapsed :.03} s)\n " )
@@ -99,6 +118,13 @@ def parse_args():
99118 parser .add_argument (
100119 "-f" , "--failfast" , action = "store_true" , dest = "failfast" , default = False , help = "Stop testing on first failure"
101120 )
121+ parser .add_argument (
122+ "--timeout" ,
123+ dest = "timeout" ,
124+ default = 0 ,
125+ type = int ,
126+ help = "Per-test timeout in seconds; 0 disables (default: %(default)d). Requires SIGALRM (Linux/macOS only)." ,
127+ )
102128 args = parser .parse_args ()
103129 print (f"Running tests in folder: '{ args .path } '" )
104130 if args .pattern :
@@ -145,6 +171,13 @@ def get_default_pattern(loader):
145171 discovery_time = pc .total_time
146172 print (f"time to discover tests: { discovery_time } s, total cases: { tests .countTestCases ()} ." )
147173
174+ if args .timeout > 0 :
175+ if _SIGALRM_AVAILABLE :
176+ TimeLoggingTestResult .timeout = args .timeout
177+ print (f"Per-test timeout enabled: { args .timeout } s" )
178+ else :
179+ print ("Warning: --timeout ignored; SIGALRM is not available on this platform." )
180+
148181 test_runner = unittest .runner .TextTestRunner (
149182 resultclass = TimeLoggingTestResult , verbosity = args .verbosity , failfast = args .failfast
150183 )
0 commit comments