Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix ref cycles #493

Merged
merged 5 commits into from
Nov 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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