From 920076096f697e3542312b7c7896e9a6892480d7 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Wed, 5 Aug 2026 23:15:29 +0200 Subject: [PATCH] fix: read the tornado global live instead of an import-time snapshot #679 replaced `if tornado:` with a `_HAS_TORNADO` bool computed once at import, to stop `truthy-bool` flagging the module object as always true. But only one of the two call sites was converted: the `retry()` decorator still dereferences the live `tornado` global right after testing the snapshot. The two desync as soon as anything reassigns the global. A/B verified against a2af454: `tenacity.tornado = None` followed by any `@tenacity.retry` decoration returned normally before, and now raises `AttributeError: 'NoneType' object has no attribute 'gen'`. Nulling the module global is the standard way a downstream suite exercises the non-tornado path without uninstalling tornado. Replace the constant with a `_has_tornado()` function so both call sites read the live global and cannot drift apart. As a bonus this needs no `redundant-expr` suppression: the check reads as a plain return rather than the left operand of an `and`. Change-Id: I6f0fbb9cad3d4e46599b0cae1b5b60378742fd1f --- ...ix-tornado-global-desync-8c2d40fa71b9e35a.yaml | 7 +++++++ tenacity/__init__.py | 15 +++++++++------ tests/test_tornado.py | 13 +++++++++++++ 3 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 releasenotes/notes/fix-tornado-global-desync-8c2d40fa71b9e35a.yaml 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()