Search code examples
pythongenericspython-typing

Why do I lose `__doc__` on a parameterized generic?


Issue

The docstring is lost when setting type parameters on a generic.

Minimal example:

from typing import TypeVar, Generic

T = TypeVar("T")

class Test(Generic[T]):
    """My docstring"""

assert Test.__doc__ == "My docstring"

This works fine as expected. However, this fails as __doc__ is now None:

assert Test[int].__doc__ == "My docstring"

Expectation

I would expect the docstring to still be the same. Afterall, it's still the "same" class.

Is there something I just don't understand about how Python's typing system making this intendet behavior?

Background

Using parameterized types in FastAPI, I'm losing the description (coming from the docstring) when generating OpenAPI specs.

I can fix this with a decorator, but that leads to more problems in my case with Pydantic's model creation. But that's besides the point. I'd like to understand why this is happening in the first place regardless of non-builtin tools.


Solution

  • Parameterising user-defined generic types with type arguments results in instances of typing._GenericAlias, which inherits from typing._BaseGenericAlias.

    >>> type(Test[int])
    <class 'typing._GenericAlias'>
    >>> type(Test[int]).mro()
    [<class 'typing._GenericAlias'>, <class 'typing._BaseGenericAlias'>, <class 'typing._Final'>, <class 'object'>]
    

    If you look at the source code, it shows you that attributes are relayed from Test to Test[int] via a __getattr__ defined on _BaseGenericAlias.

    This reveals 2 issues preventing __doc__ from being relayed from Test to Test[int]:

    • Since Test[int] is an instance of typing._GenericAlias, when you're accessing Test[int].__doc__, you are actually accessing typing._GenericAlias.__doc__; since all classes (including typing._GenericAlias) have a .__doc__ = None if no docstring is provided, __getattr__ is never called and you get None. If you add a dummy docstring to your Python standard library's typing.py,
      # typing.py
      ...
      
      class _GenericAlias(_BaseGenericAlias, _root=True):
          """
          HELLO
          """
          ...
      
      you can verify the behaviour:
      >>> Test[int].__doc__
      'HELLO'
      
    • Even if __getattr__ were called, the implementation deliberately avoids relaying dunder attributes (like __doc__):
        def __getattr__(self, attr):
            ...
      
            # We are careful for copy and pickle.
            # Also for simplicity we don't relay any dunder names
            if '__origin__' in self.__dict__ and not _is_dunder(attr):
                return getattr(self.__origin__, attr)
            raise AttributeError(attr)