diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml new file mode 100644 index 0000000..d98e87b --- /dev/null +++ b/.github/workflows/macos.yml @@ -0,0 +1,34 @@ +name: Stopit (macOS) + +on: + push: + branches: [ master ] + pull_request: + branches: '**' + +jobs: + build: + env: + LDFLAGS: "-L/usr/local/opt/llvm@14/lib" + CPPFLAGS: "-I/usr/local/opt/llvm@14/include" + runs-on: macos-latest + strategy: + matrix: + os: [macOS] + python-version: ['3.10', '3.11'] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install OS dependencies + run: | + python -m pip install --upgrade pip + - name: Install stopit + run: | + pip install "setuptools>=70.0.0" packaging pytest + pip install -e . + - name: Test Stopit + run: | + python tests.py diff --git a/.github/workflows/pyodide.yml b/.github/workflows/pyodide.yml new file mode 100644 index 0000000..46f6ea5 --- /dev/null +++ b/.github/workflows/pyodide.yml @@ -0,0 +1,60 @@ +# Copied from SymPy https://github.com/sympy/sympy/pull/27183 + +name: Stopit (Pyodide) + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + pyodide-test: + runs-on: ubuntu-latest + env: + PYODIDE_VERSION: 0.27.0a2 + # PYTHON_VERSION and EMSCRIPTEN_VERSION are determined by PYODIDE_VERSION. + # The appropriate versions can be found in the Pyodide repodata.json + # "info" field, or in Makefile.envs: + # https://github.com/pyodide/pyodide/blob/main/Makefile.envs#L2 + PYTHON_VERSION: 3.12.1 + EMSCRIPTEN_VERSION: 3.1.58 + NODE_VERSION: 20 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Set up Emscripten toolchain + uses: mymindstorm/setup-emsdk@v14 + with: + version: ${{ env.EMSCRIPTEN_VERSION }} + actions-cache-folder: emsdk-cache + + - name: Install pyodide-build + run: pip install pyodide-build + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Set up Pyodide virtual environment and run tests + run: | + # Set up Pyodide virtual environment + pyodide xbuildenv install ${{ env.PYODIDE_VERSION }} + pyodide venv .venv-pyodide + + # Activate the virtual environment + source .venv-pyodide/bin/activate + + pip install "setuptools>=70.0.0" PyYAML click packaging pytest + python -m pip install --no-build-isolation -e . + - name: Test stopit + run: | + python -c "import sys; print(sys.path); import your_package_name" + python tests.py diff --git a/.github/workflows/ubuntu.yml b/.github/workflows/ubuntu.yml new file mode 100644 index 0000000..4ae30e2 --- /dev/null +++ b/.github/workflows/ubuntu.yml @@ -0,0 +1,28 @@ +name: Stopit (ubuntu) + +on: + push: + branches: [ master ] + pull_request: + branches: '**' + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.12', '3.11', '3.8', '3.9', '3.10'] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install stopit + run: | + python -m pip install --upgrade pip + pip install "setuptools>=70.0.0" packaging pytest + pip install -e . + - name: Test stopit + run: | + python tests.py diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100755 index 0000000..b6538cc --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,31 @@ +name: Stopit (Windows) + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + runs-on: windows-latest + strategy: + matrix: + os: [windows] + # "make doctest" on MS Windows fails without showing much of a + # trace of where things went wrong on Python before 3.11. + python-version: ['3.12'] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install stopit + run: | + python -m pip install --upgrade pip + pip install "setuptools>=70.0.0" packaging pytest + pip install -e . + - name: Test stopit + run: | + python tests.py diff --git a/src/stopit/signalstop.py b/src/stopit/signalstop.py index 377c1a0..3c72cf9 100644 --- a/src/stopit/signalstop.py +++ b/src/stopit/signalstop.py @@ -13,29 +13,56 @@ from .utils import TimeoutException, BaseTimeout, base_timeoutable +ALARMS = [] + + + +def handle_alarms(signum, frame): + global ALARMS + new_alarms = [(ctx, max(0, remaining-1),) for ctx, remaining in ALARMS] + expired = [ctx for ctx, remaining in new_alarms if remaining==0] + ALARMS = [(ctx, remaining,) for ctx, remaining in new_alarms if remaining>0] + if ALARMS: + signal.alarm(1) + for task in expired: + task.stop() + break + + class SignalTimeout(BaseTimeout): """Context manager for limiting in the time the execution of a block using signal.SIGALRM Unix signal. See :class:`stopit.utils.BaseTimeout` for more information """ + def __init__(self, seconds, swallow_exc=True): seconds = int(seconds) # alarm delay for signal MUST be int super(SignalTimeout, self).__init__(seconds, swallow_exc) - def handle_timeout(self, signum, frame): + def stop(self): self.state = BaseTimeout.TIMED_OUT + self.__class__.exception_source = self raise TimeoutException('Block exceeded maximum timeout ' 'value (%d seconds).' % self.seconds) # Required overrides def setup_interrupt(self): - signal.signal(signal.SIGALRM, self.handle_timeout) - signal.alarm(self.seconds) + global ALARMS + for ctx, remaining in ALARMS: + if ctx is self: + return + if len(ALARMS)==0: + signal.signal(signal.SIGALRM, handle_alarms) + signal.alarm(1) + ALARMS.append((self, int(self.seconds),)) def suppress_interrupt(self): - signal.alarm(0) - signal.signal(signal.SIGALRM, signal.SIG_DFL) + global ALARMS + ALARMS = [(ctx, remaining) for ctx, remaining in ALARMS if ctx is not self] + if len(ALARMS)==0: + signal.alarm(0) + signal.signal(signal.SIGALRM, signal.SIG_DFL) class signal_timeoutable(base_timeoutable): #noqa diff --git a/src/stopit/threadstop.py b/src/stopit/threadstop.py index a991750..fa59878 100644 --- a/src/stopit/threadstop.py +++ b/src/stopit/threadstop.py @@ -12,7 +12,7 @@ import sys import threading -from .utils import TimeoutException, BaseTimeout, base_timeoutable +from .utils import LOG, TimeoutException, BaseTimeout, base_timeoutable if sys.version_info < (3, 7): tid_ctype = ctypes.c_long @@ -30,8 +30,9 @@ def async_raise(target_tid, exception): """ # Ensuring and releasing GIL are useless since we're not in C # gil_state = ctypes.pythonapi.PyGILState_Ensure() - ret = ctypes.pythonapi.PyThreadState_SetAsyncExc(tid_ctype(target_tid), - ctypes.py_object(exception)) + ret = ctypes.pythonapi.PyThreadState_SetAsyncExc( + tid_ctype(target_tid), ctypes.py_object(exception) + ) # ctypes.pythonapi.PyGILState_Release(gil_state) if ret == 0: raise ValueError("Invalid thread ID {}".format(target_tid)) @@ -46,7 +47,13 @@ class ThreadingTimeout(BaseTimeout): See :class:`stopit.utils.BaseTimeout` for more information """ + + # This class property keep track about who produced the + # exception. + def __init__(self, seconds, swallow_exc=True): + # Ensure that any new handler find a clear + # pointer super(ThreadingTimeout, self).__init__(seconds, swallow_exc) self.target_tid = threading.current_thread().ident self.timer = None # PEP8 @@ -56,26 +63,26 @@ def stop(self): caller thread """ self.state = BaseTimeout.TIMED_OUT + self.__class__.exception_source = self async_raise(self.target_tid, TimeoutException) # Required overrides def setup_interrupt(self): - """Setting up the resource that interrupts the block - """ + """Setting up the resource that interrupts the block""" self.timer = threading.Timer(self.seconds, self.stop) self.timer.start() def suppress_interrupt(self): - """Removing the resource that interrupts the block - """ + """Removing the resource that interrupts the block""" self.timer.cancel() -class threading_timeoutable(base_timeoutable): #noqa +class threading_timeoutable(base_timeoutable): # noqa """A function or method decorator that raises a ``TimeoutException`` to decorated functions that should not last a certain amount of time. this one uses ``ThreadingTimeout`` context manager. See :class:`.utils.base_timoutable`` class for further comments. """ + to_ctx_mgr = ThreadingTimeout diff --git a/src/stopit/utils.py b/src/stopit/utils.py index ea31fbb..6a21d83 100644 --- a/src/stopit/utils.py +++ b/src/stopit/utils.py @@ -69,18 +69,27 @@ def __repr__(self): return "<{0} in state: {1}>".format(self.__class__.__name__, self.state) def __enter__(self): + self.__class__.exception_source = None self.state = BaseTimeout.EXECUTING self.setup_interrupt() return self def __exit__(self, exc_type, exc_val, exc_tb): + exc_src = self.__class__.exception_source if exc_type is TimeoutException: if self.state != BaseTimeout.TIMED_OUT: self.state = BaseTimeout.INTERRUPTED self.suppress_interrupt() - LOG.warning("Code block execution exceeded {0} seconds timeout".format(self.seconds), - exc_info=(exc_type, exc_val, exc_tb)) - return self.swallow_exc + # LOG.warning( + # "Code block execution exceeded {0} seconds timeout".format( + # self.seconds + # ), + # exc_info=(exc_type, exc_val, exc_tb), + #) + if exc_src is self: + if self.swallow_exc: + self.__class__.exception_source = None + return True else: if exc_type is None: self.state = BaseTimeout.EXECUTED diff --git a/tests.py b/tests.py index 9b1ce5f..923b379 100644 --- a/tests.py +++ b/tests.py @@ -1,30 +1,98 @@ # -*- coding: utf-8 -*- +import time import doctest import os import unittest -from stopit import ThreadingTimeout, threading_timeoutable, SignalTimeout, signal_timeoutable +from stopit import ( + TimeoutException, + ThreadingTimeout, + threading_timeoutable, + SignalTimeout, + signal_timeoutable, +) # We run twice the same doctest with two distinct sets of globs # This one is for testing signals based timeout control -signaling_globs = { - 'Timeout': SignalTimeout, - 'timeoutable': signal_timeoutable -} +signaling_globs = {"Timeout": SignalTimeout, "timeoutable": signal_timeoutable} # And this one is for testing threading based timeout control -threading_globs = { - 'Timeout': ThreadingTimeout, - 'timeoutable': threading_timeoutable -} +threading_globs = {"Timeout": ThreadingTimeout, "timeoutable": threading_timeoutable} + + +class TestNesting(unittest.TestCase): + handlers = ( + ( + SignalTimeout, + ThreadingTimeout, + ) + if os.name == "posix" + else (ThreadingTimeout,) + ) + + def aware_wait(self, duration): + remaining = duration * 100 + t_start = time.time() + while remaining > 0: + time.sleep(0.01) + if time.time() - t_start > duration: + return 0 + remaining = remaining - 1 + return 0 + + def check_nest(self, t1, t2, duration, HandlerClass): + try: + with HandlerClass(t1, swallow_exc=False) as to_ctx_mgr1: + assert to_ctx_mgr1.state == to_ctx_mgr1.EXECUTING + with HandlerClass(t2, swallow_exc=False) as to_ctx_mgr2: + assert to_ctx_mgr2.state == to_ctx_mgr2.EXECUTING + self.aware_wait(duration) + return "success" + except TimeoutException: + if HandlerClass.exception_source is to_ctx_mgr1: + return "outer" + elif HandlerClass.exception_source is to_ctx_mgr2: + return "inner" + else: + print(HandlerClass.exception_source) + return "unknown source" + + def check_nest_swallow(self, t1, t2, duration, HandlerClass): + with HandlerClass(t1) as to_ctx_mgr1: + assert to_ctx_mgr1.state == to_ctx_mgr1.EXECUTING + with HandlerClass(t2) as to_ctx_mgr2: + assert to_ctx_mgr2.state == to_ctx_mgr2.EXECUTING + self.aware_wait(duration) + return "success" + return "inner" + return "outer" + + def test_nested_long_inner(self): + for handler in self.handlers: + self.assertEqual(self.check_nest(1.0, 10.0, 5.0, handler), "outer") + self.assertEqual(self.check_nest_swallow(1.0, 10.0, 5.0, handler), "outer") + + def test_nested_success(self): + for handler in self.handlers: + self.assertEqual( + self.check_nest_swallow(5.0, 10.0, 1.0, handler), "success" + ) + self.assertEqual(self.check_nest(5.0, 10.0, 1.0, handler), "success") + + def test_nested_long_outer(self): + for handler in self.handlers: + self.assertEqual(self.check_nest(10.0, 1.0, 5.0, handler), "inner") + self.assertEqual(self.check_nest_swallow(10.0, 1.0, 5.0, handler), "inner") def suite(): # Func for setuptools.setup(test_suite=xxx) test_suite = unittest.TestSuite() - test_suite.addTest(doctest.DocFileSuite('README.rst', globs=signaling_globs)) - if os.name == 'posix': # Other OS have no support for signal.SIGALRM - test_suite.addTest(doctest.DocFileSuite('README.rst', globs=threading_globs)) + test_suite.addTest(doctest.DocFileSuite("README.rst", globs=threading_globs)) + if os.name == "posix": # Other OS have no support for signal.SIGALRM + test_suite.addTest(doctest.DocFileSuite("README.rst", globs=signaling_globs)) return test_suite -if __name__ == '__main__': + +if __name__ == "__main__": unittest.TextTestRunner(verbosity=2).run(suite()) + unittest.main()