Search code examples
pythonpython-typingpyright

Python: type hinting for decorating custom dataclass with inheritance


Here's the situation. I'm trying to reduce the amount of boilerplate code in my project. Here is a simplified version of the setup.

from __future__ import annotations

import dataclasses
import typing as t
import types

_Self = t.TypeVar("_Self", bound="A")

class A:
  """Base class for all classes"""
  @classmethod
  def create(cls: type[_Self], *args, **kwargs) -> _Self:
    return cls(*args, **kwargs)

  def do_something(self) -> None:
    """a 'void' method"""
    pass


class B(A):
  """A secondary base class for the future dataclasses; NOTE: inheritance from A!"""


T = t.TypeVar("T", bound=B)
TCallable = t.Callable[[type], type[T]]


def decorator(**kwargs) -> TCallable:
  """A decorator for reducing boilerplate"""
  def _decorator(cls: type) -> type[B]:
    return types.new_class(
      cls.__name__, 
      (
        dataclasses.dataclass(**{"frozen": True, "kw_only": True, **kwargs}(cls), 
        B,
      ),
    )
  return _decorator


@decorator(repr=False)
class Test:
   """This is an implementation of the dataclass, subclassed from B, subclassed from A"""
   name: str
   
    def __repr__(self) -> str:
      return self.name
    
    def do_something_else(self) -> None:
      self.do_something() # <- not recognized by pyright

The problem is that my static type checker is unable to recognize the methods from class A at the top of the resolution order from within instances of class Test.

I'm using Pylance/Pyright in VSCode.

Edited to correct error in decorator


Solution

  • First, I should note it can be implemented without the decorator like so:

    @dataclasses.dataclass(frozen = True, kw_only = True, repr=False)
    class Test(B):
       """This is an implementation of the dataclass, subclassed from B, subclassed from A"""
       name: str
       
       def __repr__(self) -> str:
          return self.name
        
       def do_something_else(self) -> None:
          self.do_something()
    

    But let's assume, that for some reason it needs to be there. This example assumes that you just wanted to avoid typing frozen and kw_only many times:

    from __future__ import annotations
    
    import dataclasses
    import typing as t
    
    _Self = t.TypeVar("_Self", bound="A")
    
    class A:
      """Base class for all classes"""
      @classmethod
      def create(cls: type[_Self], *args, **kwargs) -> _Self:
        return cls(*args, **kwargs)
    
      def do_something(self) -> None:
        """a 'void' method"""
        pass
    
    class B(A):
      """A secondary base class for the future dataclasses; NOTE: inheritance from A!"""
    
    @t.dataclass_transform()
    def decorator(**kwargs):
      def _decorator(cls: type) -> type[B]:
        return dataclasses.dataclass(**{"frozen": True, "kw_only": True, **kwargs})(cls)
      return _decorator
    
    
    @decorator(repr=False)
    class Test(B):
       """This is an implementation of the dataclass, subclassed from B, subclassed from A"""
       name: str
       
       def __repr__(self) -> str:
          return self.name
        
       def do_something_else(self) -> None:
          self.do_something()
    

    The inheritance aspect is impossible to add into the type currently, which is why I've left it inheriting from B. In general this is probably better practice anyway, as it makes the inheritance structure clearer. However, this may eventually be possible to type with Intersection. Hope this is useful!