From 818f99dc99f0ab6fbe8b861d8ff2126ba0714445 Mon Sep 17 00:00:00 2001 From: deathaxe Date: Sun, 7 Jun 2026 16:40:44 +0200 Subject: [PATCH] Improve coroutine timeout handling This commit... 1. adds class-level `timeout_ms` attribute to customize runtime of asyncio coroutines. 2. uses `timeout_ms` argument from test_... coroutines to cutomize test case runtime. example: async def test_anything(self, timeout_ms=10000): ... 3. raises TimeoutError within context of currently awaited asynchronous coroutine to ensure valuable tracebacks being printed. --- README.md | 7 ++++++- tests/_Asyncio/unittesting.json | 3 --- .../.python-version | 0 .../tests/test_coroutine.py | 0 tests/_Asyncio_Timeout/.python-version | 1 + .../_Asyncio_Timeout/tests/test_coroutine.py | 19 +++++++++++++++++++ tests/test_3141596.py | 12 +++++++++--- unittesting/core/py313/case.py | 15 +++++++++++++-- unittesting/core/py38/case.py | 15 +++++++++++++-- 9 files changed, 61 insertions(+), 11 deletions(-) delete mode 100644 tests/_Asyncio/unittesting.json rename tests/{_Asyncio => _Asyncio_Success}/.python-version (100%) rename tests/{_Asyncio => _Asyncio_Success}/tests/test_coroutine.py (100%) create mode 100644 tests/_Asyncio_Timeout/.python-version create mode 100644 tests/_Asyncio_Timeout/tests/test_coroutine.py diff --git a/README.md b/README.md index b57e2e9c..5fa2d9a2 100644 --- a/README.md +++ b/README.md @@ -590,6 +590,8 @@ async def async_coroutine(view): class MyAsyncTestCase(AsyncTestCase): + timeout_ms = 4000 + """Class wide coroutine timeout.""" @classmethod async def setUpClass(cls): @@ -613,7 +615,10 @@ class MyAsyncTestCase(AsyncTestCase): "Initial Content" ) - async def test_coroutine(self): + async def test_coroutine(self, timeout_ms=10000): + """ + A long running coroutine with custom timeout. + """ await async_coroutine(self.view) self.assertEqual( self.view.substr(sublime.Region(0, self.view.size())), diff --git a/tests/_Asyncio/unittesting.json b/tests/_Asyncio/unittesting.json deleted file mode 100644 index 5fcf8eb9..00000000 --- a/tests/_Asyncio/unittesting.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "deferred": true -} diff --git a/tests/_Asyncio/.python-version b/tests/_Asyncio_Success/.python-version similarity index 100% rename from tests/_Asyncio/.python-version rename to tests/_Asyncio_Success/.python-version diff --git a/tests/_Asyncio/tests/test_coroutine.py b/tests/_Asyncio_Success/tests/test_coroutine.py similarity index 100% rename from tests/_Asyncio/tests/test_coroutine.py rename to tests/_Asyncio_Success/tests/test_coroutine.py diff --git a/tests/_Asyncio_Timeout/.python-version b/tests/_Asyncio_Timeout/.python-version new file mode 100644 index 00000000..98fccd6d --- /dev/null +++ b/tests/_Asyncio_Timeout/.python-version @@ -0,0 +1 @@ +3.8 \ No newline at end of file diff --git a/tests/_Asyncio_Timeout/tests/test_coroutine.py b/tests/_Asyncio_Timeout/tests/test_coroutine.py new file mode 100644 index 00000000..6b0218c7 --- /dev/null +++ b/tests/_Asyncio_Timeout/tests/test_coroutine.py @@ -0,0 +1,19 @@ +import asyncio + +from unittesting import AsyncTestCase + + +async def a_coro(test): + await asyncio.sleep(1.0) + + +class MyAsyncTestCaseA(AsyncTestCase): + timeout_ms = 100 + + async def test_coroutine_class_timeout(self): + await a_coro(self) + + +class MyAsyncTestCaseB(AsyncTestCase): + async def test_coroutine_local_timeout(self, timeout_ms=100): + await a_coro(self) diff --git a/tests/test_3141596.py b/tests/test_3141596.py index 8238faac..d7b253c9 100644 --- a/tests/test_3141596.py +++ b/tests/test_3141596.py @@ -122,7 +122,8 @@ def assertOk(self, txt, msg=None): class TestUnitTesting(UnitTestingTestCase): fixtures = ( - "_Success", "_Failure", "_Empty", "_Output", "_Deferred", "_Async", "_Asyncio" + "_Success", "_Failure", "_Empty", "_Output", "_Deferred", "_Async", + "_Asyncio_Success", "_Asyncio_Timeout" ) @with_package("_Success") @@ -154,10 +155,15 @@ def test_async(self, txt): self.assertOk(txt) @skipIf(PY33, "not applicable in Python 3.3") - @with_package("_Asyncio") - def test_asyncio(self, txt): + @with_package("_Asyncio_Success") + def test_asyncio_success(self, txt): self.assertOk(txt) + @skipIf(PY33, "not applicable in Python 3.3") + @with_package("_Asyncio_Timeout") + def test_asyncio_timeout(self, txt): + self.assertRegexContains(txt, r'^ERROR') + class TestSyntax(UnitTestingTestCase): fixtures = ( diff --git a/unittesting/core/py313/case.py b/unittesting/core/py313/case.py index cee605a3..9083be20 100644 --- a/unittesting/core/py313/case.py +++ b/unittesting/core/py313/case.py @@ -10,6 +10,7 @@ from unittest.case import _Outcome from unittest.case import expectedFailure +from .runner import DEFAULT_CONDITION_TIMEOUT from .runner import defer __all__ = [ @@ -22,6 +23,7 @@ class DeferrableTestCase(TestCase): + timeout_ms: int = DEFAULT_CONDITION_TIMEOUT def _callSetUp(self): return self._callMaybeCoro(self.setUp) @@ -48,7 +50,7 @@ def _callMaybeCoro(cls, func, /, *args, **kwargs): elif inspect.iscoroutine(coro): fut = cls.run_coroutine(coro) - def wait_until_complete(): + def await_future(): if not fut.done() and not fut.cancelled(): return False exception = fut.exception() @@ -56,7 +58,16 @@ def wait_until_complete(): raise exception from None return True - yield wait_until_complete + if frame := coro.cr_frame: + # prefer optional timeout from test_... coroutine's arguments + timeout_ms = frame.f_locals.get("timeout_ms", cls.timeout_ms) + else: + timeout_ms = cls.timeout_ms + try: + yield {"condition": await_future, "timeout": timeout_ms} + except TimeoutError: + msg = f"Task not completed within {timeout_ms / 1000:.2f} seconds." + coro.throw(TimeoutError, msg) @staticmethod def run_coroutine(coro): diff --git a/unittesting/core/py38/case.py b/unittesting/core/py38/case.py index e004f943..6273683b 100644 --- a/unittesting/core/py38/case.py +++ b/unittesting/core/py38/case.py @@ -9,6 +9,7 @@ from unittest.case import _Outcome from unittest.case import expectedFailure +from .runner import DEFAULT_CONDITION_TIMEOUT from .runner import defer __all__ = [ @@ -21,6 +22,7 @@ class DeferrableTestCase(TestCase): + timeout_ms: int = DEFAULT_CONDITION_TIMEOUT def _callSetUp(self): return self._callMaybeCoro(self.setUp) @@ -47,7 +49,7 @@ def _callMaybeCoro(cls, func, /, *args, **kwargs): elif inspect.iscoroutine(coro): fut = cls.run_coroutine(coro) - def wait_until_complete(): + def await_future(): if not fut.done() and not fut.cancelled(): return False exception = fut.exception() @@ -55,7 +57,16 @@ def wait_until_complete(): raise exception from None return True - yield wait_until_complete + if frame := coro.cr_frame: + # prefer optional timeout from test_... coroutine's arguments + timeout_ms = frame.f_locals.get("timeout_ms", cls.timeout_ms) + else: + timeout_ms = cls.timeout_ms + try: + yield {"condition": await_future, "timeout": timeout_ms} + except TimeoutError: + msg = f"Task not completed within {timeout_ms / 1000:.2f} seconds." + coro.throw(TimeoutError, msg) @staticmethod def run_coroutine(coro):