Skip to content

gh-106102: Fix MRO resolution for nested generic classes#108415

Open
sobolevn wants to merge 4 commits intopython:mainfrom
sobolevn:issue-106102
Open

gh-106102: Fix MRO resolution for nested generic classes#108415
sobolevn wants to merge 4 commits intopython:mainfrom
sobolevn:issue-106102

Conversation

@sobolevn
Copy link
Copy Markdown
Member

@sobolevn sobolevn commented Aug 24, 2023

I went with Generic in getattr(...), because not all types are safe with issubclass, some of them might raise TypeError there.

@sanderr
Copy link
Copy Markdown

sanderr commented Aug 24, 2023

If the use of issubclass is unsafe, wouldn't that be an issue here as well?

Comment thread Lib/typing.py Outdated
Comment on lines +1396 to +1400
if (
(isinstance(b, _BaseGenericAlias)
or Generic in getattr(b, '__mro__', ()))
and b is not self
):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is no safer than using isinstance(b, type) and issubclass(b, Generic), which would be much more idiomatic. It's true that an issubclass(x, y) call could potentially call type(y).__subclasscheck__, which could potentially do anything, including raising a TypeError. But in this case, we know what y is -- it's Generic. So calling issubclass(b, Generic) is safe, as we know that type(Generic) is type, and we know that type.__subclasscheck__ never raises TypeError.

If the call was issubclass(Generic, b), then it would be a different question.

Also, I think this conditional is just a bit too complex to be readable:

Suggested change
if (
(isinstance(b, _BaseGenericAlias)
or Generic in getattr(b, '__mro__', ()))
and b is not self
):
if b is self:
continue
if isinstance(b, _BaseGenericAlias):
return ()
if isinstance(b, type) and issubclass(b, Generic):
return ()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, looks like I broke a test with this suggestion, but I can't really say I understand how.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This happens because in a case of class L2(Generic[S], L[S]): ... we would get b as L[S] which is a GenericAlias, it is not a type. So, your condition would be False. But, it still has __mro__, so the original one will work correctly.

We can also add

if isinstance(b, GenericAlias):
     return ()

But, it does not feel safe to me.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you elaborate on that? As far as I can see L2 is currently defined as inheriting from List, which is a typing._GenericAlias. Conversely, the built-in list is a types.GenericAlias. And both are actually a type as far as I can tell.

I played around with it a bit on Python 3.11.3 (not this branch):

>>> import typing
>>> T = typing.TypeVar("T")
>>> class LowercaseList(list[T]): pass
...
>>> class UppercaseList(typing.List[T]): pass
...
>>> LowercaseList.__mro__
(<class '__main__.LowercaseList'>, <class 'list'>, <class 'object'>)
>>> UppercaseList.__mro__
(<class '__main__.UppercaseList'>, <class 'list'>, <class 'typing.Generic'>, <class 'object'>)
>>> issubclass(LowercaseList, typing.Generic)
False
>>> issubclass(UppercaseList, typing.Generic)
True
>>> isinstance(LowercaseList, type)
True
>>> isinstance(UppercaseList, type)
True
>>> import types
>>> isinstance(types.GenericAlias, type)
True

That said, I did notice that the built-in list does not have Generic in its bases (see LowercaseList.__mro__ above). So I don't see why we would want to return () for them in the first place as that would just result in the absence of Generic in the MRO. What happens when you just use my initial suggestion? Does it actually raise a TypeError? For which types?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

>>> from typing import TypeVar
>>> T = TypeVar('T')
>>> class L1(list[T]): ...
... 
>>> from typing import List
>>> class L2(List[T]): ...
... 
>>> isinstance(L1[int], type)
False
>>> isinstance(L2[int], type)
False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, I see. types.GenericAlias is not a subclass of type but it is supported in issubclass so that adding the isinstance(b, type) check changes the behavior.

Do I understand correctly that the patch as I initially proposed it doesn't actually result in a TypeError (except where the stable implementation does as well) because it doesn't really change when issubclass is called, and that your change is more a precaution against something that affects the current stable Python as well, i.e. more or less disjunct from #106102?

How about just trying the issubclass and catching the TypeError? It is generic in the sense that it doesn't assume anything about which objects are allowed in an issubclass check, unlike the isinstance(b, type) guard.

try:
    if issubclass(b, Generic):
       return ()
except TypeError:
    continue

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sanderr I tried it, didn't work :)

======================================================================
ERROR: test_multiple_inheritance_special_multiple_layers (test.test_typing.GenericTests.test_multiple_inheritance_special_multiple_layers)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/sobolev/Desktop/cpython/Lib/test/test_typing.py", line 4878, in test_multiple_inheritance_special_multiple_layers
    class L2(Generic[S], L[S]): ...
TypeError: Cannot create a consistent method resolution order (MRO) for bases Generic, L

----------------------------------------------------------------------
Ran 595 tests in 0.193s

FAILED (errors=1, skipped=1)
test test_typing failed
test_typing failed (1 error)

== Tests result: FAILURE ==

1 test failed:
    test_typing

Total duration: 321 ms
Tests result: FAILURE

Copy link
Copy Markdown

@sanderr sanderr Aug 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for that. I played around with the tests myself a bit and I think I understand it better now: L[S] is a types.GenericAlias with Generic in its MRO. The current logic (or my suggested patch) doesn't account for that possibility: it assumes that any type that has Generic in its MRO also has has typing._GenericAlias as its metaclass. But the L[S] type doesn't satisfy that assumption. Could you confirm this is the root cause of the issue, and the reason you took the approach of checking __mro__ instead of issubclass?

