python / cpython

The Python programming language
https://www.python.org
Other
63.27k stars 30.29k forks source link

ABCs with bogus __subclasses__ break instance/subclass checks for other ABCs #117255

Open hroncok opened 7 months ago

hroncok commented 7 months ago

Bug report

Bug description:

Consider this evil class:

import abc

class EvilABC(abc.ABC):
    __subclasses__ = ...

Now all instance/subclass checks for other ABCs fail:

>>> isinstance(..., abc.ABC)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
    isinstance(..., abc.ABC)
    ~~~~~~~~~~^^^^^^^^^^^^^^
  File "<frozen abc>", line 119, in __instancecheck__
  File "<frozen abc>", line 123, in __subclasscheck__
  File "<frozen abc>", line 123, in __subclasscheck__
TypeError: attribute of type 'ellipsis' is not callable

This happens because the recursive call to __subclasses__ in https://github.com/python/cpython/blob/v3.13.0a5/Lib/_py_abc.py#L141 trusts that __subclasses__ will always be callable and return an iterable (or a list in the C implementation).


You might argue that the EvilABC class is evil and whoever does that is doomed to be punished. However, it can easily be done by a mistake. This class has a broken __subclasses__ without explicitly defining it:

class Crossbreed(abc.ABCMeta, os.PathLike):
    ...
>>> isinstance("/", os.PathLike)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
    isinstance("/", os.PathLike)
    ~~~~~~~~~~^^^^^^^^^^^^^^^^^^
  File "<frozen abc>", line 119, in __instancecheck__
  File "<frozen abc>", line 123, in __subclasscheck__
  File "<frozen abc>", line 123, in __subclasscheck__
TypeError: unbound method type.__subclasses__() needs an argument

It is very surprising that by defining a class we can break checks for other classes and hence third party code, even from the standard library:

$ python3 -c 'import abc, os, subprocess
> class A(abc.ABCMeta, os.PathLike): pass
> subprocess.run(("echo",))'
Traceback (most recent call last):
  File "<string>", line 3, in <module>
  File "/usr/lib64/python3.12/subprocess.py", line 548, in run
    with Popen(*popenargs, **kwargs) as process:
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib64/python3.12/subprocess.py", line 1026, in __init__
    self._execute_child(args, executable, preexec_fn, close_fds,
  File "/usr/lib64/python3.12/subprocess.py", line 1802, in _execute_child
    elif isinstance(args, os.PathLike):
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<frozen abc>", line 119, in __instancecheck__
  File "<frozen abc>", line 123, in __subclasscheck__
  File "<frozen abc>", line 123, in __subclasscheck__
TypeError: unbound method type.__subclasses__() needs an argument

In my opinion, the __subclasses__ call should be guarded by a try-except and either:

See also https://discuss.python.org/t/abcmeta-change-isinstancecheck-of-additional-parent-class/19908

CPython versions tested on:

3.8, 3.9, 3.10, 3.11, 3.12, 3.13, CPython main branch

Operating systems tested on:

No response

blhsing commented 7 months ago

According to the documentation, the class.__subclasses__ method is supposed to work on any class. Since type itself is a class, calling type.__subclasses__() without an argument should be expected to work instead of producing TypeError: unbound method type.__subclasses__() needs an argument.

This can be fixed by making the first parameter of type.__subclasses__ optional, defaulting its value to type.

Demo of a quick fix:

import sys
import builtins

class NewType(type):
    def __new__(cls, *args, **kwargs):
        if len(args) == 1:
            return orig_type(*args)
        return super().__new__(cls, *args, **kwargs)

    def __subclasses__(cls=type):
        return orig_type.__subclasses__(cls)

orig_type = type
builtins.type = NewType
del sys.modules['_abc'], sys.modules['abc'], sys.modules['os']
import _abc
del _abc._abc_init
import abc
import os

class Crossbreed(abc.ABCMeta, os.PathLike):
    ...

print(os.PathLike.__subclasses__()) # outputs [<class '__main__.Crossbreed'>]