Skip to content

Commit

Permalink
Fixed reference cycles (#493)
Browse files Browse the repository at this point in the history
This change is essentially the same fix as in fe8e3bc, applied in two additional functions that use the same pattern.
  • Loading branch information
ojw28 authored Nov 3, 2024
1 parent 28dafec commit eb1e869
Show file tree
Hide file tree
Showing 3 changed files with 77 additions and 27 deletions.
1 change: 1 addition & 0 deletions docs/versionhistory.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/agronholm/typeguard/pull/486>`_)
- Fixed ``TypeError`` when checking a class against ``type[Self]``
Expand Down
55 changes: 31 additions & 24 deletions src/typeguard/_checkers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")


Expand Down Expand Up @@ -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)}")

Expand Down
48 changes: 45 additions & 3 deletions tests/test_checkers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down

0 comments on commit eb1e869

Please sign in to comment.