I wonder to what extent it's worth addressing this issue, because it seems to me that the assumption I mention above holds in all valid scenarios (Generic, subscripted or not, implies the corresponding metaclass) except for the typing.List etc types that somehow (I haven't figured out how exactly yet) get the corresponding built-in's metaclass when subclassing. Since this is a very niche issue that already exists on main and stable, and is contained to these deprecated types, what is Python's policy on fixing it if it clutters the implementation?

As a sanity check, I tried with the non-deprecated list instead of List and only had to change the order for a few of the assertions, and I think all of these make sense. This is on 026430f, so without the try-catch I suggested earlier.

diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py
index 0e8bdf1df0..6eafbeefd3 100644
--- a/Lib/test/test_typing.py
+++ b/Lib/test/test_typing.py
@@ -4856,27 +4856,27 @@ class B5(FromGenericAlias[S], GenericMixin[S], Generic[S]): ...
     def test_multiple_inheritance_special(self):
         S = TypeVar('S')
         class B(Generic[S]): ...
-        class C(List[int], B): ...
+        class C(list[int], B): ...
         self.assertEqual(C.__mro__, (C, list, B, Generic, object))

     def test_multiple_inheritance_special_multiple_layers(self):
         S = TypeVar('S')
-        class L(List[S]): ...
+        class L(list[S]): ...
         class M(Generic[S]): ...

         # Direct subclasses:
-        class D1(List[S], Generic[S]): ...
+        class D1(list[S], Generic[S]): ...
         self.assertEqual(D1.__mro__, (D1, list, Generic, object))

-        class D2(Generic[S], List[S]): ...
-        self.assertEqual(D2.__mro__, (D2, list, Generic, object))
+        class D2(Generic[S], list[S]): ...
+        self.assertEqual(D2.__mro__, (D2, Generic, list, object))

         # Nested subclasses:
         class L1(L[S], Generic[S]): ...
         self.assertEqual(L1.__mro__, (L1, L, list, Generic, object))

         class L2(Generic[S], L[S]): ...
-        self.assertEqual(L2.__mro__, (L2, L, list, Generic, object))
+        self.assertEqual(L2.__mro__, (L2, Generic, L, list, object))

         class L3(L[S], M[S], Generic[S]): ...
         self.assertEqual(L3.__mro__, (L3, L, list, M, Generic, object))

@bedevere-bot
Copy link
Copy Markdown

A Python core developer has requested some changes be made to your pull request before we can consider merging it. If you could please address their requests along with any other requests in other reviews from core developers that would be appreciated.

Once you have made the requested changes, please leave a comment on this pull request containing the phrase I have made the requested changes; please review again. I will then notify any core developers who have left a review that you're ready for them to take another look at this pull request.

Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
Comment thread Lib/typing.py
@AlexWaygood AlexWaygood dismissed their stale review August 24, 2023 20:58

requested changes were made

Comment thread Lib/typing.py
if isinstance(b, _BaseGenericAlias) and b is not self:
if b is self:
continue
if isinstance(b, _BaseGenericAlias):
Copy link
Copy Markdown
Member

@AlexWaygood AlexWaygood Aug 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both of these diffs would make all tests pass:

Suggested change
if isinstance(b, _BaseGenericAlias):
if isinstance(b, (_BaseGenericAlias, types.GenericAlias)):
Suggested change
if isinstance(b, _BaseGenericAlias):
if isinstance(b, types.GenericAlias):
b = b.__origin__
if isinstance(b, _BaseGenericAlias):

But, they can't both be right ;)

This indicates that we're missing some tests for some edge cases.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first breaks when you use list instead of List (see diff here) because it excludes Generic if any of the following base types is types.GenericAlias while we should only exclude it if it is both that and has Generic in its MRO.

The second might be correct, I can't say off the top of my head.

Not to say that I disagree on your conclusion that this indicates one or more test cases are missing.

@sobolevn
Copy link
Copy Markdown
Member Author

I cannot say that I fully understand G* tests I've added 🤔

@sanderr
Copy link
Copy Markdown

sanderr commented Aug 28, 2023

I cannot say that I fully understand G* tests I've added 🤔

To my (admittedly limited) understanding they look good: list[T] is not Generic, therefore the normal MRO resolution applies.

Comment thread Lib/typing.py
continue
if isinstance(b, _BaseGenericAlias):
return ()
if Generic in getattr(b, '__mro__', ()):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still don't think this is the proper solution here. Exactly which dunder attributes types.GenericAlias delegates to the "underlying" class has changed in the past. It could change again in the future. This feels to me like it's a fragile solution that could easily break.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can say even more: I don't yet understand what ideal __mro__ should look like for all the cases I've added in tests.

Comment thread Lib/test/test_typing.py
self.assertEqual(L1.__mro__, (L1, L, list, Generic, object))

class L2(Generic[S], L[S]): ...
self.assertEqual(L2.__mro__, (L2, L, list, Generic, object))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels wrong: Generic is before L in the bases, so it should be earlier in the MRO.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But L has Generic in its own bases, so it has to come first. Either that or reject it.

@github-actions
Copy link
Copy Markdown

This PR is stale because it has been open for 30 days with no activity.

@github-actions github-actions Bot added the stale Stale PR or inactive for long period of time. label Apr 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting review stale Stale PR or inactive for long period of time. topic-typing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants