diff --git a/releasenotes/notes/fix-tornado-global-desync-8c2d40fa71b9e35a.yaml b/releasenotes/notes/fix-tornado-global-desync-8c2d40fa71b9e35a.yaml new file mode 100644 index 00000000..c5a19e53 --- /dev/null +++ b/releasenotes/notes/fix-tornado-global-desync-8c2d40fa71b9e35a.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + Setting ``tenacity.tornado = None`` -- the usual way a downstream test + suite forces the non-tornado code path -- raised ``AttributeError: 'NoneType' + object has no attribute 'gen'`` from the ``@retry`` decorator. Tornado + availability is read live again rather than from an import-time snapshot. diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 51925efa..530b63a5 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -89,10 +89,13 @@ except ImportError: tornado = None # type: ignore[assignment] -# mypy resolves `tornado` to the module (it only ever sees the `try` branch), -# so testing the module object for truthiness reads as an always-true check. -# Keep the availability answer in a plain bool instead. -_HAS_TORNADO = tornado is not None + +def _has_tornado() -> bool: + # A function, not a module-level constant: test suites force the + # non-tornado path by setting `tenacity.tornado = None`, so the answer has + # to be computed from the live global every time it is asked for. + return tornado is not None + if t.TYPE_CHECKING: if sys.version_info >= (3, 11): @@ -786,7 +789,7 @@ def wrap(f: t.Callable[P, R]) -> _RetryDecorated[P, R]: ): r = AsyncRetrying(*dargs, **dkw) elif ( - _HAS_TORNADO + _has_tornado() and hasattr(tornado.gen, "is_coroutine_function") and tornado.gen.is_coroutine_function(f) ): @@ -801,7 +804,7 @@ def wrap(f: t.Callable[P, R]) -> _RetryDecorated[P, R]: from tenacity.asyncio import AsyncRetrying # noqa: E402 -if _HAS_TORNADO: +if _has_tornado(): from tenacity.tornadoweb import TornadoRetrying diff --git a/tests/test_tornado.py b/tests/test_tornado.py index 9233cca3..f20a41b4 100644 --- a/tests/test_tornado.py +++ b/tests/test_tornado.py @@ -15,9 +15,11 @@ import unittest from collections.abc import Generator from typing import Any +from unittest import mock from tornado import gen, testing +import tenacity from tenacity import RetryError, retry, stop_after_attempt, tornadoweb from .test_tenacity import NoIOErrorAfterCount @@ -73,6 +75,17 @@ def retryable(thing: NoIOErrorAfterCount) -> None: finally: gen.is_coroutine_function = old_attr + def test_tornado_set_to_none(self) -> None: + # Forcing the non-tornado path by nulling the module global is how + # downstream suites exercise installs without tornado. + with mock.patch.object(tenacity, "tornado", None): + + @retry(stop=stop_after_attempt(1)) + def retryable() -> int: + return 1 + + assert retryable() == 1 + if __name__ == "__main__": unittest.main()