From eb1e869e2c650db3d1f60a052f1426b52d3fb3cf Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Sun, 3 Nov 2024 00:21:26 +0000 Subject: [PATCH] Fixed reference cycles (#493) This change is essentially the same fix as in https://github.com/agronholm/typeguard/commit/fe8e3bc042b4721dd4d3daaf25a691fc540ba9e4, applied in two additional functions that use the same pattern. --- docs/versionhistory.rst | 1 + src/typeguard/_checkers.py | 55 +++++++++++++++++++++----------------- tests/test_checkers.py | 48 ++++++++++++++++++++++++++++++--- 3 files changed, 77 insertions(+), 27 deletions(-) diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst index 28b92a3..7127e61 100644 --- a/docs/versionhistory.rst +++ b/docs/versionhistory.rst @@ -9,6 +9,7 @@ This library adheres to - Dropped Python 3.8 support - Changed the signature of ``typeguard_ignore()`` to be compatible with ``typing.no_type_check()`` (PR by @jolaf) +- Avoid creating reference cycles when type checking uniontypes and classes - Fixed checking of variable assignments involving tuple unpacking (`#486 `_) - Fixed ``TypeError`` when checking a class against ``type[Self]`` diff --git a/src/typeguard/_checkers.py b/src/typeguard/_checkers.py index 1ab6ee2..5856cf9 100644 --- a/src/typeguard/_checkers.py +++ b/src/typeguard/_checkers.py @@ -432,16 +432,20 @@ def check_uniontype( memo: TypeCheckMemo, ) -> None: errors: dict[str, TypeCheckError] = {} - for type_ in args: - try: - check_type_internal(value, type_, memo) - return - except TypeCheckError as exc: - errors[get_type_name(type_)] = exc + try: + for type_ in args: + try: + check_type_internal(value, type_, memo) + return + except TypeCheckError as exc: + errors[get_type_name(type_)] = exc + + formatted_errors = indent( + "\n".join(f"{key}: {error}" for key, error in errors.items()), " " + ) + finally: + del errors # avoid creating ref cycle - formatted_errors = indent( - "\n".join(f"{key}: {error}" for key, error in errors.items()), " " - ) raise TypeCheckError(f"did not match any element in the union:\n{formatted_errors}") @@ -472,22 +476,25 @@ def check_class( check_typevar(value, expected_class, (), memo, subclass_check=True) elif get_origin(expected_class) is Union: errors: dict[str, TypeCheckError] = {} - for arg in get_args(expected_class): - if arg is Any: - return + try: + for arg in get_args(expected_class): + if arg is Any: + return - try: - check_class(value, type, (arg,), memo) - return - except TypeCheckError as exc: - errors[get_type_name(arg)] = exc - else: - formatted_errors = indent( - "\n".join(f"{key}: {error}" for key, error in errors.items()), " " - ) - raise TypeCheckError( - f"did not match any element in the union:\n{formatted_errors}" - ) + try: + check_class(value, type, (arg,), memo) + return + except TypeCheckError as exc: + errors[get_type_name(arg)] = exc + else: + formatted_errors = indent( + "\n".join(f"{key}: {error}" for key, error in errors.items()), " " + ) + raise TypeCheckError( + f"did not match any element in the union:\n{formatted_errors}" + ) + finally: + del errors # avoid creating ref cycle elif not issubclass(value, expected_class): # type: ignore[arg-type] raise TypeCheckError(f"is not a subclass of {qualified_name(expected_class)}") diff --git a/tests/test_checkers.py b/tests/test_checkers.py index 526e94f..8a2ef9f 100644 --- a/tests/test_checkers.py +++ b/tests/test_checkers.py @@ -816,8 +816,6 @@ def test_union_fail(self, annotation, value): reason="Test relies on CPython's reference counting behavior", ) def test_union_reference_leak(self): - leaked = True - class Leak: def __del__(self): nonlocal leaked @@ -827,19 +825,63 @@ def inner1(): leak = Leak() # noqa: F841 check_type(b"asdf", Union[str, bytes]) + leaked = True inner1() assert not leaked + def inner2(): + leak = Leak() # noqa: F841 + check_type(b"asdf", Union[bytes, str]) + leaked = True + inner2() + assert not leaked - def inner2(): + def inner3(): leak = Leak() # noqa: F841 with pytest.raises(TypeCheckError, match="any element in the union:"): check_type(1, Union[str, bytes]) + leaked = True + inner3() + assert not leaked + + @pytest.mark.skipif( + sys.implementation.name != "cpython", + reason="Test relies on CPython's reference counting behavior", + ) + @pytest.mark.skipif(sys.version_info < (3, 10), reason="UnionType requires 3.10") + def test_uniontype_reference_leak(self): + class Leak: + def __del__(self): + nonlocal leaked + leaked = False + + def inner1(): + leak = Leak() # noqa: F841 + check_type(b"asdf", str | bytes) + + leaked = True + inner1() + assert not leaked + + def inner2(): + leak = Leak() # noqa: F841 + check_type(b"asdf", bytes | str) + + leaked = True inner2() assert not leaked + def inner3(): + leak = Leak() # noqa: F841 + with pytest.raises(TypeCheckError, match="any element in the union:"): + check_type(1, Union[str, bytes]) + + leaked = True + inner3() + assert not leaked + class TestTypevar: def test_bound(self):